tledger 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,15 +2,39 @@ import { Buffer } from "node:buffer";
2
2
 
3
3
  import sharp from "sharp";
4
4
 
5
+ import { buildBurnDayBins, buildUsageTrend } from "./token-ledger-trend.mjs";
6
+ import { chooseBinSize } from "./token-ledger-trend-terminal.mjs";
5
7
  import {
6
- buildBurnDayBins,
7
- buildUsageTrend,
8
- weeklyQuotaObservations,
9
- } from "./token-ledger-trend.mjs";
10
- import { FAST_MODE_MULTIPLIER } from "./token-ledger-rates.mjs";
11
- import { buildActualTokenBins } from "./token-ledger-trend-terminal.mjs";
12
- import { buildCacheReportData } from "./token-ledger-cache-image.mjs";
13
- import { usageBucketsInRange } from "../lib/token-ledger-usage.mjs";
8
+ calculateCodexPurchasedCredits,
9
+ codexCreditMultiplier,
10
+ isFastServiceTier,
11
+ } from "../lib/token-ledger-rates.mjs";
12
+ import {
13
+ compact,
14
+ escapeXml,
15
+ fastShade,
16
+ shiftCalendarDate,
17
+ svgRect,
18
+ svgText,
19
+ textWidth,
20
+ truncateText,
21
+ TREND_IMAGE_MODEL_COLORS,
22
+ } from "./token-ledger-image-primitives.mjs";
23
+ import {
24
+ buildTrendReportViewModel,
25
+ zonedMidnight,
26
+ } from "./token-ledger-report-data.mjs";
27
+ export {
28
+ compact,
29
+ escapeXml,
30
+ fastShade,
31
+ shiftCalendarDate,
32
+ svgRect,
33
+ svgText,
34
+ textWidth,
35
+ truncateText,
36
+ TREND_IMAGE_MODEL_COLORS,
37
+ };
14
38
 
15
39
  const MODEL_ORDER = [
16
40
  "Luna",
@@ -25,25 +49,11 @@ const MODEL_ORDER = [
25
49
  "Unattributed",
26
50
  ];
27
51
 
28
- // Dark-surface categorical palette; the co-occurring set and the stack-order
29
- // adjacency both pass CVD, normal-vision, and contrast checks on #0e1420.
30
- export const TREND_IMAGE_MODEL_COLORS = {
31
- Luna: "#3b82f6",
32
- Sol: "#10a394",
33
- Terra: "#8b7cf6",
34
- "GPT-5.5": "#d55181",
35
- "GPT-5.4": "#0891b2",
36
- Daybreak: "#16a34a",
37
- "Auto review": "#e5484d",
38
- Other: "#64748b",
39
- Unknown: "#64748b",
40
- Unattributed: "#475569",
41
- };
42
-
43
52
  const COLORS = {
44
53
  background: "#0e1420",
45
54
  panel: "#151d2c",
46
55
  panelBorder: "#273246",
56
+ separator: "rgba(119,131,154,.22)",
47
57
  meterPanel: "#1b1712",
48
58
  meterPanelBorder: "rgba(246,183,60,.4)",
49
59
  ink: "#f2f5fa",
@@ -51,82 +61,178 @@ const COLORS = {
51
61
  muted: "#77839a",
52
62
  grid: "#1c2534",
53
63
  baseline: "#33405a",
54
- rule: "rgba(255,255,255,.1)",
55
64
  track: "rgba(255,255,255,.09)",
56
65
  projectTrack: "rgba(255,255,255,.07)",
57
66
  line: "#f6b73c",
58
67
  meterAxis: "#cf9a37",
59
- chipFill: "#151d2c",
60
68
  leftAxis: "#7ea2f0",
69
+ cache: "#22c58f",
70
+ uncached: "#b0483f",
61
71
  deltaUp: "#7fb37a",
62
- deltaUpFill: "rgba(127,179,122,.14)",
63
72
  deltaDown: "#e08a86",
64
- deltaDownFill: "rgba(217,83,79,.16)",
73
+ warn: "#f0a35e",
65
74
  remainderBar: "#475569",
66
75
  onFill: "rgba(255,255,255,.82)",
67
- cached: "#2ec4a1",
68
- uncached: "#d88362",
69
- weighted: "#c7d2e8",
70
- cacheTrack: "#202a3a",
71
76
  };
72
77
 
73
- const FONT_FAMILY = "system-ui, -apple-system, 'Segoe UI', sans-serif";
74
- const MONO_FAMILY = "ui-monospace, Menlo, monospace";
75
78
  const FAST_MODE_LABEL_COLOR = "#a78bfa";
76
79
  const MIN_BAR_WIDTH = 26;
77
- const METER_PANEL_HEADING = "WEEKLY LIMIT · PACE & RUNWAY";
78
-
79
- export function escapeXml(value) {
80
- return String(value)
81
- .replaceAll("&", "&")
82
- .replaceAll("<", "&lt;")
83
- .replaceAll(">", "&gt;")
84
- .replaceAll('"', "&quot;")
85
- .replaceAll("'", "&apos;");
80
+ const PROJECT_PANEL_ROW_COUNT = 5;
81
+
82
+ function pct(value) {
83
+ if (!Number.isFinite(value)) return "—";
84
+ if (value > 0 && value < 0.05) return "<0.1%";
85
+ return `${value.toFixed(1)}%`;
86
86
  }
87
87
 
88
- export function compact(value, digits = 2) {
88
+ // Meter percentages read as whole numbers when they are whole ("0%", "62%").
89
+ function meterPct(value) {
89
90
  if (!Number.isFinite(value)) return "—";
90
- const absolute = Math.abs(value);
91
- const units = [
92
- [1_000_000_000, "B"],
93
- [1_000_000, "M"],
94
- [1_000, "K"],
95
- ];
96
- for (let index = 0; index < units.length; index += 1) {
97
- const [divisor, suffix] = units[index];
98
- if (absolute < divisor) continue;
99
- const scaled = value / divisor;
100
- const magnitude = Math.abs(scaled);
101
- const precision = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : digits;
102
- // Values that round to 1000 of a unit belong to the next unit up
103
- // (999,999 → 1.00M, not 1000K).
104
- if (index > 0 && Number(magnitude.toFixed(precision)) >= 1_000) {
105
- return compact(Math.sign(value) * divisor * 1_000, digits);
106
- }
107
- return `${scaled.toFixed(precision)}${suffix}`;
108
- }
109
- return Math.round(value).toLocaleString("en-US");
91
+ const rounded = Math.round(value);
92
+ if (Math.abs(value - rounded) < 0.05) return `${rounded}%`;
93
+ return `${value.toFixed(1)}%`;
94
+ }
95
+
96
+ // Advance width of a letter-spaced label; SVG letter-spacing adds per glyph.
97
+ function spacedWidth(text, size, weight, spacing) {
98
+ return textWidth(text, size, weight) + (Number(spacing) || 0) * String(text).length;
99
+ }
100
+
101
+ function deltaLabel(value) {
102
+ if (!Number.isFinite(value)) return "—";
103
+ return `${value >= 0 ? "+" : "−"}${Math.abs(value).toFixed(1)}%`;
104
+ }
105
+
106
+ function approximateLabel(value, estimated) {
107
+ return estimated && value !== "—" ? `≈${value}` : value;
108
+ }
109
+
110
+ function durationLabel(ms) {
111
+ if (!Number.isFinite(ms) || ms <= 0) return "—";
112
+ const hours = ms / 3_600_000;
113
+ if (hours >= 36) return `${Math.round(ms / 86_400_000)} days`;
114
+ if (hours >= 21) return "1 day";
115
+ return `${Math.max(1, Math.round(hours))} ${Math.max(1, Math.round(hours)) === 1 ? "hour" : "hours"}`;
110
116
  }
111
117
 
112
- function percent(value) {
113
- const numeric = Number(value);
114
- if (!Number.isFinite(numeric)) return "—";
115
- if (numeric > 0 && numeric < 0.1) return "<0.1%";
116
- return `${numeric.toFixed(1)}%`;
118
+ function barTotalLabelPlacement({
119
+ centerX,
120
+ labelWidth,
121
+ slotLeft,
122
+ slotRight,
123
+ resetXs,
124
+ }) {
125
+ const resetPadding = 5;
126
+ const resetGap = 8;
127
+ const boundsFor = (x, anchor) => {
128
+ if (anchor === "end") return { left: x - labelWidth, right: x };
129
+ if (anchor === "start") return { left: x, right: x + labelWidth };
130
+ return { left: x - labelWidth / 2, right: x + labelWidth / 2 };
131
+ };
132
+ const isClear = ({ left, right }) =>
133
+ left >= slotLeft &&
134
+ right <= slotRight &&
135
+ !resetXs.some((resetX) =>
136
+ resetX >= left - resetPadding && resetX <= right + resetPadding
137
+ );
138
+ const centered = {
139
+ x: centerX,
140
+ anchor: "middle",
141
+ placement: "centered",
142
+ };
143
+ if (isClear(boundsFor(centered.x, centered.anchor))) return centered;
144
+
145
+ const candidates = [];
146
+ for (const resetX of resetXs) {
147
+ for (const candidate of [
148
+ { x: resetX - resetGap, anchor: "end", placement: "reset-left" },
149
+ { x: resetX + resetGap, anchor: "start", placement: "reset-right" },
150
+ ]) {
151
+ const bounds = boundsFor(candidate.x, candidate.anchor);
152
+ if (!isClear(bounds)) continue;
153
+ candidates.push({
154
+ ...candidate,
155
+ distance: Math.abs((bounds.left + bounds.right) / 2 - centerX),
156
+ });
157
+ }
158
+ }
159
+ candidates.sort((left, right) => left.distance - right.distance);
160
+ if (candidates.length) {
161
+ return {
162
+ x: candidates[0].x,
163
+ anchor: candidates[0].anchor,
164
+ placement: candidates[0].placement,
165
+ };
166
+ }
167
+ return { ...centered, placement: "centered-over-reset" };
117
168
  }
118
169
 
119
- function meterLabel(value) {
120
- const numeric = Number(value);
121
- if (!Number.isFinite(numeric)) return "—";
122
- return `${numeric.toFixed(Number.isInteger(numeric) ? 0 : 1)}%`;
170
+ function fastRateSummary(snapshot, bounds, effectiveEndMs, events = null) {
171
+ let standardCardCredits = 0;
172
+ let fastCardCredits = 0;
173
+ let unratedTokens = 0;
174
+ const multipliers = new Set();
175
+ const sourceEvents = events ?? snapshot.events ?? [];
176
+ for (const event of sourceEvents) {
177
+ const timestampMs = new Date(event?.timestamp).getTime();
178
+ if (
179
+ !Number.isFinite(timestampMs) ||
180
+ timestampMs < bounds.start.getTime() ||
181
+ timestampMs >= effectiveEndMs ||
182
+ !isFastServiceTier(event?.serviceTier)
183
+ ) {
184
+ continue;
185
+ }
186
+ const tokens = Math.max(0, Number(event.totalTokens) || 0);
187
+ const model = event.rateCardModel ?? event.model;
188
+ const multiplier = codexCreditMultiplier(model, event.serviceTier);
189
+ const standardCredits = calculateCodexPurchasedCredits({
190
+ model,
191
+ serviceTier: null,
192
+ usage: event,
193
+ });
194
+ const fastCredits = calculateCodexPurchasedCredits({
195
+ model,
196
+ serviceTier: event.serviceTier,
197
+ usage: event,
198
+ });
199
+ if (
200
+ multiplier === null ||
201
+ !Number.isFinite(standardCredits) ||
202
+ !(standardCredits > 0) ||
203
+ !Number.isFinite(fastCredits)
204
+ ) {
205
+ unratedTokens += tokens;
206
+ continue;
207
+ }
208
+ standardCardCredits += standardCredits;
209
+ fastCardCredits += fastCredits;
210
+ multipliers.add(multiplier);
211
+ }
212
+ const sortedMultipliers = [...multipliers].sort((left, right) => left - right);
213
+ return {
214
+ unratedTokens,
215
+ effectiveMultiplier: standardCardCredits > 0
216
+ ? fastCardCredits / standardCardCredits
217
+ : null,
218
+ minimumMultiplier: sortedMultipliers[0] ?? null,
219
+ maximumMultiplier: sortedMultipliers.at(-1) ?? null,
220
+ mixedMultipliers: sortedMultipliers.length > 1,
221
+ };
123
222
  }
124
223
 
125
- function niceCeiling(value) {
224
+ // Darker step of the same hue; retained for terminal parity and callers that
225
+ // still shade fast-mode swatches (the report itself uses the hatch pattern).
226
+ // Ceiling scale for the daily chart. Tighter than 1–2–5 so a 2.39B peak lands
227
+ // on a 3.00B axis instead of 5.00B.
228
+ const NICE_CEILING_STEPS = [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10];
229
+ export function reportCeiling(value) {
126
230
  if (!(value > 0)) return 1;
127
- const magnitude = 10 ** Math.floor(Math.log10(value));
128
- const normalized = value / magnitude;
129
- const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
231
+ const target = value * 1.12;
232
+ const magnitude = 10 ** Math.floor(Math.log10(target));
233
+ const normalized = target / magnitude;
234
+ const step =
235
+ NICE_CEILING_STEPS.find((candidate) => normalized <= candidate) ?? 10;
130
236
  return step * magnitude;
131
237
  }
132
238
 
@@ -144,62 +250,53 @@ function styleForModel(model) {
144
250
  return TREND_IMAGE_MODEL_COLORS[model] ?? TREND_IMAGE_MODEL_COLORS.Other;
145
251
  }
146
252
 
147
- // Darker step of the same hue, used for the fast-mode share of a segment.
148
- export function fastShade(hexColor) {
149
- const match = /^#([0-9a-f]{6})$/i.exec(String(hexColor));
150
- if (!match) return hexColor;
151
- const channels = [0, 2, 4].map((offset) =>
152
- Math.round(parseInt(match[1].slice(offset, offset + 2), 16) * 0.62),
153
- );
154
- return `#${channels.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
155
- }
156
-
157
- function sortedModelEntries(values) {
158
- return [...values.entries()]
159
- .filter(([, value]) => value > 0)
160
- .sort(([left], [right]) => modelSort(left, right));
161
- }
162
-
163
- function dateParts(dateString) {
164
- return dateString.split("-").map(Number);
165
- }
166
-
167
- function dateStringFromParts(year, month, day) {
168
- return [year, month, day]
169
- .map((value, index) =>
170
- index === 0 ? String(value) : String(value).padStart(2, "0"),
171
- )
172
- .join("-");
253
+ function truncateToWidth(text, maxWidth, size, weight = 400) {
254
+ const value = String(text);
255
+ if (textWidth(value, size, weight) <= maxWidth) return value;
256
+ let kept = value;
257
+ while (kept.length > 1 && textWidth(`${kept}…`, size, weight) > maxWidth) {
258
+ kept = kept.slice(0, -1);
259
+ }
260
+ return `${kept.trimEnd()}…`;
173
261
  }
174
262
 
175
- export function shiftCalendarDate(dateString, amount) {
176
- const [year, month, day] = dateParts(dateString);
177
- const date = new Date(Date.UTC(year, month - 1, day + amount));
178
- return dateStringFromParts(
179
- date.getUTCFullYear(),
180
- date.getUTCMonth() + 1,
181
- date.getUTCDate(),
182
- );
263
+ function fitTextSize(text, maxWidth, preferredSize, minimumSize, weight = 400) {
264
+ let size = preferredSize;
265
+ while (size > minimumSize && textWidth(text, size, weight) > maxWidth) {
266
+ size = Math.max(minimumSize, size - 0.25);
267
+ }
268
+ return size;
183
269
  }
184
270
 
185
- function timeZoneOffsetMs(instant, timeZone) {
186
- const parts = new Intl.DateTimeFormat("en-US", {
187
- timeZone,
188
- timeZoneName: "longOffset",
189
- }).formatToParts(instant);
190
- const value = parts.find((part) => part.type === "timeZoneName")?.value ?? "GMT";
191
- if (value === "GMT") return 0;
192
- const match = value.match(/^GMT([+-])(\d{2}):?(\d{2})?$/);
193
- if (!match) return 0;
194
- const minutes = Number(match[2]) * 60 + Number(match[3] || 0);
195
- return (match[1] === "+" ? 1 : -1) * minutes * 60 * 1_000;
271
+ function svgLine(x1, y1, x2, y2, attrs = {}) {
272
+ const pieces = [
273
+ `x1="${Number(x1).toFixed(2)}"`,
274
+ `y1="${Number(y1).toFixed(2)}"`,
275
+ `x2="${Number(x2).toFixed(2)}"`,
276
+ `y2="${Number(y2).toFixed(2)}"`,
277
+ ];
278
+ for (const [key, value] of Object.entries(attrs)) {
279
+ if (value === null || value === undefined) continue;
280
+ pieces.push(`${key}="${value}"`);
281
+ }
282
+ return `<line ${pieces.join(" ")}/>`;
196
283
  }
197
284
 
198
- function zonedMidnight(dateString, timeZone) {
199
- const [year, month, day] = dateParts(dateString);
200
- const utcGuess = Date.UTC(year, month - 1, day);
201
- const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess), timeZone));
202
- return new Date(utcGuess - timeZoneOffsetMs(first, timeZone));
285
+ function chip(x, y, label, { fill, stroke, color, size = 11.5, weight = 700, anchor = "start", mono = false }) {
286
+ const width = textWidth(label, size, weight) + 14;
287
+ const left = anchor === "middle" ? x - width / 2 : anchor === "end" ? x - width : x;
288
+ return {
289
+ width,
290
+ markup: [
291
+ svgRect(left, y - 13, width, 19, {
292
+ rx: 4,
293
+ fill: fill ?? "none",
294
+ stroke: stroke ?? null,
295
+ "stroke-width": stroke ? 1 : null,
296
+ }),
297
+ svgText({ x: left + 7, y: y + 1, value: label, fill: color, size, weight, mono }),
298
+ ].join("\n"),
299
+ };
203
300
  }
204
301
 
205
302
  function localDateLabel(dateString, timeZone) {
@@ -217,17 +314,8 @@ function localWeekdayLabel(dateString, timeZone) {
217
314
  }).format(zonedMidnight(dateString, timeZone));
218
315
  }
219
316
 
220
- function timestampDateLabel(timestampMs, timeZone) {
221
- if (!Number.isFinite(timestampMs)) return "unknown";
222
- return new Intl.DateTimeFormat("en-US", {
223
- timeZone,
224
- month: "short",
225
- day: "numeric",
226
- }).format(new Date(timestampMs));
227
- }
228
-
229
- function timestampReadLabel(timestampMs, timeZone) {
230
- if (!Number.isFinite(timestampMs)) return "unknown";
317
+ function shortDateTimeLabel(timestampMs, timeZone) {
318
+ if (!Number.isFinite(timestampMs)) return "unknown time";
231
319
  return new Intl.DateTimeFormat("en-US", {
232
320
  timeZone,
233
321
  month: "short",
@@ -237,8 +325,8 @@ function timestampReadLabel(timestampMs, timeZone) {
237
325
  }).format(new Date(timestampMs));
238
326
  }
239
327
 
240
- function timestampTimeLabel(timestampMs, timeZone) {
241
- if (!Number.isFinite(timestampMs)) return "unknown";
328
+ function timeOnlyLabel(timestampMs, timeZone) {
329
+ if (!Number.isFinite(timestampMs)) return "unknown time";
242
330
  return new Intl.DateTimeFormat("en-US", {
243
331
  timeZone,
244
332
  hour: "numeric",
@@ -248,1778 +336,1991 @@ function timestampTimeLabel(timestampMs, timeZone) {
248
336
 
249
337
  function binDateLabel(bin, timeZone) {
250
338
  const start = localDateLabel(bin.startDateString, timeZone);
251
- const lastDate = shiftCalendarDate(bin.endDateString, -1);
339
+ const lastDate = bin.lastDateString;
252
340
  if (lastDate === bin.startDateString) return start;
253
- return `${start}–${localDateLabel(lastDate, timeZone).replace(/^[A-Za-z]+ /, "")}`;
341
+ const end = localDateLabel(lastDate, timeZone);
342
+ if (bin.startDateString.slice(0, 4) !== lastDate.slice(0, 4)) {
343
+ return `${start} ${bin.startDateString.slice(0, 4)}–${end} ${lastDate.slice(0, 4)}`;
344
+ }
345
+ const [startMonth] = start.split(" ");
346
+ const [endMonth, endDay] = end.split(" ");
347
+ return startMonth === endMonth ? `${start}–${endDay}` : `${start}–${end}`;
254
348
  }
255
349
 
256
- // Rough sans-serif advance widths in em units, for placing inline runs
257
- // (value + chip, legend items, pace rows). SVG has no flow layout.
258
- export function textWidth(text, size, weight = 400) {
259
- let units = 0;
260
- for (const character of String(text)) {
261
- if (/[il.,:;'|!]/.test(character)) units += 0.3;
262
- else if (/[Ijtfr\-()[\] ]/.test(character)) units += 0.37;
263
- else if (/[mwMW@%]/.test(character)) units += 0.92;
264
- else if (/[A-Z]/.test(character)) units += 0.7;
265
- else if (/[0-9+±×−]/.test(character)) units += 0.58;
266
- else units += 0.55;
267
- }
268
- return units * size * (weight >= 700 ? 1.05 : 1);
350
+ function labelEvery(binCount) {
351
+ if (binCount <= 14) return 1;
352
+ if (binCount <= 20) return 2;
353
+ return 3;
269
354
  }
270
355
 
271
- export function truncateText(text, maxWidth, size, weight = 400) {
272
- let value = String(text ?? "").replace(/\.{3,}/g, "…");
273
- if (!(maxWidth > 0) || textWidth(value, size, weight) <= maxWidth) return value;
274
- if (value.includes("…")) {
275
- const leading = `${value.split("…", 1)[0].trimEnd()}…`;
276
- if (textWidth(leading, size, weight) <= maxWidth) return leading;
277
- value = leading;
356
+ // Keep the requested cadence, but drop a candidate when its actual label
357
+ // bounds would crowd the preceding label. The final bin is always retained so
358
+ // a partial marker and its through-time remain visible.
359
+ function selectDateLabelIndices(
360
+ bins,
361
+ {
362
+ timeZone,
363
+ slotWidth,
364
+ labelStep,
365
+ labelSize,
366
+ labelForBin = (bin) => binDateLabel(bin, timeZone),
367
+ gap = 8,
368
+ },
369
+ ) {
370
+ const finalIndex = bins.length - 1;
371
+ if (finalIndex < 0 || !(slotWidth > 0)) return new Set();
372
+ const cadence = Math.max(1, Math.floor(Number(labelStep) || 1));
373
+ const candidates = [];
374
+ for (let index = 0; index < bins.length; index += 1) {
375
+ if (index % cadence !== 0 && index !== finalIndex) continue;
376
+ const width = textWidth(labelForBin(bins[index]), labelSize);
377
+ const center = (index + 0.5) * slotWidth;
378
+ candidates.push({
379
+ index,
380
+ left: center - width / 2,
381
+ right: center + width / 2,
382
+ });
278
383
  }
279
- const ellipsis = "…";
280
- const ellipsisWidth = textWidth(ellipsis, size, weight);
281
- if (ellipsisWidth >= maxWidth) return ellipsis;
282
-
283
- const characters = [...value];
284
- let low = 0;
285
- let high = characters.length;
286
- while (low < high) {
287
- const middle = Math.ceil((low + high) / 2);
288
- const candidate = `${characters.slice(0, middle).join("")}${ellipsis}`;
289
- if (textWidth(candidate, size, weight) <= maxWidth) low = middle;
290
- else high = middle - 1;
384
+
385
+ const selected = [];
386
+ for (const candidate of candidates) {
387
+ while (
388
+ candidate.index === finalIndex &&
389
+ selected.length > 0 &&
390
+ candidate.left - selected.at(-1).right < gap
391
+ ) {
392
+ selected.pop();
393
+ }
394
+ if (
395
+ selected.length > 0 &&
396
+ candidate.left - selected.at(-1).right < gap
397
+ ) {
398
+ continue;
399
+ }
400
+ selected.push(candidate);
291
401
  }
292
- return `${characters.slice(0, low).join("").trimEnd()}${ellipsis}`;
402
+ return new Set(selected.map(({ index }) => index));
293
403
  }
294
404
 
295
- export function svgText({
296
- x,
297
- y,
298
- value,
299
- fill = COLORS.ink,
300
- size = 12,
301
- weight = 400,
302
- anchor = "start",
303
- spacing = null,
304
- opacity = null,
305
- mono = false,
306
- }) {
307
- const spacingAttr = spacing ? ` letter-spacing="${spacing}"` : "";
308
- const opacityAttr = opacity !== null ? ` opacity="${opacity}"` : "";
309
- const family = mono ? MONO_FAMILY : FONT_FAMILY;
310
- return `<text x="${x}" y="${y}" fill="${fill}" font-family="${family}" font-size="${size}px" font-weight="${weight}" text-anchor="${anchor}"${spacingAttr}${opacityAttr}>${escapeXml(value)}</text>`;
405
+ function hourLabel(timestampMs, timeZone, withZone = false) {
406
+ if (!Number.isFinite(timestampMs)) return "unknown time";
407
+ return new Intl.DateTimeFormat("en-US", {
408
+ timeZone,
409
+ hour: "numeric",
410
+ minute: "2-digit",
411
+ ...(withZone ? { timeZoneName: "short" } : {}),
412
+ }).format(new Date(timestampMs));
311
413
  }
312
414
 
313
- export function svgRect(x, y, width, height, attrs = {}) {
314
- const pieces = [
315
- `x="${Number(x).toFixed(2)}"`,
316
- `y="${Number(y).toFixed(2)}"`,
317
- `width="${Math.max(0, Number(width)).toFixed(2)}"`,
318
- `height="${Math.max(0, Number(height)).toFixed(2)}"`,
319
- ];
320
- for (const [key, value] of Object.entries(attrs)) {
321
- if (value === null || value === undefined) continue;
322
- pieces.push(`${key}="${value}"`);
415
+ function hourlyChartRows(rows, timeZone) {
416
+ const baseLabels = rows.map((row) => hourLabel(row.startMs, timeZone));
417
+ const labelCounts = new Map();
418
+ for (const label of baseLabels) {
419
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
323
420
  }
324
- return `<rect ${pieces.join(" ")}/>`;
421
+ return rows.map((row, index) => ({
422
+ ...row,
423
+ startDateString: row.dateString,
424
+ lastDateString: row.dateString,
425
+ hourStartMs: row.startMs,
426
+ hourEndMs: row.endMs,
427
+ hourLabel: labelCounts.get(baseLabels[index]) > 1
428
+ ? hourLabel(row.startMs, timeZone, true)
429
+ : baseLabels[index],
430
+ }));
325
431
  }
326
432
 
327
- // Fritsch–Carlson monotone cubic through the points; keeps the meter line
328
- // smooth without overshooting between observations.
329
- function monotonePath(points) {
330
- const count = points.length;
331
- if (count < 2) return "";
332
- const round = (value) => Math.round(value * 100) / 100;
333
- const dx = [];
334
- const slope = [];
335
- for (let index = 0; index < count - 1; index += 1) {
336
- dx[index] = Math.max(0.01, points[index + 1].x - points[index].x);
337
- slope[index] = (points[index + 1].y - points[index].y) / dx[index];
338
- }
339
- const tangent = [slope[0]];
340
- for (let index = 1; index < count - 1; index += 1) {
341
- tangent.push(
342
- slope[index - 1] * slope[index] <= 0
343
- ? 0
344
- : (slope[index - 1] + slope[index]) / 2,
345
- );
346
- }
347
- tangent.push(slope[count - 2]);
348
- for (let index = 0; index < count - 1; index += 1) {
349
- if (slope[index] === 0) {
350
- tangent[index] = 0;
351
- tangent[index + 1] = 0;
352
- continue;
353
- }
354
- const alpha = tangent[index] / slope[index];
355
- const beta = tangent[index + 1] / slope[index];
356
- const magnitude = alpha * alpha + beta * beta;
357
- if (magnitude > 9) {
358
- const tau = 3 / Math.sqrt(magnitude);
359
- tangent[index] = tau * alpha * slope[index];
360
- tangent[index + 1] = tau * beta * slope[index];
433
+ export function buildBurnHourBins(trend, hourRows, startMs, endMs) {
434
+ const bins = hourRows.map((row) => ({
435
+ ...row,
436
+ values: new Map(),
437
+ totalPercent: 0,
438
+ approximate: false,
439
+ }));
440
+ for (const interval of trend?.burnIntervals ?? []) {
441
+ const intervalStartMs = Number(interval.startMs);
442
+ const intervalEndMs = Number(interval.endMs);
443
+ if (!Number.isFinite(intervalStartMs) || !Number.isFinite(intervalEndMs)) continue;
444
+ const fullDurationMs = intervalEndMs - intervalStartMs;
445
+ if (!(fullDurationMs > 0)) continue;
446
+ const clippedStartMs = Math.max(startMs, intervalStartMs);
447
+ const clippedEndMs = Math.min(endMs, intervalEndMs);
448
+ if (!(clippedEndMs > clippedStartMs)) continue;
449
+ for (const bin of bins) {
450
+ const overlapMs = Math.min(bin.endMs, clippedEndMs) -
451
+ Math.max(bin.startMs, clippedStartMs);
452
+ if (!(overlapMs > 0)) continue;
453
+ // A burn interval can straddle the report window. Allocate only its
454
+ // in-window elapsed share; dividing by the clipped duration would
455
+ // incorrectly move the entire interval's drain into this chart.
456
+ const fraction = overlapMs / fullDurationMs;
457
+ if (interval.spansLongGap) bin.approximate = true;
458
+ for (const [model, burnPoints] of Object.entries(interval.contributions ?? {})) {
459
+ const share = Number(burnPoints) * fraction;
460
+ if (!(share > 0)) continue;
461
+ bin.values.set(model, (bin.values.get(model) ?? 0) + share);
462
+ bin.totalPercent += share;
463
+ }
361
464
  }
362
465
  }
363
- let path = `M ${round(points[0].x)} ${round(points[0].y)}`;
364
- for (let index = 0; index < count - 1; index += 1) {
365
- const h = dx[index];
366
- path += ` C ${round(points[index].x + h / 3)} ${round(points[index].y + (tangent[index] * h) / 3)}` +
367
- ` ${round(points[index + 1].x - h / 3)} ${round(points[index + 1].y - (tangent[index + 1] * h) / 3)}` +
368
- ` ${round(points[index + 1].x)} ${round(points[index + 1].y)}`;
466
+ const totals = new Map();
467
+ let totalPercent = 0;
468
+ for (const bin of bins) {
469
+ for (const [model, value] of bin.values) {
470
+ totals.set(model, (totals.get(model) ?? 0) + value);
471
+ totalPercent += value;
472
+ }
369
473
  }
370
- return path;
371
- }
372
-
373
- function labelEvery(binCount) {
374
- if (binCount <= 14) return 1;
375
- if (binCount <= 20) return 2;
376
- return 3;
474
+ return {
475
+ bins,
476
+ totals,
477
+ totalPercent,
478
+ binSize: 1,
479
+ binCount: bins.length,
480
+ };
377
481
  }
378
482
 
379
- function fallbackProjectRows(snapshot, bounds) {
380
- const startMs = bounds.start.getTime();
381
- const endMs = bounds.end.getTime();
382
- const totals = new Map();
383
- for (const event of usageBucketsInRange(snapshot, startMs, endMs)) {
384
- const timestampMs = new Date(event.timestamp).getTime();
385
- if (!Number.isFinite(timestampMs)) continue;
386
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
387
- if (!(tokens > 0)) continue;
388
- const project = String(event.project || "Unlabelled activity")
389
- .replace(/[\t\r\n]+/g, " ")
390
- .trim() || "Unlabelled activity";
391
- totals.set(project, (totals.get(project) ?? 0) + tokens);
483
+ // Merge the view model's per-day rows into multi-day bins for narrow layouts.
484
+ function binDailyRows(daily, binSize) {
485
+ const bins = [];
486
+ for (let index = 0; index < daily.length; index += binSize) {
487
+ const rows = daily.slice(index, index + binSize);
488
+ const models = new Map();
489
+ for (const row of rows) {
490
+ for (const dayModel of row.models) {
491
+ const merged = models.get(dayModel.model) ?? {
492
+ model: dayModel.model,
493
+ totalTokens: 0,
494
+ normalTokens: 0,
495
+ fastTokens: 0,
496
+ unknownTokens: 0,
497
+ estimated: false,
498
+ };
499
+ merged.totalTokens += dayModel.totalTokens;
500
+ merged.normalTokens += dayModel.normalTokens;
501
+ merged.fastTokens += dayModel.fastTokens;
502
+ merged.unknownTokens += dayModel.unknownTokens;
503
+ merged.estimated ||= dayModel.estimated === true;
504
+ models.set(dayModel.model, merged);
505
+ }
506
+ }
507
+ bins.push({
508
+ startDateString: rows[0].dateString,
509
+ lastDateString: rows.at(-1).dateString,
510
+ totalTokens: rows.reduce((sum, row) => sum + row.totalTokens, 0),
511
+ inputTokens: rows.reduce((sum, row) => sum + row.inputTokens, 0),
512
+ cachedInputTokens: rows.reduce(
513
+ (sum, row) => sum + row.cachedInputTokens,
514
+ 0,
515
+ ),
516
+ modelCalls: rows.reduce((sum, row) => sum + row.modelCalls, 0),
517
+ estimated: rows.some((row) => row.estimated),
518
+ partial: rows.some((row) => row.partial),
519
+ unobserved: rows.every((row) => row.observed === false),
520
+ models: [...models.values()].sort(
521
+ (left, right) => modelSort(left.model, right.model),
522
+ ),
523
+ });
392
524
  }
393
- return [...totals.entries()]
394
- .map(([project, totalTokens]) => ({
395
- project,
396
- displayProject: project,
397
- totalTokens,
398
- }))
399
- .sort((left, right) => right.totalTokens - left.totalTokens);
525
+ return bins;
400
526
  }
401
527
 
402
528
  export function renderTrendImage({
403
529
  snapshot,
404
530
  bounds,
405
- trend = buildUsageTrend(snapshot, bounds),
531
+ trend = null,
406
532
  days = bounds.rangeDays ?? 7,
407
533
  options = {},
408
534
  projectRows = null,
535
+ viewModel = null,
536
+ reportTimeMs = null,
537
+ sourceStatus = null,
538
+ analysis = null,
539
+ reportEvents = null,
409
540
  }) {
410
541
  const width = Math.max(900, Math.min(2_400, Number(options.imageWidth) || 1_280));
411
- const outer = 32;
412
- const plotLeft = 96;
413
- const plotRight = width - 96;
414
- const plotWidth = plotRight - plotLeft;
542
+ const outer = 28;
415
543
  const contentRight = width - outer;
416
544
  const contentWidth = width - outer * 2;
545
+ const wide = width >= 1_100;
417
546
 
418
- // Keep daily bars while they fit at the minimum readable width; aggregate
419
- // longer windows into multi-day columns so bars and labels never overlap.
420
- const actual = buildActualTokenBins(snapshot, bounds, days, plotWidth, {
421
- minBinWidth: MIN_BAR_WIDTH,
422
- preferDaily: true,
423
- });
424
- const burn = buildBurnDayBins(trend, bounds, { days, binSize: actual.binSize });
425
- const meterUsable = Boolean(trend.available && burn.totalPercent > 0);
426
- const percentMode = Boolean(options.drain) && meterUsable;
427
- const bars = percentMode ? burn.bins : actual.bins;
428
- const binCount = actual.binCount;
429
- const binTotalOf = (bin) => (percentMode ? bin.totalPercent : bin.totalTokens);
430
- const maxBar = niceCeiling(
431
- bars.reduce((maximum, bin) => Math.max(maximum, binTotalOf(bin)), 0),
432
- );
433
- const hasLine = Boolean(trend.available && (trend.points ?? []).length > 0);
434
-
435
- const totalTokens = [...actual.totals.values()].reduce((sum, value) => sum + value, 0);
436
- const fastTokens = [...(actual.fastTotals?.values() ?? [])].reduce(
437
- (sum, value) => sum + value,
438
- 0,
439
- );
440
- const hasFast = !percentMode && fastTokens > 0;
441
-
442
- const modelCards = [...actual.totals.entries()]
443
- .filter(([, value]) => value > 0 && totalTokens > 0 && value / totalTokens >= 0.01)
444
- .sort((left, right) => right[1] - left[1])
445
- .slice(0, 3)
446
- .map(([model, value]) => ({ model, tokens: value }));
447
-
448
- // Prior-period per-model totals feed the delta chips.
449
- const priorBounds = {
450
- ...bounds,
451
- startDateString: shiftCalendarDate(bounds.startDateString, -days),
452
- endDateString: shiftCalendarDate(bounds.endDateString, -days),
453
- start: zonedMidnight(
454
- shiftCalendarDate(bounds.startDateString, -days),
455
- bounds.timeZone,
456
- ),
457
- end: bounds.start,
458
- };
459
- const priorTotals = buildActualTokenBins(snapshot, priorBounds, days, plotWidth, {
460
- minBinWidth: MIN_BAR_WIDTH,
461
- preferDaily: true,
462
- }).totals;
463
-
464
- const latestQuotaPoint = [...(trend.points ?? [])]
465
- .filter(
466
- (point) => point.observed && point.timestampMs <= bounds.end.getTime(),
467
- )
468
- .at(-1);
469
- const latestQuotaReadMs = Number.isFinite(trend.observedThroughMs)
470
- ? trend.observedThroughMs
471
- : null;
472
- const resetsInRange = trend.resets ?? [];
473
- const weeklyObservationsAll = weeklyQuotaObservations(snapshot).filter(
474
- (observation) => observation.timestampMs < bounds.end.getTime(),
475
- );
476
- const latestResetsAtSec = weeklyObservationsAll.at(-1)?.resetsAt ?? null;
477
-
478
- const rows = projectRows ?? fallbackProjectRows(snapshot, bounds);
479
-
480
- // Cache bins share the trend chart's bin size so both charts' columns stay
481
- // vertically aligned.
482
- const cacheData = buildCacheReportData(
547
+ const effectiveReportTimeMs = Number.isFinite(reportTimeMs)
548
+ ? reportTimeMs
549
+ : Number.isFinite(options.reportTimeMs)
550
+ ? options.reportTimeMs
551
+ : null;
552
+ const effectiveSourceStatus = sourceStatus ?? options.sourceStatus ??
553
+ "verified-current";
554
+ const vm = viewModel ?? buildTrendReportViewModel({
483
555
  snapshot,
484
556
  bounds,
485
557
  days,
486
- plotWidth,
487
- actual.binSize,
488
- );
489
- const hasCache = cacheData.inputTokens > 0;
490
- const cacheModelRows = (() => {
491
- if (!hasCache) return [];
492
- const models = cacheData.models;
493
- if (models.length <= 4) return models;
494
- const rest = models.slice(3);
495
- const restInput = rest.reduce((sum, model) => sum + model.inputTokens, 0);
496
- const restCached = rest.reduce(
497
- (sum, model) => sum + model.cachedInputTokens,
498
- 0,
499
- );
500
- return [...models.slice(0, 3), {
501
- model: `${rest.length} other models`,
502
- inputTokens: restInput,
503
- cachedInputTokens: restCached,
504
- rate: restInput > 0 ? (restCached / restInput) * 100 : null,
505
- muted: true,
506
- }];
507
- })();
508
-
509
- // ---- Pace & runway (computed early: its height shapes the top row) ----
510
- const generatedAtMs = new Date(snapshot.generatedAt).getTime();
511
- const paceLines = [];
512
- let paceNote = null;
513
- let paceRunwayBar = null;
514
- const dailyAverage = totalTokens / Math.max(1, days);
515
- if (hasLine && meterUsable && latestQuotaPoint && totalTokens > 0) {
516
- const tokensPerPercent = totalTokens / burn.totalPercent;
517
- const burnPerDay = dailyAverage / tokensPerPercent;
518
- const runwayDays = burnPerDay > 0
519
- ? latestQuotaPoint.remainingPercent / burnPerDay
520
- : null;
521
- if (runwayDays !== null) {
522
- paceLines.push({
523
- value: `${runwayDays.toFixed(1)} days`,
524
- size: 23,
525
- weight: 800,
526
- color: COLORS.line,
527
- detail: "of meter left at this pace",
528
- });
529
- }
530
- paceLines.push({
531
- value: `${compact(dailyAverage)} / day`,
532
- size: 17,
533
- weight: 700,
534
- color: COLORS.ink,
535
- detail: `${days}-day average · ${burnPerDay.toFixed(1)}% of meter`,
536
- });
537
- paceLines.push({
538
- value: `${compact(tokensPerPercent)} / 1%`,
539
- size: 17,
540
- weight: 700,
541
- color: COLORS.ink,
542
- detail: "tokens per meter point",
543
- });
544
- const daysToReset = latestResetsAtSec !== null && Number.isFinite(generatedAtMs)
545
- ? (latestResetsAtSec * 1_000 - generatedAtMs) / 86_400_000
546
- : null;
547
- if (runwayDays !== null && daysToReset !== null && daysToReset > 0) {
548
- const resetIn = Math.max(1, Math.round(daysToReset));
549
- const resetInLabel = `${resetIn} ${resetIn === 1 ? "day" : "days"}`;
550
- paceRunwayBar = { runwayDays, daysToReset, resetInLabel };
551
- const gap = runwayDays - daysToReset;
552
- if (Math.abs(gap) <= 1.5) {
553
- paceNote = `Next weekly reset in ${resetInLabel}. Current pace lands within ~${Math.max(1, Math.round(Math.abs(gap)))} day of it.`;
554
- } else if (gap > 0) {
555
- paceNote = `Next weekly reset in ${resetInLabel}. Current pace leaves ~${Math.round(gap)} days of headroom past it.`;
556
- } else {
557
- paceNote = `Next weekly reset in ${resetInLabel}. Current pace runs the meter out ~${Math.round(-gap)} days before it.`;
558
- }
559
- }
560
- } else {
561
- paceLines.push({
562
- value: `${compact(dailyAverage)} / day`,
563
- size: 17,
564
- weight: 700,
565
- color: COLORS.ink,
566
- detail: `${days}-day average`,
567
- });
568
- paceNote = "No usable weekly meter drain in this range, so runway cannot be estimated.";
569
- }
570
-
571
- // ---- Layout ----
572
- // One uniform-height top band: a 2x2 quad of stat cells beside one unified
573
- // weekly meter + pace panel.
574
- const headerBaseline = 53;
575
- const cardTop = 82;
576
- const topGap = 24;
577
- const hasMeterCard = Boolean(hasLine && latestQuotaPoint);
578
- const statCardCount =
579
- modelCards.length + (hasFast ? 1 : 0) + (percentMode ? 1 : 0);
580
- const pacePanelWidth = hasMeterCard
581
- ? Math.min(560, Math.max(480, contentWidth * 0.55))
582
- : 432;
583
- const pacePanelX = contentRight - pacePanelWidth;
584
- const paceTextX = pacePanelX + 18;
585
- const paceInnerWidth = pacePanelWidth - 36;
586
- const quadWidth = contentWidth - pacePanelWidth - topGap;
587
- const quadColumns = statCardCount >= 2 ? 2 : 1;
588
- const quadRows = Math.max(1, Math.ceil(statCardCount / quadColumns));
589
- const paceNoteLines = [];
590
- if (paceNote) {
591
- let current = "";
592
- for (const word of paceNote.split(" ")) {
593
- const candidate = current ? `${current} ${word}` : word;
594
- if (textWidth(candidate, 11.5) > paceInnerWidth && current) {
595
- paceNoteLines.push(current);
596
- current = word;
597
- } else {
598
- current = candidate;
599
- }
600
- }
601
- if (current) paceNoteLines.push(current);
602
- }
603
- // Baseline offsets inside the unified panel: meter headline, optional
604
- // runway timeline, the two-column stat pair, then the note.
605
- const paceHeadlineBaseline = hasMeterCard ? 58 : 54;
606
- const paceStatValueBaseline = paceHeadlineBaseline + (paceRunwayBar ? 62 : 34);
607
- const paceStatsPresent = paceLines.length > (hasMeterCard ? 0 : 1);
608
- const paceNoteStart =
609
- (paceStatsPresent ? paceStatValueBaseline + 16 : paceHeadlineBaseline) + 24;
610
- const topRowHeight = Math.max(
611
- hasMeterCard ? 170 : 150,
612
- paceNoteStart + (paceNoteLines.length - 1) * 16 + 14,
613
- );
614
- const chartBlockTop = cardTop + topRowHeight + 16;
615
- const plotTop = chartBlockTop + 40;
616
- const plotHeight = 430;
617
- const plotBottom = plotTop + plotHeight;
618
- const chartBlockBottom = plotBottom + 70;
619
- const legendBaseline = chartBlockBottom + 25;
620
- const cacheRuleY = legendBaseline + 23;
621
- const cacheHeaderBaseline = cacheRuleY + 27;
622
- const cachePlotTop = cacheHeaderBaseline + 34;
623
- const cachePlotHeight = 128;
624
- const cachePlotBottom = hasCache
625
- ? cachePlotTop + cachePlotHeight
626
- : cacheHeaderBaseline + 26;
627
- const bottomRuleY = cachePlotBottom + 30;
628
- const bottomTop = bottomRuleY + 20;
629
- const projectRowCount = Math.min(4, rows.length > 3 ? 4 : rows.length);
630
- const bottomBlockHeight = Math.max(
631
- 29 + projectRowCount * 29,
632
- 29 + Math.max(1, cacheModelRows.length) * 30,
633
- 120,
558
+ reportTimeMs: effectiveReportTimeMs,
559
+ sourceStatus: effectiveSourceStatus,
560
+ projectRows,
561
+ events: reportEvents ?? analysis?.currentEvents ?? null,
562
+ priorEvents: analysis?.priorEvents ?? null,
563
+ });
564
+ const { summary, meter, meta } = vm;
565
+ const timeZone = meta.timeZone;
566
+ const stale = meta.sourceStatus === "stale-fallback";
567
+ const verified = meta.sourceStatus === "verified-current";
568
+ const fastRates = fastRateSummary(
569
+ snapshot,
570
+ bounds,
571
+ meta.effectiveEndMs,
572
+ reportEvents ?? analysis?.currentEvents,
634
573
  );
635
- const height = bottomTop + bottomBlockHeight + 34;
636
- const rangeStartMs = bounds.start.getTime();
637
- const rangeEndMs = bounds.end.getTime();
638
- const requestedReportTimeMs = Number.isFinite(options.reportTimeMs)
639
- ? options.reportTimeMs
640
- : generatedAtMs;
641
- const reportTimeMs = Number.isFinite(requestedReportTimeMs) &&
642
- requestedReportTimeMs > rangeStartMs && requestedReportTimeMs < rangeEndMs
643
- ? requestedReportTimeMs
574
+ // Drain mode swaps the main chart to observed meter-drain columns; every
575
+ // other panel keeps actual-token semantics.
576
+ const drainTrend = options.drain
577
+ ? trend ?? buildUsageTrend(snapshot, bounds)
644
578
  : null;
645
- const slotWidth = plotWidth / binCount;
646
- const binTimeRanges = actual.bins.map((bin) => ({
647
- startMs: zonedMidnight(bin.startDateString, bounds.timeZone).getTime(),
648
- endMs: zonedMidnight(bin.endDateString, bounds.timeZone).getTime(),
649
- }));
650
- const finalBinTimeRange = binTimeRanges.at(-1);
651
- const partialFinalBin = Boolean(
652
- reportTimeMs !== null &&
653
- finalBinTimeRange &&
654
- reportTimeMs > finalBinTimeRange.startMs &&
655
- reportTimeMs < finalBinTimeRange.endMs,
656
- );
657
- // The x axis is made of equal calendar-period slots. On an incomplete final
658
- // day, stretch only the elapsed part of that slot so report time lands on
659
- // the right edge instead of reserving space for hours that have not happened.
660
- const xForTimestamp = (timestampMs) => {
661
- if (!(timestampMs > rangeStartMs)) return plotLeft;
662
- if (timestampMs >= (partialFinalBin ? reportTimeMs : rangeEndMs)) {
663
- return plotRight;
664
- }
665
- let binIndex = binTimeRanges.findIndex(
666
- (range) => timestampMs >= range.startMs && timestampMs < range.endMs,
667
- );
668
- if (binIndex < 0) {
669
- binIndex = timestampMs < rangeStartMs ? 0 : binCount - 1;
670
- }
671
- const range = binTimeRanges[binIndex];
672
- const effectiveEndMs = partialFinalBin && binIndex === binCount - 1
673
- ? reportTimeMs
674
- : range.endMs;
675
- const span = Math.max(1, effectiveEndMs - range.startMs);
676
- const ratio = Math.max(
677
- 0,
678
- Math.min(1, (timestampMs - range.startMs) / span),
679
- );
680
- return plotLeft + (binIndex + ratio) * slotWidth;
681
- };
682
- const yForRemaining = (value) =>
683
- plotTop + (1 - Math.max(0, Math.min(100, value)) / 100) * plotHeight;
684
- const reportTimeX = reportTimeMs === null
685
- ? null
686
- : xForTimestamp(reportTimeMs);
687
-
688
- const yearLabel = bounds.endDateString.slice(0, 4);
689
- const title = percentMode
690
- ? `TOKEN LEDGER · ${days}-DAY METER DRAIN`
691
- : `TOKEN LEDGER · ${days}-DAY TREND`;
692
- const subtitle = `${localDateLabel(bounds.startDateString, bounds.timeZone)} – ${localDateLabel(bounds.endDateString, bounds.timeZone)}, ${yearLabel} · ${bounds.timeZone}`;
693
- const description = percentMode
694
- ? "Dark report card: compact actual-token stat cards beside pace and runway, stacked columns of observed weekly-meter drain with an explicitly estimated per-model split, the OpenAI-reported weekly limit remaining as an amber line, a partial final day ending at report time, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates."
695
- : "Dark report card: compact model stat cards with week-over-week delta chips beside pace and runway, stacked columns of local token volume by model with fast-mode usage in a darker shade, the OpenAI-reported weekly limit remaining as a smoothed amber line, a partial final day ending at report time, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates.";
696
-
697
- const elements = [
698
- `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-title trend-description" data-report-mode="${percentMode ? "meter-drain" : "actual-tokens"}" data-time-domain="${partialFinalBin ? "through-report" : "full-range"}">`,
699
- `<title id="trend-title">${escapeXml(percentMode ? `Token Ledger · ${days}-day meter drain` : `Token Ledger · ${days}-day trend`)}</title>`,
700
- `<desc id="trend-description">${escapeXml(description)}</desc>`,
701
- `<defs><clipPath id="trend-plot-clip"><rect x="${plotLeft}" y="${plotTop}" width="${plotWidth}" height="${plotHeight}"/></clipPath></defs>`,
702
- `<rect width="100%" height="100%" fill="${COLORS.background}"/>`,
703
- svgText({
579
+
580
+ const elements = [];
581
+ const defs = [
582
+ "<defs>",
583
+ // Fast-mode tokens keep the model color and add this hatch so they stay
584
+ // visible in grayscale without inventing extra bar height.
585
+ '<pattern id="fast-mode-hatch" patternUnits="userSpaceOnUse" width="7" height="7" patternTransform="rotate(45)">',
586
+ '<line x1="0" y1="0" x2="0" y2="7" stroke="rgba(14,20,32,.48)" stroke-width="1.6"/>',
587
+ "</pattern>",
588
+ "</defs>",
589
+ ].join("\n");
590
+
591
+ // ---------------------------------------------------------------- header
592
+ function buildHeaderSection() {
593
+ const yearLabel = meta.endDateString.slice(0, 4);
594
+ const title = `TOKEN LEDGER · ${meta.rangeDays}-DAY TREND`;
595
+ const subtitle = [
596
+ `${localDateLabel(meta.startDateString, timeZone)} – ${localDateLabel(meta.endDateString, timeZone)}, ${yearLabel}`,
597
+ timeZone,
598
+ vm.provenance.historyScope,
599
+ ].filter(Boolean).join(" · ");
600
+ elements.push(svgText({
704
601
  x: outer,
705
- y: headerBaseline,
602
+ y: 46,
706
603
  value: title,
707
- size: 27,
604
+ size: 26,
708
605
  weight: 800,
709
- spacing: "-0.27",
710
- }),
711
- svgText({
712
- x: contentRight,
713
- y: headerBaseline,
606
+ spacing: "-0.26",
607
+ }));
608
+ // The subtitle sits beside the title when the right-hand provenance block
609
+ // leaves room; otherwise it wraps beneath the title.
610
+ const subtitleX = outer + textWidth(title, 26, 800) + 18;
611
+ const subtitleInline =
612
+ subtitleX + textWidth(subtitle, 13.5) < contentRight - 270;
613
+ elements.push(svgText({
614
+ x: subtitleInline ? subtitleX : outer,
615
+ y: subtitleInline ? 46 : 68,
714
616
  value: subtitle,
715
617
  fill: COLORS.muted,
716
- size: 14,
618
+ size: 13.5,
619
+ }));
620
+
621
+ const generatedLabel = shortDateTimeLabel(
622
+ vm.provenance.snapshotGeneratedAtMs,
623
+ timeZone,
624
+ );
625
+ const throughLine = verified
626
+ ? `Report through ${shortDateTimeLabel(meta.reportThroughMs, timeZone)}`
627
+ : `Snapshot generated ${generatedLabel}`;
628
+ elements.push(svgText({
629
+ x: contentRight,
630
+ y: 34,
631
+ value: throughLine,
632
+ fill: COLORS.secondary,
633
+ size: 12.5,
717
634
  anchor: "end",
718
- }),
719
- ];
635
+ }));
636
+ elements.push(svgText({
637
+ x: contentRight,
638
+ y: 54,
639
+ value: meter.lastObservedAtMs !== null
640
+ ? `Meter last observed ${shortDateTimeLabel(meter.lastObservedAtMs, timeZone)}`
641
+ : "No weekly meter observation",
642
+ fill: COLORS.muted,
643
+ size: 12.5,
644
+ anchor: "end",
645
+ }));
646
+ return 82;
647
+ }
720
648
 
721
- // ---- KPI cards ----
722
- const cards = [];
723
- let meterCard = null;
724
- for (const { model, tokens } of modelCards) {
725
- const share = totalTokens > 0 ? (tokens / totalTokens) * 100 : 0;
726
- const priorValue = priorTotals.get(model) ?? 0;
727
- let chip = null;
728
- if (priorValue >= 1_000_000) {
729
- const ratio = tokens / priorValue;
730
- const delta = (ratio - 1) * 100;
731
- chip = {
732
- text: ratio >= 5
733
- ? `${ratio.toFixed(1)}×`
734
- : `${delta >= 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`,
735
- color: delta >= 0 ? COLORS.deltaUp : COLORS.deltaDown,
736
- fill: delta >= 0 ? COLORS.deltaUpFill : COLORS.deltaDownFill,
737
- };
738
- }
649
+ // -------------------------------------------------------------- KPI cards
650
+ function compactCards() {
651
+ const cards = [];
739
652
  cards.push({
740
- swatch: styleForModel(model),
741
- label: percentMode ? `${model} · tokens` : model,
742
- labelColor: COLORS.muted,
743
- value: compact(tokens),
744
- valueColor: COLORS.ink,
745
- chip,
746
- suffix: null,
747
- track: COLORS.track,
748
- fill: styleForModel(model),
749
- barPercent: share,
750
- caption: `${percent(share)} of ${percentMode ? "actual " : ""}tokens`,
751
- captionShort: percent(share),
752
- panel: COLORS.panel,
753
- border: COLORS.panelBorder,
653
+ accent: COLORS.leftAxis,
654
+ label: "TOTAL USAGE",
655
+ value: approximateLabel(compact(summary.totalTokens), summary.estimated),
656
+ unit: "tokens",
657
+ sub: summary.totalDeltaPercent !== null
658
+ ? {
659
+ text: approximateLabel(
660
+ deltaLabel(summary.totalDeltaPercent),
661
+ summary.totalDeltaEstimated,
662
+ ),
663
+ color: summary.totalDeltaPercent >= 0 ? COLORS.deltaUp : COLORS.deltaDown,
664
+ weight: 700,
665
+ }
666
+ : { text: "no prior-period baseline", color: COLORS.muted },
667
+ caption: summary.totalDeltaPercent !== null
668
+ ? "vs prior equivalent period"
669
+ : null,
670
+ sparkline: vm.daily
671
+ .filter((row) => row.observed !== false)
672
+ .map((row) => row.totalTokens),
754
673
  });
755
- }
756
- if (hasFast) {
757
- const fastShare = totalTokens > 0 ? (fastTokens / totalTokens) * 100 : 0;
674
+ const cacheKnown = summary.inputTokens > 0;
758
675
  cards.push({
759
- swatch: FAST_MODE_LABEL_COLOR,
760
- label: "Fast mode",
761
- labelColor: COLORS.muted,
762
- value: `${FAST_MODE_MULTIPLIER.toFixed(2)}×`,
763
- valueColor: COLORS.ink,
764
- chip: null,
765
- suffix: "rate",
766
- track: COLORS.track,
767
- fill: FAST_MODE_LABEL_COLOR,
768
- barPercent: fastShare,
769
- caption: `${percent(fastShare)} of tokens · darker bar shade`,
770
- captionShort: `${percent(fastShare)} of tokens`,
771
- panel: COLORS.panel,
772
- border: COLORS.panelBorder,
676
+ accent: COLORS.cache,
677
+ label: "CACHE EFFICIENCY",
678
+ value: cacheKnown
679
+ ? approximateLabel(pct(summary.cacheRatePercent), summary.estimated)
680
+ : "—",
681
+ unit: cacheKnown ? "input-weighted" : null,
682
+ sub: cacheKnown
683
+ ? {
684
+ text: `${approximateLabel(compact(summary.cachedInputTokens), summary.estimated)} of ${approximateLabel(compact(summary.inputTokens), summary.estimated)} input cached`,
685
+ color: COLORS.secondary,
686
+ }
687
+ : { text: "No measured input-token breakdown", color: COLORS.muted },
688
+ bar: cacheKnown
689
+ ? { fraction: summary.cacheRatePercent / 100, fill: COLORS.cache }
690
+ : null,
773
691
  });
774
- }
775
- if (percentMode) {
692
+ const hasFast = summary.fastTokens > 0;
693
+ const hasUnknownTier = summary.unknownTokens > 0;
694
+ const confirmedFastShare = approximateLabel(
695
+ pct(summary.fastSharePercent),
696
+ summary.estimated,
697
+ );
698
+ const fastRateShare = hasFast &&
699
+ fastRates.effectiveMultiplier !== null &&
700
+ fastRates.unratedTokens === 0
701
+ ? ` · ${fastRates.effectiveMultiplier.toFixed(2)}× avg`
702
+ : "";
703
+ const unknownTierCaption = hasUnknownTier
704
+ ? `${approximateLabel(pct(summary.unknownSharePercent), summary.estimated)} unknown${
705
+ hasFast ? " · hatch=fast" : ""
706
+ }`
707
+ : null;
776
708
  cards.push({
777
- swatch: COLORS.line,
778
- label: "Observed drain",
779
- labelColor: COLORS.meterAxis,
780
- value: `${burn.totalPercent.toFixed(1)} pts`,
781
- valueColor: COLORS.line,
782
- chip: null,
783
- suffix: null,
784
- track: "rgba(246,183,60,.16)",
785
- fill: COLORS.line,
786
- barPercent: burn.totalPercent,
787
- caption: "observed total · model split estimated",
788
- captionShort: "model split estimated",
789
- panel: COLORS.panel,
790
- border: COLORS.panelBorder,
709
+ accent: FAST_MODE_LABEL_COLOR,
710
+ label: "FAST MODE USAGE",
711
+ value: approximateLabel(compact(summary.fastTokens), summary.fastEstimated),
712
+ unit: "tokens",
713
+ sub: {
714
+ text: `${confirmedFastShare} confirmed fast`,
715
+ optionalSuffix: fastRateShare,
716
+ color: hasFast || hasUnknownTier ? COLORS.secondary : COLORS.muted,
717
+ },
718
+ bar: hasFast
719
+ ? { fraction: summary.fastSharePercent / 100, fill: FAST_MODE_LABEL_COLOR }
720
+ : null,
721
+ caption: unknownTierCaption ?? (hasFast
722
+ ? fastRates.effectiveMultiplier === null
723
+ ? "Fast credit rate: UNRATED"
724
+ : fastRates.unratedTokens > 0
725
+ ? "Some fast usage is unrated"
726
+ : "Hatched = confirmed fast"
727
+ : null),
791
728
  });
792
- }
793
- if (hasLine && latestQuotaPoint) {
794
- const lastReset = resetsInRange.at(-1);
795
- const resetCaption = lastReset
796
- ? `last reset ${timestampDateLabel(lastReset.timestampMs, bounds.timeZone)}`
797
- : latestResetsAtSec
798
- ? `next reset ${timestampDateLabel(latestResetsAtSec * 1_000, bounds.timeZone)}`
799
- : "no reset in range";
800
- const meterCaption = (() => {
801
- if (latestQuotaReadMs === null) return resetCaption;
802
- const candidates = [
803
- `${resetCaption} · OpenAI reading ${timestampReadLabel(latestQuotaReadMs, bounds.timeZone)}`,
804
- `OpenAI reading · ${timestampReadLabel(latestQuotaReadMs, bounds.timeZone)}`,
805
- `OpenAI reading · ${timestampTimeLabel(latestQuotaReadMs, bounds.timeZone)}`,
806
- ];
807
- const available = Math.max(
808
- 80,
809
- paceInnerWidth - 15 - textWidth(METER_PANEL_HEADING, 10.5) - 18,
810
- );
811
- return candidates.find((candidate) => textWidth(candidate, 10.5) <= available) ??
812
- `reported ${timestampTimeLabel(latestQuotaReadMs, bounds.timeZone)}`;
813
- })();
814
- meterCard = ({
815
- swatch: COLORS.line,
816
- label: "Weekly limit",
817
- labelColor: COLORS.meterAxis,
818
- value: meterLabel(latestQuotaPoint.remainingPercent),
819
- valueColor: COLORS.line,
820
- chip: null,
821
- suffix: "remaining",
822
- track: "rgba(246,183,60,.2)",
823
- fill: COLORS.line,
824
- barPercent: latestQuotaPoint.remainingPercent,
825
- caption: meterCaption,
826
- captionShort: resetCaption,
827
- panel: COLORS.meterPanel,
828
- border: COLORS.meterPanelBorder,
729
+ cards.push({
730
+ accent: COLORS.secondary,
731
+ label: "PROJECTS",
732
+ value: String(summary.activeProjects),
733
+ unit: "active",
734
+ sub: summary.topFourProjectSharePercent !== null
735
+ ? {
736
+ text: `Top ${Math.min(4, vm.projects.length)} = ${approximateLabel(pct(summary.topFourProjectSharePercent), summary.estimated)}`,
737
+ color: COLORS.secondary,
738
+ }
739
+ : { text: "no project activity", color: COLORS.muted },
740
+ histogram: [...vm.projects.map((row) => row.sharePercent),
741
+ vm.projectRemainder.sharePercent].filter((share) => share > 0),
829
742
  });
743
+ return cards;
830
744
  }
831
- if (cards.length) {
832
- // A 2x2 quad of stat cells inside one panel with hairline dividers.
833
- const cellWidth = quadWidth / quadColumns;
834
- const cellHeight = topRowHeight / quadRows;
835
- elements.push(svgRect(outer, cardTop, quadWidth, topRowHeight, {
836
- rx: 7,
837
- fill: COLORS.panel,
745
+
746
+ function drawCompactCard(card, x, y, cardWidth, cardHeight) {
747
+ elements.push(svgText({
748
+ x: x + 16,
749
+ y: y + 25,
750
+ value: card.label,
751
+ fill: card.accent,
752
+ size: 12,
753
+ weight: 600,
754
+ spacing: "1.08",
838
755
  }));
839
- if (quadColumns > 1) {
840
- const dividerX = outer + cellWidth;
841
- elements.push(`<line x1="${dividerX.toFixed(2)}" y1="${cardTop + 1}" x2="${dividerX.toFixed(2)}" y2="${cardTop + topRowHeight - 1}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
842
- }
843
- if (quadRows > 1) {
844
- const dividerY = cardTop + cellHeight;
845
- elements.push(`<line x1="${outer + 1}" y1="${dividerY.toFixed(2)}" x2="${(outer + quadWidth - 1).toFixed(2)}" y2="${dividerY.toFixed(2)}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
846
- }
847
- cards.forEach((card, index) => {
848
- const cellX = outer + (index % quadColumns) * cellWidth;
849
- const cellY = cardTop + Math.floor(index / quadColumns) * cellHeight;
850
- const contentX = cellX + 17;
851
- const innerWidth = cellWidth - 34;
852
- const innerRight = contentX + innerWidth;
853
- const labelText = card.label.toUpperCase();
854
- // Each corner carries something: label top-left, delta chip top-right,
855
- // value bottom-left, share caption bottom-right, bar along the bottom.
856
- elements.push(`<circle cx="${(contentX + 3.5).toFixed(2)}" cy="${(cellY + 19).toFixed(2)}" r="3.5" fill="${card.swatch}"/>`);
756
+ const valueBaseline = y + 62;
757
+ const innerWidth = cardWidth - 32;
758
+ const valueSize = 30;
759
+ const unitSize = 12.5;
760
+ const inlineUnitGap = 11;
761
+ const valueWidth = textWidth(card.value, valueSize, 800);
762
+ const unitInline = !card.unit ||
763
+ valueWidth + inlineUnitGap + textWidth(card.unit, unitSize) <= innerWidth;
764
+ elements.push(svgText({
765
+ x: x + 16,
766
+ y: valueBaseline,
767
+ value: card.value,
768
+ size: valueSize,
769
+ weight: 800,
770
+ spacing: "-0.6",
771
+ }));
772
+ if (card.unit) {
773
+ const placement = unitInline ? "inline" : "stacked";
774
+ elements.push(`<g data-role="kpi-unit" data-placement="${placement}">`);
857
775
  elements.push(svgText({
858
- x: contentX + 15,
859
- y: cellY + 23,
860
- value: labelText,
861
- fill: card.labelColor,
862
- size: 10.5,
863
- spacing: ".9",
776
+ x: unitInline ? x + 16 + valueWidth + inlineUnitGap : x + 16,
777
+ y: unitInline ? valueBaseline : y + 81,
778
+ value: card.unit,
779
+ fill: COLORS.muted,
780
+ size: unitInline ? unitSize : 11.5,
864
781
  }));
865
- const labelBaseline = cellY + 23;
866
- if (card.chip) {
867
- const chipTextWidth = textWidth(card.chip.text, 10, 700);
868
- const chipX = innerRight - chipTextWidth - 10;
869
- const labelEnd =
870
- contentX +
871
- 15 +
872
- textWidth(labelText, 10.5) +
873
- Math.max(0, labelText.length - 1) * 0.9;
874
- if (chipX >= labelEnd + 10) {
875
- elements.push(svgRect(chipX, labelBaseline - 11, chipTextWidth + 10, 15, {
876
- rx: 3,
877
- fill: card.chip.fill,
878
- }));
879
- elements.push(svgText({
880
- x: chipX + 5,
881
- y: labelBaseline,
882
- value: card.chip.text,
883
- fill: card.chip.color,
884
- size: 10,
885
- weight: 700,
886
- }));
887
- }
888
- }
889
- const valueBaseline = cellY + cellHeight / 2 + 9;
782
+ elements.push("</g>");
783
+ }
784
+ if (card.sub) {
785
+ const subWidth = innerWidth;
786
+ const fullSub = card.sub.text + (card.sub.optionalSuffix || "");
787
+ const subText = textWidth(fullSub, 11.5, card.sub.weight ?? 400) <= subWidth
788
+ ? fullSub
789
+ : card.sub.text;
790
+ const subSize = fitTextSize(
791
+ subText,
792
+ subWidth,
793
+ 12.5,
794
+ 11.5,
795
+ card.sub.weight ?? 400,
796
+ );
797
+ const subBaseline = unitInline ? y + 88 : y + 96;
798
+ elements.push(`<g data-role="kpi-sub" data-placement="${unitInline ? "inline" : "stacked"}" data-baseline="${subBaseline}">`);
890
799
  elements.push(svgText({
891
- x: contentX,
892
- y: valueBaseline,
893
- value: card.value,
894
- fill: card.valueColor,
895
- size: 21,
896
- weight: 800,
897
- spacing: "-0.5",
800
+ x: x + 16,
801
+ y: subBaseline,
802
+ value: truncateToWidth(
803
+ subText,
804
+ subWidth,
805
+ subSize,
806
+ card.sub.weight ?? 400,
807
+ ),
808
+ fill: card.sub.color,
809
+ size: subSize,
810
+ weight: card.sub.weight ?? 400,
898
811
  }));
899
- let valueEnd = contentX + textWidth(card.value, 21, 800);
900
- if (card.suffix) {
901
- elements.push(svgText({
902
- x: valueEnd + 10,
903
- y: valueBaseline,
904
- value: card.suffix,
905
- fill: COLORS.secondary,
906
- size: 10.5,
907
- }));
908
- valueEnd += 10 + textWidth(card.suffix, 10.5);
909
- }
910
- const captionAvail = innerRight - valueEnd - 16;
911
- const caption = card.captionShort && textWidth(card.caption, 11) > captionAvail
912
- ? card.captionShort
913
- : card.caption;
914
- if (textWidth(caption, 11) <= captionAvail) {
915
- elements.push(svgText({
916
- x: innerRight,
917
- y: valueBaseline,
918
- value: caption,
919
- fill: COLORS.secondary,
920
- size: 11,
921
- anchor: "end",
922
- }));
923
- }
924
- const barY = cellY + cellHeight - 16;
925
- elements.push(svgRect(contentX, barY, innerWidth, 3, {
926
- rx: 1.5,
927
- fill: card.track,
812
+ elements.push("</g>");
813
+ }
814
+ if (card.caption) {
815
+ const captionBaseline = unitInline ? y + 107 : y + 112;
816
+ elements.push(`<g data-role="kpi-caption" data-placement="${unitInline ? "inline" : "stacked"}" data-baseline="${captionBaseline}">`);
817
+ elements.push(svgText({
818
+ x: x + 16,
819
+ y: captionBaseline,
820
+ value: truncateToWidth(card.caption, cardWidth - 32, 12),
821
+ fill: COLORS.muted,
822
+ size: 12,
928
823
  }));
929
- const fillWidth = (Math.min(100, Math.max(0, card.barPercent)) / 100) * innerWidth;
824
+ elements.push("</g>");
825
+ }
826
+ if (card.bar) {
827
+ const barY = y + cardHeight - 22;
828
+ const barWidth = cardWidth - 32;
829
+ elements.push(svgRect(x + 16, barY, barWidth, 5, { rx: 2.5, fill: COLORS.track }));
830
+ const fillWidth = Math.max(0, Math.min(1, card.bar.fraction)) * barWidth;
930
831
  if (fillWidth > 0) {
931
- elements.push(svgRect(contentX, barY, fillWidth, 3, {
932
- rx: 1.5,
933
- fill: card.fill,
934
- }));
832
+ elements.push(svgRect(x + 16, barY, fillWidth, 5, { rx: 2.5, fill: card.bar.fill }));
935
833
  }
936
- });
937
- elements.push(svgRect(outer, cardTop, quadWidth, topRowHeight, {
938
- rx: 7,
939
- fill: "none",
940
- stroke: COLORS.panelBorder,
834
+ }
835
+ if (card.sparkline && card.sparkline.length > 1 && card.sparkline.some((value) => value > 0)) {
836
+ const sparkWidth = Math.min(84, cardWidth * 0.34);
837
+ const sparkHeight = 26;
838
+ const sparkLeft = x + cardWidth - sparkWidth - 14;
839
+ const sparkTop = y + 16;
840
+ const maxValue = Math.max(...card.sparkline);
841
+ const points = card.sparkline.map((value, index) => {
842
+ const px = sparkLeft + (index / (card.sparkline.length - 1)) * sparkWidth;
843
+ const py = sparkTop + (1 - (maxValue > 0 ? value / maxValue : 0)) * sparkHeight;
844
+ return `${px.toFixed(1)},${py.toFixed(1)}`;
845
+ });
846
+ elements.push(`<polyline points="${points.join(" ")}" fill="none" stroke="${COLORS.leftAxis}" stroke-width="1.6" stroke-linejoin="round"/>`);
847
+ }
848
+ if (card.histogram && card.histogram.length) {
849
+ const shares = card.histogram.slice(0, 7);
850
+ const maxShare = Math.max(...shares);
851
+ const histWidth = Math.min(86, cardWidth * 0.34);
852
+ const slot = histWidth / shares.length;
853
+ const histBottom = y + cardHeight - 20;
854
+ shares.forEach((share, index) => {
855
+ const barHeight = maxShare > 0 ? Math.max(2, (share / maxShare) * 30) : 2;
856
+ elements.push(svgRect(
857
+ x + cardWidth - 14 - histWidth + index * slot,
858
+ histBottom - barHeight,
859
+ Math.max(2, slot - 3),
860
+ barHeight,
861
+ { fill: COLORS.leftAxis, opacity: 0.85, rx: 1 },
862
+ ));
863
+ });
864
+ }
865
+ }
866
+
867
+ function addVerticalDivider(x, y, height, role) {
868
+ elements.push(svgLine(x, y + 2, x, y + height - 2, {
869
+ stroke: COLORS.separator,
941
870
  "stroke-width": 1,
871
+ "data-role": role,
942
872
  }));
943
873
  }
944
874
 
945
- // ---- Unified weekly meter + pace panel (top right) ----
946
- elements.push(svgRect(pacePanelX, cardTop, pacePanelWidth, topRowHeight, {
947
- rx: 7,
948
- fill: meterCard ? meterCard.panel : COLORS.panel,
949
- stroke: meterCard ? meterCard.border : COLORS.panelBorder,
950
- "stroke-width": 1,
951
- }));
952
- const paceRight = pacePanelX + pacePanelWidth - 18;
953
- if (meterCard) {
954
- elements.push(`<circle cx="${(paceTextX + 3.5).toFixed(2)}" cy="${cardTop + 19}" r="3.5" fill="${meterCard.swatch}"/>`);
955
- }
956
- elements.push(svgText({
957
- x: paceTextX + (meterCard ? 15 : 0),
958
- y: cardTop + 23,
959
- value: meterCard ? METER_PANEL_HEADING : "PACE & RUNWAY",
960
- fill: meterCard ? meterCard.labelColor : COLORS.muted,
961
- size: 10.5,
962
- spacing: "1.2",
963
- }));
964
- if (meterCard) {
965
- // Provenance rides the label row; the meter reading is the headline with
966
- // the projected runway right-aligned beside it.
967
- elements.push(svgText({
968
- x: paceRight,
969
- y: cardTop + 23,
970
- value: meterCard.caption,
971
- fill: COLORS.muted,
972
- size: 10.5,
973
- anchor: "end",
875
+ function drawWeeklyCard(x, y, cardWidth, cardHeight) {
876
+ elements.push(svgRect(x, y, cardWidth, cardHeight, {
877
+ rx: 8,
878
+ fill: COLORS.meterPanel,
879
+ stroke: COLORS.meterPanelBorder,
880
+ "stroke-width": 1,
974
881
  }));
882
+ let labelX = x + 16;
975
883
  elements.push(svgText({
976
- x: paceTextX,
977
- y: cardTop + paceHeadlineBaseline,
978
- value: meterCard.value,
979
- fill: meterCard.valueColor,
980
- size: 28,
981
- weight: 800,
982
- spacing: "-0.6",
884
+ x: labelX,
885
+ y: y + 25,
886
+ value: "WEEKLY LIMIT",
887
+ fill: COLORS.meterAxis,
888
+ size: 12,
889
+ weight: 600,
890
+ spacing: "1.08",
983
891
  }));
984
- elements.push(svgText({
985
- x: paceTextX + textWidth(meterCard.value, 28, 800) + 10,
986
- y: cardTop + paceHeadlineBaseline,
987
- value: meterCard.suffix,
988
- fill: COLORS.secondary,
989
- size: 11,
990
- }));
991
- if (paceLines.length > 1) {
992
- const runwayValue = paceLines[0].value;
993
- const runwayDetail = "left at this pace";
994
- const detailWidth = textWidth(runwayDetail, 11);
892
+ labelX += textWidth("WEEKLY LIMIT", 12, 600) + 22;
893
+ if (stale) {
894
+ elements.push(chip(labelX, y + 21, "STALE SNAPSHOT", {
895
+ fill: "rgba(246,183,60,.16)",
896
+ stroke: COLORS.line,
897
+ color: COLORS.line,
898
+ size: 10.5,
899
+ }).markup);
900
+ }
901
+
902
+ const status = meter.status;
903
+ const valueBaseline = y + 60;
904
+ const rightX = x + cardWidth - 16;
905
+
906
+ if (status === "unavailable") {
995
907
  elements.push(svgText({
996
- x: paceRight - detailWidth - 8,
997
- y: cardTop + paceHeadlineBaseline,
998
- value: runwayValue,
999
- fill: paceLines[0].color,
1000
- size: 18,
908
+ x: x + 16,
909
+ y: valueBaseline,
910
+ value: "NO OBSERVATION",
911
+ fill: COLORS.line,
912
+ size: 24,
1001
913
  weight: 800,
1002
- anchor: "end",
914
+ spacing: "-0.24",
1003
915
  }));
1004
916
  elements.push(svgText({
1005
- x: paceRight,
1006
- y: cardTop + paceHeadlineBaseline,
1007
- value: runwayDetail,
1008
- fill: COLORS.muted,
917
+ x: x + 16,
918
+ y: y + 86,
919
+ value: truncateToWidth(
920
+ "No account-wide weekly-limit reading was found",
921
+ cardWidth - 32,
922
+ 12.5,
923
+ ),
924
+ fill: COLORS.secondary,
925
+ size: 12.5,
926
+ }));
927
+ return;
928
+ }
929
+
930
+ // Right column: time to the scheduled reset.
931
+ if (meter.resetInMs !== null) {
932
+ elements.push(svgText({
933
+ x: rightX,
934
+ y: y + 25,
935
+ value: "RESETS IN",
936
+ fill: COLORS.meterAxis,
1009
937
  size: 11,
938
+ weight: 600,
939
+ spacing: "1",
940
+ anchor: "end",
941
+ }));
942
+ elements.push(svgText({
943
+ x: rightX,
944
+ y: y + 50,
945
+ value: durationLabel(meter.resetInMs).toUpperCase(),
946
+ fill: COLORS.line,
947
+ size: 21,
948
+ weight: 800,
1010
949
  anchor: "end",
950
+ spacing: "-0.2",
1011
951
  }));
1012
952
  }
1013
- } else {
1014
- const paceHeadline = paceLines[0];
1015
- elements.push(svgText({
1016
- x: paceTextX,
1017
- y: cardTop + paceHeadlineBaseline,
1018
- value: paceHeadline.value,
1019
- fill: paceHeadline.color,
1020
- size: 26,
1021
- weight: 800,
1022
- spacing: "-0.52",
1023
- }));
1024
- elements.push(svgText({
1025
- x: paceTextX + textWidth(paceHeadline.value, 26, 800) + 10,
1026
- y: cardTop + paceHeadlineBaseline,
1027
- value: paceHeadline.detail,
1028
- fill: COLORS.muted,
1029
- size: 12.5,
1030
- }));
1031
- }
1032
- if (paceRunwayBar) {
1033
- // Runway timeline: amber fill = days of meter left, tick = the next
1034
- // weekly reset, both on a shared day scale.
1035
- const scaleDays = Math.max(paceRunwayBar.runwayDays, paceRunwayBar.daysToReset) * 1.06;
1036
- const trackY = cardTop + paceHeadlineBaseline + 12;
1037
- elements.push(svgRect(paceTextX, trackY, paceInnerWidth, 5, {
1038
- rx: 2.5,
1039
- fill: "rgba(246,183,60,.14)",
1040
- }));
1041
- const runwayWidth = Math.min(1, paceRunwayBar.runwayDays / scaleDays) * paceInnerWidth;
1042
- if (runwayWidth > 0) {
1043
- elements.push(svgRect(paceTextX, trackY, runwayWidth, 5, {
1044
- rx: 2.5,
953
+
954
+ let subLine;
955
+ if (status === "exhausted") {
956
+ elements.push(svgText({
957
+ x: x + 16,
958
+ y: valueBaseline,
959
+ value: "EXHAUSTED",
960
+ fill: COLORS.line,
961
+ size: 26,
962
+ weight: 800,
963
+ spacing: "-0.26",
964
+ }));
965
+ subLine = meter.firstExhaustedObservedAtMs !== null && meter.resetsAtMs !== null
966
+ ? `Reached 0% about ${durationLabel(meter.resetsAtMs - meter.firstExhaustedObservedAtMs)} before reset`
967
+ : "Latest reading reports 0% remaining";
968
+ } else {
969
+ elements.push(svgText({
970
+ x: x + 16,
971
+ y: valueBaseline,
972
+ value: meterPct(meter.remainingPercent),
1045
973
  fill: COLORS.line,
974
+ size: 26,
975
+ weight: 800,
976
+ spacing: "-0.26",
1046
977
  }));
978
+ elements.push(svgText({
979
+ x: x + 16 + textWidth(meterPct(meter.remainingPercent), 26, 800) + 12,
980
+ y: valueBaseline,
981
+ value: "remaining",
982
+ fill: COLORS.secondary,
983
+ size: 12.5,
984
+ }));
985
+ if (status === "at-risk" && meter.runwayDays !== null && meter.resetInMs !== null) {
986
+ subLine = `At this pace: 0% about ${durationLabel(meter.resetInMs - meter.runwayDays * 86_400_000)} before reset`;
987
+ } else if (meter.runwayDays !== null) {
988
+ subLine = `${meter.runwayDays.toFixed(1)} days of runway at this pace`;
989
+ } else {
990
+ subLine = "Runway unavailable · no usable meter drain in the active cycle";
991
+ }
1047
992
  }
1048
- const tickX = paceTextX +
1049
- Math.min(1, paceRunwayBar.daysToReset / scaleDays) * paceInnerWidth;
1050
- elements.push(`<line x1="${tickX.toFixed(2)}" y1="${trackY - 3}" x2="${tickX.toFixed(2)}" y2="${trackY + 8}" stroke="${COLORS.secondary}" stroke-width="2"/>`);
1051
993
  elements.push(svgText({
1052
- x: paceTextX,
1053
- y: trackY + 22,
1054
- value: "now",
1055
- fill: COLORS.muted,
1056
- size: 10.5,
994
+ x: x + 16,
995
+ y: y + 91,
996
+ value: truncateToWidth(subLine, cardWidth - 32, 12.5),
997
+ fill: status === "at-risk" ? COLORS.warn : COLORS.secondary,
998
+ size: 12.5,
1057
999
  }));
1058
- const resetLabel = `reset in ${paceRunwayBar.resetInLabel}`;
1059
- const resetLabelWidth = textWidth(resetLabel, 10.5);
1060
- const nowLabelWidth = textWidth("now", 10.5);
1061
- const resetLabelX = Math.max(
1062
- paceTextX + nowLabelWidth + 8 + resetLabelWidth / 2,
1063
- Math.min(tickX, paceTextX + paceInnerWidth - resetLabelWidth / 2 - 2),
1000
+
1001
+ const barY = y + cardHeight - 20;
1002
+ const barWidth = cardWidth - 32;
1003
+ elements.push(svgRect(x + 16, barY, barWidth, 5, { rx: 2.5, fill: "rgba(246,183,60,.2)" }));
1004
+ const fillWidth = (Math.max(0, Math.min(100, meter.remainingPercent)) / 100) * barWidth;
1005
+ if (fillWidth > 0) {
1006
+ elements.push(svgRect(x + 16, barY, fillWidth, 5, { rx: 2.5, fill: COLORS.line }));
1007
+ }
1008
+ }
1009
+
1010
+ function buildKpiSection(top) {
1011
+ const cards = compactCards();
1012
+ const cardHeight = 140;
1013
+ const gap = 12;
1014
+ if (wide) {
1015
+ // Weekly card takes ~1.55 compact-card widths on one row.
1016
+ const unit = (contentWidth - gap * 4) / (4 + 1.55);
1017
+ for (let index = 1; index < cards.length; index += 1) {
1018
+ addVerticalDivider(
1019
+ outer + index * (unit + gap) - gap / 2,
1020
+ top,
1021
+ cardHeight,
1022
+ "kpi-column-divider",
1023
+ );
1024
+ }
1025
+ cards.forEach((card, index) => {
1026
+ drawCompactCard(card, outer + index * (unit + gap), top, unit, cardHeight);
1027
+ });
1028
+ drawWeeklyCard(outer + 4 * (unit + gap), top, unit * 1.55, cardHeight);
1029
+ return top + cardHeight;
1030
+ }
1031
+ const half = (contentWidth - gap) / 2;
1032
+ addVerticalDivider(
1033
+ outer + half + gap / 2,
1034
+ top,
1035
+ cardHeight * 2 + gap,
1036
+ "kpi-column-divider",
1064
1037
  );
1065
- elements.push(svgText({
1066
- x: resetLabelX,
1067
- y: trackY + 22,
1068
- value: resetLabel,
1069
- fill: COLORS.muted,
1070
- size: 10.5,
1071
- anchor: "middle",
1072
- }));
1038
+ cards.forEach((card, index) => {
1039
+ const row = Math.floor(index / 2);
1040
+ const column = index % 2;
1041
+ drawCompactCard(card, outer + column * (half + gap), top + row * (cardHeight + gap), half, cardHeight);
1042
+ });
1043
+ const weeklyTop = top + 2 * (cardHeight + gap);
1044
+ drawWeeklyCard(outer, weeklyTop, contentWidth, cardHeight);
1045
+ return weeklyTop + cardHeight;
1073
1046
  }
1074
- const paceStatLines = meterCard
1075
- ? (paceLines.length > 1 ? paceLines.slice(1) : paceLines)
1076
- : paceLines.slice(1);
1077
- paceStatLines.forEach((line, index) => {
1078
- const columnX = paceTextX + index * (paceInnerWidth / 2 + 8);
1047
+
1048
+ // ------------------------------------------------------------- model mix
1049
+ function buildModelMixSection(top) {
1050
+ const sectionHeight = 28;
1051
+ const labelBaseline = top + 19;
1079
1052
  elements.push(svgText({
1080
- x: columnX,
1081
- y: cardTop + paceStatValueBaseline,
1082
- value: line.value,
1083
- fill: line.color,
1084
- size: 17,
1085
- weight: 700,
1053
+ x: outer + 16,
1054
+ y: labelBaseline,
1055
+ value: "MODEL MIX",
1056
+ fill: COLORS.leftAxis,
1057
+ size: 11.5,
1058
+ weight: 600,
1059
+ spacing: "1.08",
1086
1060
  }));
1087
1061
  elements.push(svgText({
1088
- x: columnX,
1089
- y: cardTop + paceStatValueBaseline + 16,
1090
- value: line.detail,
1062
+ x: outer + 16 + spacedWidth("MODEL MIX", 11.5, 600, 1.08) + 8,
1063
+ y: labelBaseline,
1064
+ value: "(by tokens)",
1091
1065
  fill: COLORS.muted,
1092
1066
  size: 11,
1093
1067
  }));
1094
- });
1095
- let paceNoteBaseline = cardTop + paceNoteStart;
1096
- for (const line of paceNoteLines) {
1097
- elements.push(svgText({
1098
- x: paceTextX,
1099
- y: paceNoteBaseline,
1100
- value: line,
1101
- fill: COLORS.muted,
1102
- size: 11.5,
1103
- }));
1104
- paceNoteBaseline += 16;
1105
- }
1106
1068
 
1107
- // ---- Chart grid + axes ----
1108
- for (const fraction of [1, 0.75, 0.5, 0.25, 0]) {
1109
- const y = plotBottom - fraction * plotHeight;
1110
- elements.push(`<line x1="${plotLeft}" y1="${y.toFixed(2)}" x2="${plotRight}" y2="${y.toFixed(2)}" stroke="${fraction === 0 ? COLORS.baseline : COLORS.grid}" stroke-width="1"/>`);
1111
- elements.push(svgText({
1112
- x: plotLeft - 14,
1113
- y: y + 4,
1114
- value: percentMode
1115
- ? `${Number((maxBar * fraction).toFixed(1))}%`
1116
- : fraction === 0
1117
- ? "0"
1118
- : compact(maxBar * fraction),
1119
- fill: COLORS.muted,
1120
- size: 13,
1121
- anchor: "end",
1122
- mono: true,
1123
- }));
1124
- if (hasLine) {
1069
+ const rows = vm.models.filter((row) => row.totalTokens > 0);
1070
+ if (!rows.length || !(summary.totalTokens > 0)) {
1125
1071
  elements.push(svgText({
1126
- x: plotRight + 14,
1127
- y: y + 4,
1128
- value: `${Math.round(fraction * 100)}%`,
1129
- fill: COLORS.meterAxis,
1130
- size: 13,
1131
- mono: true,
1072
+ x: contentRight - 16,
1073
+ y: labelBaseline,
1074
+ value: "no usage in range",
1075
+ fill: COLORS.muted,
1076
+ size: 12.5,
1077
+ anchor: "end",
1132
1078
  }));
1079
+ return top + sectionHeight;
1133
1080
  }
1134
- }
1135
- elements.push(svgText({
1136
- x: plotLeft,
1137
- y: chartBlockTop + 18,
1138
- value: percentMode
1139
- ? "METER DRAIN · OBSERVED TOTAL, ESTIMATED MODEL SPLIT"
1140
- : "TOKEN VOLUME · ACTUAL",
1141
- fill: COLORS.leftAxis,
1142
- size: 11.5,
1143
- spacing: "1.25",
1144
- }));
1145
- if (hasLine) {
1146
- elements.push(svgText({
1147
- x: plotRight,
1148
- y: chartBlockTop + 18,
1149
- value: "WEEKLY LIMIT · OPENAI REPORTED",
1150
- fill: COLORS.meterAxis,
1151
- size: 11.5,
1152
- anchor: "end",
1153
- spacing: "1.25",
1154
- }));
1155
- }
1156
-
1157
- // ---- Bars ----
1158
- const barWidth = Math.min(74, Math.max(MIN_BAR_WIDTH, slotWidth * 0.6));
1159
- const barGeometry = bars.map((bin, binIndex) => {
1160
- const centerX = plotLeft + (binIndex + 0.5) * slotWidth;
1161
- return {
1162
- bin,
1163
- centerX,
1164
- x: centerX - barWidth / 2,
1165
- topY: plotBottom - (binTotalOf(bin) / maxBar) * plotHeight,
1166
- };
1167
- });
1168
1081
 
1169
- const segmentLabels = [];
1170
- for (const { bin, centerX, x } of barGeometry) {
1171
- const entries = sortedModelEntries(bin.values);
1172
- let y = plotBottom;
1173
- for (const [model, value] of entries) {
1174
- const segmentHeight = (value / maxBar) * plotHeight;
1175
- y -= segmentHeight;
1176
- if (segmentHeight <= 0.4) continue;
1177
- const baseColor = styleForModel(model);
1178
- const fastValue = percentMode ? 0 : (bin.fastValues?.get(model) ?? 0);
1179
- const fastHeight = fastValue > 0 && value > 0
1180
- ? segmentHeight * Math.min(1, fastValue / value)
1181
- : 0;
1182
- elements.push(svgRect(x, y, barWidth, segmentHeight - fastHeight, {
1183
- fill: baseColor,
1184
- "data-series": "usage-bars",
1185
- "data-model": model,
1186
- "data-value": value,
1187
- "data-unit": percentMode ? "meter-points" : "tokens",
1188
- }));
1189
- if (fastHeight > 0.5) {
1190
- elements.push(svgRect(x, y + segmentHeight - fastHeight, barWidth, fastHeight, {
1191
- fill: fastShade(baseColor),
1192
- "data-series": "usage-bars",
1193
- "data-model": model,
1194
- "data-value": fastValue,
1195
- "data-unit": "tokens",
1196
- "data-tier": "fast",
1197
- }));
1082
+ // Segments that are too narrow for an inside label move to an external
1083
+ // caption at the right end of the strip.
1084
+ const barLeft = outer + 170;
1085
+ const external = [];
1086
+ const externalRows = [];
1087
+ let barRight = contentRight - 16;
1088
+ const segmentLabel = (row) =>
1089
+ `${row.model} ${approximateLabel(pct(row.sharePercent), summary.estimated)} (${approximateLabel(compact(row.totalTokens), row.estimated)})`;
1090
+ for (const row of [...rows].reverse()) {
1091
+ const share = row.totalTokens / summary.totalTokens;
1092
+ const estimatedWidth = share * (barRight - barLeft);
1093
+ if (
1094
+ estimatedWidth < textWidth(segmentLabel(row), 12, 600) + 18 &&
1095
+ externalRows.length < 2 &&
1096
+ rows.length > 1
1097
+ ) {
1098
+ externalRows.unshift(row);
1099
+ } else {
1100
+ break;
1198
1101
  }
1199
- const valueLabel = percentMode ? percent(value) : compact(value);
1200
- const fits = (text, size) => textWidth(text, size, 700) <= barWidth - 6;
1201
- if (segmentHeight >= 32 && fits(model, 13) && fits(valueLabel, 15)) {
1202
- const segmentCenter = y + segmentHeight / 2;
1203
- segmentLabels.push(svgText({
1204
- x: centerX,
1205
- y: segmentCenter - 5,
1206
- value: model,
1207
- fill: COLORS.onFill,
1208
- size: 13,
1209
- anchor: "middle",
1210
- }));
1211
- segmentLabels.push(svgText({
1212
- x: centerX,
1213
- y: segmentCenter + 13,
1214
- value: valueLabel,
1102
+ }
1103
+ for (const row of externalRows) external.push(segmentLabel(row));
1104
+ if (external.length) {
1105
+ const caption = external.join(" · ");
1106
+ barRight -= textWidth(caption, 11, 500) + 16;
1107
+ elements.push(svgText({
1108
+ x: contentRight - 16,
1109
+ y: labelBaseline,
1110
+ value: caption,
1111
+ fill: COLORS.secondary,
1112
+ size: 11,
1113
+ weight: 500,
1114
+ anchor: "end",
1115
+ }));
1116
+ }
1117
+ const barY = top + 7;
1118
+ const barHeight = 14;
1119
+ const unlabeledRows = [];
1120
+ let cursor = barLeft;
1121
+ const barWidth = Math.max(60, barRight - barLeft);
1122
+ rows.forEach((row, index) => {
1123
+ const share = row.totalTokens / summary.totalTokens;
1124
+ const segmentWidth = share * barWidth;
1125
+ elements.push(svgRect(cursor, barY, segmentWidth, barHeight, {
1126
+ fill: styleForModel(row.model),
1127
+ rx: index === 0 || index === rows.length - 1 ? 3 : null,
1128
+ }));
1129
+ const label = segmentLabel(row);
1130
+ if (!externalRows.includes(row) && textWidth(label, 10.5, 600) + 14 <= segmentWidth) {
1131
+ elements.push(svgText({
1132
+ x: cursor + segmentWidth / 2,
1133
+ y: barY + 10.5,
1134
+ value: label,
1215
1135
  fill: "#ffffff",
1216
- size: 15,
1217
- weight: 700,
1136
+ size: 10.5,
1137
+ weight: 600,
1218
1138
  anchor: "middle",
1219
1139
  }));
1220
1140
  }
1141
+ if (!externalRows.includes(row) && textWidth(label, 10.5, 600) + 14 > segmentWidth) {
1142
+ unlabeledRows.push(row);
1143
+ }
1144
+ cursor += segmentWidth;
1145
+ });
1146
+ let captionX = barLeft;
1147
+ let captionRow = 0;
1148
+ for (const row of unlabeledRows) {
1149
+ const label = segmentLabel(row);
1150
+ const labelWidth = textWidth(label, 11, 500) + 24;
1151
+ if (captionX > barLeft && captionX + labelWidth > contentRight - 16) {
1152
+ captionX = barLeft;
1153
+ captionRow += 1;
1154
+ }
1155
+ elements.push(svgText({
1156
+ x: captionX, y: top + 38 + captionRow * 18, value: label,
1157
+ fill: styleForModel(row.model), size: 11, weight: 500,
1158
+ }));
1159
+ captionX += labelWidth;
1221
1160
  }
1161
+ return top + sectionHeight + (unlabeledRows.length ? (captionRow + 1) * 18 : 0);
1222
1162
  }
1223
1163
 
1224
- // ---- Meter line: per-cycle smoothed segments with reset breaks ----
1225
- let resetMarks = [];
1226
- let binDots = [];
1227
- let pills = [];
1228
- let hasHeldSegment = false;
1229
- const lineSegments = [];
1230
- if (hasLine) {
1231
- const cycles = new Map();
1232
- for (const point of trend.points ?? []) {
1233
- const cycle = cycles.get(point.cycle) ?? [];
1234
- cycle.push(point);
1235
- cycles.set(point.cycle, cycle);
1236
- }
1237
- const orderedCycles = [...cycles.entries()].sort(
1238
- (left, right) => left[1][0].timestampMs - right[1][0].timestampMs,
1239
- );
1240
-
1241
- resetMarks = resetsInRange
1242
- .filter((reset) => reset.kind !== "start")
1243
- .map((reset) => ({
1244
- ...reset,
1245
- x: xForTimestamp(Math.max(bounds.start.getTime(), reset.timestampMs)),
1246
- label: reset.kind === "weekly-expiry"
1247
- ? "RESET · 100%"
1248
- : "RESTART · 100%",
1249
- }));
1250
- const resetByCycle = new Map(resetMarks.map((reset) => [reset.cycle, reset]));
1251
- const resetLabels = (() => {
1252
- const maximum = 4;
1253
- if (resetMarks.length <= maximum) return resetMarks;
1254
- const selected = new Map();
1255
- const add = (reset) => {
1256
- if (reset) selected.set(reset.cycle, reset);
1257
- };
1258
- const scheduled = resetMarks.filter(
1259
- (reset) => reset.kind === "weekly-expiry",
1260
- );
1261
- if (scheduled.length >= maximum) {
1262
- for (let index = 0; index < maximum; index += 1) {
1263
- add(scheduled[Math.round((index / (maximum - 1)) * (scheduled.length - 1))]);
1264
- }
1265
- } else {
1266
- scheduled.forEach(add);
1267
- add(resetMarks[0]);
1268
- add(resetMarks.findLast((reset) => reset.kind !== "weekly-expiry"));
1269
- for (let index = 1; selected.size < maximum && index < resetMarks.length - 1; index += 1) {
1270
- const candidateIndex = Math.round(
1271
- (index / (maximum - 1)) * (resetMarks.length - 1),
1272
- );
1273
- add(resetMarks[candidateIndex]);
1274
- }
1275
- }
1276
- return [...selected.values()]
1277
- .sort((left, right) => left.x - right.x)
1278
- .slice(-maximum);
1279
- })();
1280
- const labeledResetCycles = new Set(resetLabels.map((reset) => reset.cycle));
1281
-
1282
- for (const [cycleIndex, [cycleId, cyclePoints]] of orderedCycles.entries()) {
1283
- const points = cyclePoints.map((point) => ({
1284
- x: xForTimestamp(point.timestampMs),
1285
- y: yForRemaining(point.remainingPercent),
1286
- remainingPercent: point.remainingPercent,
1287
- timestampMs: point.timestampMs,
1288
- }));
1289
- const cycleReset = resetByCycle.get(cycleId);
1290
- if (
1291
- cycleReset &&
1292
- points.length &&
1293
- cycleReset.timestampMs < points[0].timestampMs
1294
- ) {
1295
- points.unshift({
1296
- x: cycleReset.x,
1297
- y: yForRemaining(100),
1298
- remainingPercent: 100,
1299
- timestampMs: cycleReset.timestampMs,
1300
- syntheticReset: true,
1164
+ // ------------------------------------------------------------ daily chart
1165
+ function buildDailyChartSection(top) {
1166
+ const hourlyMode = meta.granularity === "hour" &&
1167
+ Array.isArray(vm.hourly) &&
1168
+ vm.hourly.length > 0;
1169
+ const hourlyRows = hourlyMode ? hourlyChartRows(vm.hourly, timeZone) : null;
1170
+ const plotWidthEstimate = contentWidth - 70 - 66 - 24;
1171
+ const binSize = hourlyMode
1172
+ ? 1
1173
+ : chooseBinSize(meta.rangeDays, plotWidthEstimate, {
1174
+ minBinWidth: MIN_BAR_WIDTH,
1175
+ preferDaily: true,
1301
1176
  });
1302
- }
1177
+ const tokenBins = hourlyMode ? hourlyRows : binDailyRows(vm.daily, binSize);
1178
+ const meterDrainAvailable = Boolean(options.drain) &&
1179
+ Boolean(drainTrend?.available) &&
1180
+ meter.status !== "unavailable";
1181
+ const burnCandidate = meterDrainAvailable
1182
+ ? hourlyMode
1183
+ ? buildBurnHourBins(
1184
+ drainTrend,
1185
+ hourlyRows,
1186
+ meta.startMs,
1187
+ meta.effectiveEndMs,
1188
+ )
1189
+ : buildBurnDayBins(drainTrend, bounds, { days: meta.rangeDays, binSize })
1190
+ : null;
1191
+ const percentMode = Boolean(options.drain && burnCandidate?.totalPercent > 0);
1192
+ const drainFallback = Boolean(options.drain) && !percentMode;
1193
+ const burn = percentMode ? burnCandidate : null;
1194
+ const bins = percentMode
1195
+ ? burn.bins.map((bin, index) => ({
1196
+ ...bin,
1197
+ startDateString: bin.startDateString,
1198
+ lastDateString: bin.lastDateString ?? shiftCalendarDate(bin.endDateString, -1),
1199
+ totalPercent: bin.totalPercent,
1200
+ approximate: bin.approximate,
1201
+ values: bin.values,
1202
+ partial: tokenBins[index]?.partial ?? false,
1203
+ unobserved: tokenBins[index]?.unobserved ?? true,
1204
+ estimated: false,
1205
+ }))
1206
+ : tokenBins;
1207
+ const binCount = bins.length;
1208
+ const binTotalOf = (bin) => (percentMode ? bin.totalPercent : bin.totalTokens);
1209
+ const maxBin = bins.reduce((maximum, bin) => Math.max(maximum, binTotalOf(bin)), 0);
1210
+ const ceiling = reportCeiling(maxBin);
1211
+ const meterVisible = meter.status !== "unavailable" && meter.observations.length > 0;
1212
+
1213
+ const panelTop = top;
1214
+ const headerBaseline = panelTop + 27;
1215
+ const plotLeft = outer + 70;
1216
+ const plotRight = contentRight - (meterVisible ? 66 : 24);
1217
+ const plotWidth = plotRight - plotLeft;
1218
+ const plotTop = panelTop + 64;
1219
+ const plotHeight = wide ? 330 : 300;
1220
+ const plotBottom = plotTop + plotHeight;
1221
+ const partialInRange = bins.some((bin) => bin.partial);
1222
+ const slotWidth = plotWidth / binCount;
1223
+ const barWidth = Math.min(86, Math.max(MIN_BAR_WIDTH, slotWidth * 0.62));
1224
+ const entriesForBin = (bin) => percentMode
1225
+ ? [...bin.values.entries()]
1226
+ .filter(([, value]) => value > 0)
1227
+ .sort(([left], [right]) => modelSort(left, right))
1228
+ .map(([model, value]) => ({ model, totalTokens: value, fastTokens: 0 }))
1229
+ : [...bin.models].filter((entry) => entry.totalTokens > 0)
1230
+ .sort((left, right) => modelSort(left.model, right.model));
1231
+ const valueLabelFor = (entry) => percentMode
1232
+ ? pct(entry.totalTokens)
1233
+ : approximateLabel(compact(entry.totalTokens), entry.estimated);
1234
+ const fitsInside = (entry) =>
1235
+ (entry.totalTokens / ceiling) * plotHeight >= 20 &&
1236
+ textWidth(entry.model, 12.5, 700) <= barWidth - 6;
1237
+ const smallSegments = bins.map((bin) => bin.unobserved
1238
+ ? [] : entriesForBin(bin).filter((entry) => !fitsInside(entry)));
1239
+ const labelBand = 58 + (partialInRange ? 18 : 0);
1240
+ const panelHeight = plotBottom - panelTop + labelBand;
1241
+ elements.push(svgRect(outer, panelTop, contentWidth, panelHeight, {
1242
+ rx: 8,
1243
+ fill: COLORS.panel,
1244
+ stroke: COLORS.panelBorder,
1245
+ "stroke-width": 1,
1246
+ }));
1303
1247
 
1304
- // Keep a visual connection to a known reset boundary, but render the
1305
- // unsampled hold as dashed instead of making it look observed.
1306
- const nextCycleId = orderedCycles[cycleIndex + 1]?.[0];
1307
- const nextReset = resetByCycle.get(nextCycleId);
1308
- const lastPoint = points.at(-1);
1309
- const resetCarry =
1310
- nextReset &&
1311
- lastPoint &&
1312
- lastPoint.timestampMs < nextReset.timestampMs
1313
- ? [lastPoint, {
1314
- ...lastPoint,
1315
- x: nextReset.x,
1316
- timestampMs: nextReset.timestampMs,
1317
- carriedToReset: true,
1318
- }]
1319
- : null;
1320
-
1321
- // Thin to at most one point per 2px so the path stays light while the
1322
- // spline still follows every meaningful movement.
1323
- const thinned = [];
1324
- for (const point of points) {
1325
- const { x, y } = point;
1326
- const previous = thinned.at(-1);
1327
- if (previous && x - previous.x < 2) {
1328
- if (previous.syntheticReset && Math.abs(previous.y - y) > 0.5) {
1329
- thinned.push({ ...point, x: Math.max(x, previous.x + 0.75) });
1330
- } else {
1331
- Object.assign(previous, point);
1332
- }
1333
- } else {
1334
- thinned.push({ ...point });
1335
- }
1336
- }
1337
- const path = monotonePath(thinned);
1338
- if (path) {
1339
- elements.push(`<path d="${path}" fill="none" stroke="${COLORS.background}" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round" opacity=".88" clip-path="url(#trend-plot-clip)"/>`);
1340
- elements.push(`<path d="${path}" fill="none" stroke="${COLORS.line}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" clip-path="url(#trend-plot-clip)" data-series="weekly-meter" data-cycle="${escapeXml(cycleId)}"/>`);
1341
- lineSegments.push(thinned);
1342
- }
1343
- if (resetCarry) {
1344
- const [from, to] = resetCarry;
1345
- const heldPath = `M ${from.x.toFixed(2)} ${from.y.toFixed(2)} L ${to.x.toFixed(2)} ${to.y.toFixed(2)}`;
1346
- elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.background}" stroke-width="5.5" stroke-linecap="round" opacity=".72" clip-path="url(#trend-plot-clip)"/>`);
1347
- elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.line}" stroke-width="2.25" stroke-linecap="round" stroke-dasharray="5 6" opacity=".7" clip-path="url(#trend-plot-clip)" data-series="weekly-meter-held" data-reason="reset" data-cycle="${escapeXml(cycleId)}"/>`);
1348
- lineSegments.push(resetCarry);
1349
- hasHeldSegment = true;
1350
- }
1248
+ // Panel header: title + legend + right axis caption.
1249
+ const chartTitle = percentMode
1250
+ ? "OBSERVED LIMIT DRAIN"
1251
+ : hourlyMode
1252
+ ? "HOURLY TOKEN VOLUME"
1253
+ : "DAILY TOKEN VOLUME";
1254
+ const chartSubtitle = percentMode
1255
+ ? hourlyMode
1256
+ ? "(meter percent by hour)"
1257
+ : "(meter percent by model)"
1258
+ : drainFallback
1259
+ ? "(actual · --drain unavailable; raw local tokens)"
1260
+ : "(actual)";
1261
+ elements.push(svgText({
1262
+ x: outer + 16,
1263
+ y: headerBaseline,
1264
+ value: chartTitle,
1265
+ fill: COLORS.leftAxis,
1266
+ size: 12,
1267
+ weight: 600,
1268
+ spacing: "1.08",
1269
+ }));
1270
+ elements.push(svgText({
1271
+ x: outer + 16 +
1272
+ spacedWidth(chartTitle, 12, 600, 1.08) + 8,
1273
+ y: headerBaseline,
1274
+ value: chartSubtitle,
1275
+ fill: COLORS.muted,
1276
+ size: 11.5,
1277
+ }));
1278
+ if (meterVisible) {
1279
+ elements.push(svgText({
1280
+ x: contentRight - 16,
1281
+ y: headerBaseline,
1282
+ value: "Meter %",
1283
+ fill: COLORS.meterAxis,
1284
+ size: 12,
1285
+ weight: 600,
1286
+ anchor: "end",
1287
+ }));
1351
1288
  }
1352
1289
 
1353
- const latestObservedPoint = [...(trend.points ?? [])]
1354
- .filter(
1355
- (point) =>
1356
- point.observed &&
1357
- reportTimeMs !== null &&
1358
- point.timestampMs <= reportTimeMs,
1359
- )
1360
- .at(-1);
1361
- if (latestObservedPoint && reportTimeMs !== null) {
1362
- const from = {
1363
- x: xForTimestamp(latestObservedPoint.timestampMs),
1364
- y: yForRemaining(latestObservedPoint.remainingPercent),
1365
- };
1366
- const to = { x: xForTimestamp(reportTimeMs), y: from.y };
1367
- if (to.x - from.x >= 2) {
1368
- const heldPath = `M ${from.x.toFixed(2)} ${from.y.toFixed(2)} L ${to.x.toFixed(2)} ${to.y.toFixed(2)}`;
1369
- elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.background}" stroke-width="5.5" stroke-linecap="round" opacity=".72" clip-path="url(#trend-plot-clip)"/>`);
1370
- elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.line}" stroke-width="2.25" stroke-linecap="round" stroke-dasharray="5 6" opacity=".7" clip-path="url(#trend-plot-clip)" data-series="weekly-meter-held" data-reason="report-time"/>`);
1371
- lineSegments.push([from, to]);
1372
- hasHeldSegment = true;
1373
- }
1290
+ // Legend, wrapped when narrow.
1291
+ const legendItems = [];
1292
+ const presentModels = vm.models
1293
+ .filter((row) => row.totalTokens > 0)
1294
+ .map((row) => row.model)
1295
+ .sort(modelSort);
1296
+ for (const model of presentModels) {
1297
+ legendItems.push({ kind: "swatch", fill: styleForModel(model), label: model });
1374
1298
  }
1375
-
1376
- // One dot per labeled column: the last observation inside that column.
1377
- const observed = (trend.points ?? []).filter((point) => point.observed);
1378
- const step = labelEvery(binCount);
1379
- const resetBinIndexes = new Set(resetMarks.map((reset) =>
1380
- Math.max(0, Math.min(binCount - 1, Math.floor((reset.x - plotLeft) / slotWidth)))));
1381
- for (let binIndex = 0; binIndex < binCount; binIndex += 1) {
1382
- if (binIndex % step !== 0 && binIndex !== binCount - 1) continue;
1383
- if (resetBinIndexes.has(binIndex)) continue;
1384
- const binEndMs = zonedMidnight(
1385
- bars[binIndex].endDateString,
1386
- bounds.timeZone,
1387
- ).getTime();
1388
- const binStartMs = zonedMidnight(
1389
- bars[binIndex].startDateString,
1390
- bounds.timeZone,
1391
- ).getTime();
1392
- const point = observed.findLast(
1393
- (candidate) =>
1394
- candidate.timestampMs >= binStartMs && candidate.timestampMs < binEndMs,
1395
- );
1396
- if (!point) continue;
1397
- binDots.push({
1398
- binIndex,
1399
- x: xForTimestamp(point.timestampMs),
1400
- y: yForRemaining(point.remainingPercent),
1401
- remainingPercent: point.remainingPercent,
1402
- cycle: point.cycle,
1403
- });
1299
+ if (!percentMode && summary.fastTokens > 0) {
1300
+ legendItems.push({ kind: "hatch", label: "Fast mode" });
1404
1301
  }
1405
- for (const dot of binDots) {
1406
- elements.push(`<circle cx="${dot.x.toFixed(2)}" cy="${dot.y.toFixed(2)}" r="3.5" fill="${COLORS.line}" stroke="${COLORS.background}" stroke-width="1.5"/>`);
1302
+ if (meterVisible) {
1303
+ legendItems.push({ kind: "solid", label: "Reported interval" });
1304
+ legendItems.push({ kind: "dashed", label: "Unobserved gap" });
1407
1305
  }
1408
-
1409
- // Stagger dense reset labels across lanes: a label joins the first lane
1410
- // whose previous label sits far enough to its left.
1411
- const laneRight = [];
1412
- for (const reset of resetMarks) {
1413
- const resetBinIndex = Math.max(
1414
- 0,
1415
- Math.min(binCount - 1, Math.floor((reset.x - plotLeft) / slotWidth)),
1416
- );
1417
- const resetBar = barGeometry[resetBinIndex];
1418
- const crossesBar = resetBar &&
1419
- reset.x >= resetBar.x - 2 &&
1420
- reset.x <= resetBar.x + barWidth + 2;
1421
- const resetLineBottom = crossesBar
1422
- ? Math.max(plotTop + 36, resetBar.topY - 8)
1423
- : plotBottom;
1424
- elements.push(`<line x1="${reset.x.toFixed(2)}" y1="${plotTop}" x2="${reset.x.toFixed(2)}" y2="${resetLineBottom.toFixed(2)}" stroke="rgba(246,183,60,.48)" stroke-width="1.5" stroke-dasharray="5 6"/>`);
1425
- if (!labeledResetCycles.has(reset.cycle)) continue;
1426
- const labelWidth = textWidth(reset.label, 11, 700) + 14;
1427
- const labelCenterX = Math.max(
1428
- plotLeft + labelWidth / 2,
1429
- Math.min(plotRight - labelWidth / 2, reset.x),
1430
- );
1431
- const labelLeft = labelCenterX - labelWidth / 2;
1432
- let lane = laneRight.findIndex((right) => labelLeft - right >= 8);
1433
- if (lane < 0) {
1434
- lane = laneRight.length < 3
1435
- ? laneRight.length
1436
- : laneRight.indexOf(Math.min(...laneRight));
1306
+ let legendX = outer + 16;
1307
+ let legendY = headerBaseline + 21;
1308
+ const legendLimit = contentRight - 16;
1309
+ for (const item of legendItems) {
1310
+ const swatchWidth = item.kind === "swatch" || item.kind === "hatch" ? 13 : 20;
1311
+ const itemWidth = swatchWidth + 7 + textWidth(item.label, 12) + 18;
1312
+ if (legendX + itemWidth > legendLimit && legendX > outer + 16) {
1313
+ legendX = outer + 16;
1314
+ legendY += 18;
1315
+ }
1316
+ if (item.kind === "swatch") {
1317
+ elements.push(svgRect(legendX, legendY - 9, 13, 10, { fill: item.fill, rx: 2 }));
1318
+ } else if (item.kind === "hatch") {
1319
+ elements.push(svgRect(legendX, legendY - 9, 13, 10, { fill: COLORS.leftAxis, rx: 2 }));
1320
+ elements.push(svgRect(legendX, legendY - 9, 13, 10, { fill: "url(#fast-mode-hatch)", rx: 2 }));
1321
+ } else if (item.kind === "solid") {
1322
+ elements.push(svgLine(legendX, legendY - 4, legendX + 20, legendY - 4, {
1323
+ stroke: COLORS.line,
1324
+ "stroke-width": 2.4,
1325
+ }));
1326
+ } else {
1327
+ elements.push(svgLine(legendX, legendY - 4, legendX + 20, legendY - 4, {
1328
+ stroke: COLORS.line,
1329
+ "stroke-width": 2,
1330
+ "stroke-dasharray": "4 4",
1331
+ }));
1437
1332
  }
1438
- laneRight[lane] = labelCenterX + labelWidth / 2;
1439
- const labelBaseline = plotTop + 20 + lane * 21;
1440
- elements.push(svgRect(
1441
- labelCenterX - labelWidth / 2,
1442
- labelBaseline - 14,
1443
- labelWidth,
1444
- 19,
1445
- {
1446
- rx: 5,
1447
- fill: COLORS.background,
1448
- stroke: "rgba(246,183,60,.42)",
1449
- "stroke-width": 1,
1450
- },
1451
- ));
1452
1333
  elements.push(svgText({
1453
- x: labelCenterX,
1454
- y: labelBaseline,
1455
- value: reset.label,
1456
- fill: COLORS.line,
1457
- size: 11,
1458
- weight: 700,
1459
- anchor: "middle",
1460
- mono: true,
1334
+ x: legendX + swatchWidth + 7,
1335
+ y: legendY,
1336
+ value: item.label,
1337
+ fill: COLORS.secondary,
1338
+ size: 12,
1461
1339
  }));
1340
+ legendX += itemWidth;
1462
1341
  }
1463
1342
 
1464
- // Keep the line readable with no more than two decision-useful labels:
1465
- // the first reading after the latest reset (or the range start when no
1466
- // reset exists) and the latest reading.
1467
- const picked = [];
1468
- const latestReset = resetMarks.at(-1);
1469
- if (latestReset) {
1470
- const afterReset = binDots.find((dot) => dot.x > latestReset.x + 2);
1471
- if (afterReset) picked.push(afterReset);
1472
- } else if (binDots.length) {
1473
- picked.push(binDots[0]);
1474
- }
1475
- if (binDots.length > 1) picked.push(binDots.at(-1));
1476
- const uniquePicks = [...new Map(picked.map((dot) => [dot.binIndex, dot])).values()];
1477
- pills = uniquePicks.map((dot, pickIndex) => {
1478
- const label = meterLabel(dot.remainingPercent);
1479
- const pillWidth = textWidth(label, 12, 700) + 18;
1480
- const preferLeft = pickIndex === uniquePicks.length - 1 ||
1481
- dot.x + pillWidth + 16 > plotRight;
1482
- let x = preferLeft ? dot.x - pillWidth - 13 : dot.x + 13;
1483
- x = Math.max(plotLeft + 3, Math.min(plotRight - pillWidth - 3, x));
1484
- let y = dot.y - 32;
1485
- if (y < plotTop + 7) y = dot.y + 11;
1486
- y = Math.max(plotTop + 7, Math.min(plotBottom - 31, y));
1487
- return {
1488
- x,
1489
- y,
1490
- w: pillWidth,
1491
- h: 24,
1492
- tx: x + pillWidth / 2,
1493
- ty: y + 16,
1494
- label,
1495
- dotX: dot.x,
1496
- dotY: dot.y,
1497
- };
1498
- });
1499
- }
1500
-
1501
- if (reportTimeX !== null) {
1502
- elements.push(`<line x1="${reportTimeX.toFixed(2)}" y1="${plotTop}" x2="${reportTimeX.toFixed(2)}" y2="${plotBottom}" stroke="${COLORS.muted}" stroke-width="1.25" stroke-dasharray="4 5" opacity=".72" data-marker="report-time"/>`);
1503
- elements.push(svgText({
1504
- x: reportTimeX - 7,
1505
- y: plotTop - 8,
1506
- value: `AS OF ${timestampTimeLabel(reportTimeMs, bounds.timeZone).toUpperCase()}`,
1507
- fill: COLORS.muted,
1508
- size: 10.5,
1509
- weight: 700,
1510
- anchor: "end",
1511
- spacing: ".55",
1512
- }));
1513
- }
1514
-
1515
- // ---- Bar totals and day labels (drawn over the line like the labels) ----
1516
- elements.push(...segmentLabels);
1517
- // Where the line passes through a horizontal span at band height, from the
1518
- // thinned polylines; keeps each column total clear of the amber stroke.
1519
- const lineTopIfCrossing = (x0, x1, bandTop, bandBottom) => {
1520
- let top = Infinity;
1521
- for (const segment of lineSegments) {
1522
- for (let index = 0; index < segment.length - 1; index += 1) {
1523
- const from = segment[index];
1524
- const to = segment[index + 1];
1525
- if (to.x < x0 || from.x > x1) continue;
1526
- const clip0 = Math.max(x0, from.x);
1527
- const clip1 = Math.min(x1, to.x);
1343
+ // Meter pixel geometry is needed both by the overlay and by bar-total
1344
+ // placement (totals step above the line when it crosses their band).
1345
+ // Hourly bars retain the nominal end of the final partial hour so meter
1346
+ // timestamps land inside the same column; meter observations themselves
1347
+ // are still clipped by the view model's effective cutoff.
1348
+ const chartEndMs = hourlyMode
1349
+ ? hourlyRows.at(-1).endMs
1350
+ : meta.requestedEndMs;
1351
+ const spanMs = chartEndMs - meta.startMs;
1352
+ const xForTs = (timestampMs) =>
1353
+ plotLeft +
1354
+ Math.max(0, Math.min(1, spanMs > 0 ? (timestampMs - meta.startMs) / spanMs : 0)) *
1355
+ plotWidth;
1356
+ const resetXs = meterVisible
1357
+ ? meter.resets.map((reset) => xForTs(reset.timestampMs))
1358
+ : [];
1359
+ const yForRemaining = (value) =>
1360
+ plotTop + (1 - Math.max(0, Math.min(100, value)) / 100) * plotHeight;
1361
+ const pixelSegments = meterVisible
1362
+ ? meter.segments.map((segment) => ({
1363
+ x0: xForTs(segment.fromMs),
1364
+ y0: yForRemaining(segment.fromPercent),
1365
+ x1: xForTs(segment.toMs),
1366
+ y1: yForRemaining(segment.toPercent),
1367
+ }))
1368
+ : [];
1369
+ const lineTopWithin = (x0, x1, bandTop, bandBottom) => {
1370
+ let top = Infinity;
1371
+ for (const segment of pixelSegments) {
1372
+ if (segment.x1 < x0 || segment.x0 > x1) continue;
1373
+ const clip0 = Math.max(x0, segment.x0);
1374
+ const clip1 = Math.min(x1, segment.x1);
1528
1375
  if (clip1 < clip0) continue;
1529
1376
  const yAt = (x) =>
1530
- from.y + (to.x === from.x ? 0 : ((x - from.x) / (to.x - from.x)) * (to.y - from.y));
1377
+ segment.y0 +
1378
+ (segment.x1 === segment.x0
1379
+ ? 0
1380
+ : ((x - segment.x0) / (segment.x1 - segment.x0)) * (segment.y1 - segment.y0));
1531
1381
  const yLow = Math.min(yAt(clip0), yAt(clip1));
1532
1382
  const yHigh = Math.max(yAt(clip0), yAt(clip1));
1533
1383
  if (yLow <= bandBottom && yHigh >= bandTop) top = Math.min(top, yLow);
1534
1384
  }
1535
- }
1536
- return top;
1537
- };
1538
- const labelStep = labelEvery(binCount);
1539
- const isLabeledColumn = (binIndex) =>
1540
- binIndex % labelStep === 0 || binIndex === binCount - 1;
1541
- for (const [binIndex, { bin, centerX, topY }] of barGeometry.entries()) {
1542
- const binTotal = binTotalOf(bin);
1543
- // Dense windows only caption the columns that carry date labels; a total
1544
- // on all 30 daily columns would overlap its neighbours.
1545
- if (binTotal > 0 && isLabeledColumn(binIndex)) {
1546
- // The total label sits in the band just above the stack; step it above
1547
- // the line only when the line actually crosses that band.
1548
- const lineTop = lineTopIfCrossing(
1549
- centerX - barWidth / 2 - 6,
1550
- centerX + barWidth / 2 + 6,
1551
- topY - 32,
1552
- topY + 8,
1553
- );
1554
- const clearedTop = Number.isFinite(lineTop) ? Math.min(topY, lineTop) : topY;
1385
+ return Number.isFinite(top) ? top : null;
1386
+ };
1387
+
1388
+ // Grid and axes.
1389
+ for (const fraction of [1, 0.75, 0.5, 0.25, 0]) {
1390
+ const y = plotBottom - fraction * plotHeight;
1391
+ elements.push(svgLine(plotLeft, y, plotRight, y, {
1392
+ stroke: fraction === 0 ? COLORS.baseline : COLORS.grid,
1393
+ "stroke-width": 1,
1394
+ }));
1555
1395
  elements.push(svgText({
1556
- x: centerX,
1557
- y: clearedTop - 13,
1396
+ x: plotLeft - 12,
1397
+ y: y + 4,
1558
1398
  value: percentMode
1559
- ? `${bin.approximate ? "≈" : ""}${percent(binTotal)}`
1560
- : compact(binTotal),
1561
- fill: COLORS.ink,
1562
- size: 16,
1563
- weight: 700,
1564
- anchor: "middle",
1399
+ ? `${Number((ceiling * fraction).toFixed(1))}%`
1400
+ : fraction === 0
1401
+ ? "0"
1402
+ : compact(ceiling * fraction),
1403
+ fill: COLORS.muted,
1404
+ size: 12,
1405
+ anchor: "end",
1406
+ mono: true,
1565
1407
  }));
1566
- }
1567
- if (isLabeledColumn(binIndex)) {
1568
- const weekday = actual.binSize === 1
1569
- ? localWeekdayLabel(bin.startDateString, bounds.timeZone).toUpperCase()
1570
- : "";
1571
- if (weekday) {
1408
+ if (meterVisible) {
1572
1409
  elements.push(svgText({
1573
- x: centerX,
1574
- y: plotBottom + 32,
1575
- value: weekday,
1576
- fill: COLORS.muted,
1577
- size: 13,
1578
- anchor: "middle",
1579
- spacing: "1.56",
1410
+ x: plotRight + 12,
1411
+ y: y + 4,
1412
+ value: `${Math.round(fraction * 100)}%`,
1413
+ fill: COLORS.meterAxis,
1414
+ size: 12,
1415
+ mono: true,
1580
1416
  }));
1581
1417
  }
1582
- elements.push(svgText({
1583
- x: centerX,
1584
- y: plotBottom + (weekday ? 54 : 40),
1585
- value: binDateLabel(bin, bounds.timeZone),
1586
- fill: COLORS.secondary,
1587
- size: 15,
1588
- anchor: "middle",
1589
- }));
1590
- if (partialFinalBin && binIndex === binCount - 1) {
1418
+ }
1419
+
1420
+ // Bars.
1421
+ const labelStep = labelEvery(binCount);
1422
+ const peakBinIndex = hourlyMode && maxBin > 0
1423
+ ? bins.reduce(
1424
+ (peakIndex, bin, index) =>
1425
+ binTotalOf(bin) > binTotalOf(bins[peakIndex]) ? index : peakIndex,
1426
+ 0,
1427
+ )
1428
+ : -1;
1429
+ const isLabeledColumn = (index) =>
1430
+ index % labelStep === 0 ||
1431
+ index === binCount - 1 ||
1432
+ index === peakBinIndex;
1433
+ const dateLabelIndices = selectDateLabelIndices(bins, {
1434
+ timeZone,
1435
+ slotWidth,
1436
+ labelStep,
1437
+ labelSize: 14,
1438
+ labelForBin: hourlyMode
1439
+ ? (bin) => bin.hourLabel
1440
+ : (bin) => binDateLabel(bin, timeZone),
1441
+ });
1442
+ const isDateLabeledColumn = (index) => dateLabelIndices.has(index);
1443
+ const segmentLabels = [];
1444
+
1445
+ bins.forEach((bin, binIndex) => {
1446
+ const centerX = plotLeft + (binIndex + 0.5) * slotWidth;
1447
+ const x = centerX - barWidth / 2;
1448
+ if (bin.unobserved) {
1449
+ elements.push(svgRect(centerX - slotWidth / 2 + 2, plotTop, slotWidth - 4, plotHeight, {
1450
+ fill: "rgba(255,255,255,.025)",
1451
+ }));
1452
+ if (isDateLabeledColumn(binIndex)) {
1453
+ elements.push(chip(centerX, plotBottom + 21, "UNOBSERVED", {
1454
+ fill: "rgba(255,255,255,.04)",
1455
+ stroke: COLORS.baseline,
1456
+ color: COLORS.muted,
1457
+ size: 9.5,
1458
+ anchor: "middle",
1459
+ }).markup);
1460
+ elements.push(svgText({
1461
+ x: centerX,
1462
+ y: plotBottom + 45,
1463
+ value: hourlyMode ? bin.hourLabel : binDateLabel(bin, timeZone),
1464
+ fill: COLORS.secondary,
1465
+ size: 14,
1466
+ anchor: "middle",
1467
+ }));
1468
+ }
1469
+ return;
1470
+ }
1471
+ if (bin.partial) {
1472
+ elements.push(svgRect(centerX - slotWidth / 2 + 2, plotTop, slotWidth - 4, plotHeight, {
1473
+ fill: "rgba(255,255,255,.03)",
1474
+ }));
1475
+ }
1476
+ const entries = entriesForBin(bin);
1477
+ let y = plotBottom;
1478
+ for (const entry of entries) {
1479
+ const value = entry.totalTokens;
1480
+ const segmentHeight = (value / ceiling) * plotHeight;
1481
+ y -= segmentHeight;
1482
+ if (segmentHeight <= 0.4) continue;
1483
+ const baseColor = styleForModel(entry.model);
1484
+ elements.push(svgRect(x, y, barWidth, segmentHeight, {
1485
+ fill: baseColor,
1486
+ "data-series": "usage-bars",
1487
+ }));
1488
+ // Fast-mode tokens are a subset of the segment: same color, hatched,
1489
+ // never extra height.
1490
+ const fastFraction = value > 0 ? Math.min(1, entry.fastTokens / value) : 0;
1491
+ const fastHeight = segmentHeight * fastFraction;
1492
+ if (fastHeight > 0.5) {
1493
+ elements.push(svgRect(x, y, barWidth, fastHeight, {
1494
+ fill: "url(#fast-mode-hatch)",
1495
+ }));
1496
+ }
1497
+ const valueLabel = valueLabelFor(entry);
1498
+ if (fitsInside(entry)) {
1499
+ const segmentCenter = y + segmentHeight / 2;
1500
+ const amountSize = Math.min(14,
1501
+ 14 * (barWidth - 6) / Math.max(1, textWidth(valueLabel, 14, 700)));
1502
+ const showAmount = segmentHeight >= 34 && amountSize >= 9;
1503
+ segmentLabels.push(`<g data-role="segment-label" data-bin="${binIndex}">`);
1504
+ segmentLabels.push(svgText({
1505
+ x: centerX,
1506
+ y: segmentCenter + (showAmount ? -4 : 4),
1507
+ value: entry.model,
1508
+ fill: COLORS.onFill,
1509
+ size: 12.5,
1510
+ anchor: "middle",
1511
+ }));
1512
+ if (showAmount) segmentLabels.push(svgText({
1513
+ x: centerX,
1514
+ y: segmentCenter + 13,
1515
+ value: valueLabel,
1516
+ fill: "#ffffff",
1517
+ size: amountSize,
1518
+ weight: 700,
1519
+ anchor: "middle",
1520
+ }));
1521
+ segmentLabels.push("</g>");
1522
+ }
1523
+ }
1524
+ const total = binTotalOf(bin);
1525
+ let totalLabelY = y - 9;
1526
+ if (total > 0 && isLabeledColumn(binIndex)) {
1527
+ const estimatedPrefix = (percentMode ? bin.approximate : bin.estimated) ? "≈" : "";
1528
+ const labelValue = percentMode
1529
+ ? `${estimatedPrefix}${pct(total)}`
1530
+ : `${estimatedPrefix}${compact(total)}`;
1531
+ const labelWidth = textWidth(labelValue, 15, 700);
1532
+ const placement = barTotalLabelPlacement({
1533
+ centerX,
1534
+ labelWidth,
1535
+ slotLeft: centerX - slotWidth / 2 + 4,
1536
+ slotRight: centerX + slotWidth / 2 - 4,
1537
+ resetXs,
1538
+ });
1539
+ let labelTop = y;
1540
+ const labelLeft = placement.anchor === "end"
1541
+ ? placement.x - labelWidth
1542
+ : placement.anchor === "start"
1543
+ ? placement.x
1544
+ : placement.x - labelWidth / 2;
1545
+ const labelRight = labelLeft + labelWidth;
1546
+ const clearance = lineTopWithin(
1547
+ labelLeft - 5,
1548
+ labelRight + 5,
1549
+ labelTop - 28,
1550
+ labelTop + 8,
1551
+ );
1552
+ if (clearance !== null) labelTop = Math.min(labelTop, clearance);
1553
+ const lineSafeLabelY = Math.max(plotTop + 12, labelTop - 9);
1554
+ const labelY = placement.placement.startsWith("reset-")
1555
+ ? Math.max(plotTop + 40, lineSafeLabelY)
1556
+ : lineSafeLabelY;
1557
+ totalLabelY = labelY;
1558
+ segmentLabels.push(
1559
+ `<g data-role="bar-total-label" data-placement="${placement.placement}"${placement.placement.startsWith("reset-") ? ' data-clearance="reset-marker"' : ""}>${svgText({
1560
+ x: placement.x,
1561
+ y: labelY,
1562
+ value: labelValue,
1563
+ size: 15,
1564
+ weight: 700,
1565
+ anchor: placement.anchor,
1566
+ })}</g>`,
1567
+ );
1568
+ }
1569
+ // Low-volume columns have room above them for a compact callout;
1570
+ // keep the date axis clear and connect the callout to its actual bar.
1571
+ if (total > 0 && (total / ceiling) * plotHeight < 100) {
1572
+ const details = smallSegments[binIndex]
1573
+ .filter((entry) => entry.totalTokens / total >= 0.05)
1574
+ .sort((left, right) => right.totalTokens - left.totalTokens);
1575
+ const rowHeight = 16;
1576
+ const calloutBottom = totalLabelY - 25;
1577
+ if (details.length) {
1578
+ elements.push(svgLine(centerX, calloutBottom + 5, centerX, totalLabelY - 13, {
1579
+ stroke: COLORS.muted, "stroke-width": 1,
1580
+ }));
1581
+ }
1582
+ details.forEach((entry, rowIndex) => {
1583
+ const label = `${entry.model} ${valueLabelFor(entry)}`;
1584
+ const labelSize = Math.min(10.5, 10.5 * (slotWidth - 6) /
1585
+ Math.max(1, textWidth(label, 10.5, 600)));
1586
+ elements.push(`<g data-role="small-segment-label" data-bin="${binIndex}">`);
1587
+ elements.push(svgText({
1588
+ x: centerX,
1589
+ y: calloutBottom - (details.length - rowIndex - 1) * rowHeight,
1590
+ value: label, fill: styleForModel(entry.model), size: labelSize,
1591
+ weight: 600, anchor: "middle",
1592
+ }));
1593
+ elements.push("</g>");
1594
+ });
1595
+ }
1596
+ // Day labels.
1597
+ if (isDateLabeledColumn(binIndex)) {
1598
+ const weekday = !hourlyMode &&
1599
+ binSize === 1 &&
1600
+ bin.lastDateString === bin.startDateString
1601
+ ? localWeekdayLabel(bin.startDateString, timeZone).toUpperCase()
1602
+ : "";
1603
+ if (bin.partial) {
1604
+ elements.push(chip(centerX, plotBottom + 21, "PARTIAL", {
1605
+ fill: "rgba(255,255,255,.06)",
1606
+ stroke: COLORS.baseline,
1607
+ color: COLORS.secondary,
1608
+ size: 10.5,
1609
+ anchor: "middle",
1610
+ }).markup);
1611
+ } else if (weekday) {
1612
+ elements.push(svgText({
1613
+ x: centerX,
1614
+ y: plotBottom + 25,
1615
+ value: weekday,
1616
+ fill: COLORS.muted,
1617
+ size: 12,
1618
+ anchor: "middle",
1619
+ spacing: "1.44",
1620
+ }));
1621
+ }
1591
1622
  elements.push(svgText({
1592
1623
  x: centerX,
1593
- y: plotBottom + 70,
1594
- value: `PARTIAL · THROUGH ${timestampTimeLabel(reportTimeMs, bounds.timeZone).toUpperCase()}`,
1595
- fill: COLORS.muted,
1596
- size: 10.5,
1597
- weight: 700,
1624
+ y: plotBottom + (weekday || bin.partial ? 45 : 34),
1625
+ value: hourlyMode ? bin.hourLabel : binDateLabel(bin, timeZone),
1626
+ fill: COLORS.secondary,
1627
+ size: 14,
1598
1628
  anchor: "middle",
1599
- spacing: ".45",
1600
1629
  }));
1630
+ if (bin.partial && meta.partialFinalDay) {
1631
+ elements.push(svgText({
1632
+ x: centerX,
1633
+ y: plotBottom + 62,
1634
+ value: `THROUGH ${timeOnlyLabel(meta.effectiveEndMs, timeZone).toUpperCase()}`,
1635
+ fill: COLORS.muted,
1636
+ size: 10.5,
1637
+ anchor: "middle",
1638
+ spacing: "0.63",
1639
+ }));
1640
+ }
1641
+ }
1642
+ });
1643
+
1644
+ // Meter overlay: solid runs mark spans confirmed by repeated equal
1645
+ // readings, while dashed runs bridge unobserved gaps. The line never
1646
+ // extends past the last reading.
1647
+ if (meterVisible) {
1648
+ // Dense windows keep a line per reset but cap the callout chips so the
1649
+ // top of the plot stays readable; scheduled expiries win the labels.
1650
+ const chipLimit = 4;
1651
+ const labeledResets = (() => {
1652
+ if (meter.resets.length <= chipLimit) return new Set(meter.resets);
1653
+ const scheduled = meter.resets.filter(
1654
+ (reset) => reset.kind === "weekly-expiry",
1655
+ );
1656
+ const pool = scheduled.length >= chipLimit ? scheduled : meter.resets;
1657
+ const selected = new Set();
1658
+ for (let index = 0; index < chipLimit; index += 1) {
1659
+ selected.add(
1660
+ pool[Math.round((index / (chipLimit - 1)) * (pool.length - 1))],
1661
+ );
1662
+ }
1663
+ return selected;
1664
+ })();
1665
+ const resetLabelMarkups = [];
1666
+ const resetLabelBounds = [];
1667
+ for (const reset of meter.resets) {
1668
+ const x = xForTs(reset.timestampMs);
1669
+ elements.push(svgLine(x, plotTop, x, plotBottom, {
1670
+ stroke: "rgba(246,183,60,.5)",
1671
+ "stroke-width": 2,
1672
+ "stroke-dasharray": reset.inferred ? "5 6" : null,
1673
+ }));
1674
+ if (!labeledResets.has(reset)) continue;
1675
+ const labelX = Math.max(plotLeft + 32, Math.min(plotRight - 32, x));
1676
+ const resetLabel = chip(
1677
+ labelX,
1678
+ plotTop + 13,
1679
+ reset.kind === "weekly-expiry" ? "RESET" : "RESTART",
1680
+ {
1681
+ fill: COLORS.panel,
1682
+ color: COLORS.meterAxis,
1683
+ size: 10.5,
1684
+ weight: 600,
1685
+ anchor: "middle",
1686
+ mono: true,
1687
+ },
1688
+ );
1689
+ resetLabelBounds.push({
1690
+ left: labelX - resetLabel.width / 2,
1691
+ right: labelX + resetLabel.width / 2,
1692
+ bottom: plotTop + 19,
1693
+ });
1694
+ resetLabelMarkups.push(
1695
+ `<g data-role="meter-reset-label">${resetLabel.markup}</g>`,
1696
+ );
1697
+ }
1698
+
1699
+ for (const segment of meter.segments) {
1700
+ elements.push(svgLine(
1701
+ xForTs(segment.fromMs),
1702
+ yForRemaining(segment.fromPercent),
1703
+ xForTs(segment.toMs),
1704
+ yForRemaining(segment.toPercent),
1705
+ {
1706
+ stroke: COLORS.line,
1707
+ "stroke-width": segment.kind === "confirmed" ? 2.6 : 2,
1708
+ "stroke-dasharray": segment.kind === "confirmed" ? null : "5 5",
1709
+ "stroke-linecap": "round",
1710
+ "data-series": "weekly-meter",
1711
+ },
1712
+ ));
1713
+ }
1714
+ elements.push(...resetLabelMarkups);
1715
+ const latest = meter.observations.at(-1);
1716
+ if (latest) {
1717
+ const label = `${Math.round(latest.remainingPercent)}%`;
1718
+ const px = xForTs(latest.timestampMs);
1719
+ const py = yForRemaining(latest.remainingPercent);
1720
+ const anchorEnd = px > plotRight - 70;
1721
+ const labelX = anchorEnd ? px - 10 : px + 10;
1722
+ const labelWidth = textWidth(label, 11, 700) + 14;
1723
+ const labelLeft = anchorEnd ? labelX - labelWidth : labelX;
1724
+ const labelRight = labelLeft + labelWidth;
1725
+ let labelY = Math.max(plotTop + 14, Math.min(plotBottom - 6, py + 1));
1726
+ const overlapsResetLabel = resetLabelBounds.some((bounds) =>
1727
+ labelLeft < bounds.right + 6 &&
1728
+ labelRight > bounds.left - 6 &&
1729
+ labelY - 13 < bounds.bottom + 6
1730
+ );
1731
+ if (overlapsResetLabel) labelY = plotTop + 38;
1732
+ const latestLabel = chip(
1733
+ labelX,
1734
+ labelY,
1735
+ label,
1736
+ {
1737
+ fill: COLORS.background,
1738
+ stroke: COLORS.line,
1739
+ color: COLORS.line,
1740
+ size: 11,
1741
+ anchor: anchorEnd ? "end" : "start",
1742
+ mono: true,
1743
+ },
1744
+ );
1745
+ elements.push(
1746
+ `<g data-role="meter-latest-label">${latestLabel.markup}</g>`,
1747
+ );
1601
1748
  }
1602
1749
  }
1603
- }
1604
- for (const pill of pills) {
1605
- const leaderX = pill.x > pill.dotX ? pill.x : pill.x + pill.w;
1606
- const leaderY = Math.max(pill.y + 6, Math.min(pill.y + pill.h - 6, pill.dotY));
1607
- elements.push(`<line x1="${pill.dotX.toFixed(2)}" y1="${pill.dotY.toFixed(2)}" x2="${leaderX.toFixed(2)}" y2="${leaderY.toFixed(2)}" stroke="rgba(246,183,60,.58)" stroke-width="1"/>`);
1608
- elements.push(svgRect(pill.x, pill.y, pill.w, pill.h, {
1609
- rx: 5,
1610
- fill: COLORS.background,
1611
- stroke: COLORS.line,
1612
- "stroke-width": 1,
1613
- }));
1614
- elements.push(svgText({
1615
- x: pill.tx,
1616
- y: pill.ty,
1617
- value: pill.label,
1618
- fill: COLORS.line,
1619
- size: 12,
1620
- weight: 700,
1621
- anchor: "middle",
1622
- mono: true,
1623
- }));
1750
+
1751
+ elements.push(...segmentLabels);
1752
+ return panelTop + panelHeight;
1624
1753
  }
1625
1754
 
1626
- // ---- Legend row ----
1627
- const legendModels = sortedModelEntries(
1628
- percentMode ? burn.totals : actual.totals,
1629
- ).map(([model]) => model);
1630
- let legendX = outer;
1631
- const legendItem = (swatchMarkup, swatchWidth, label) => {
1632
- elements.push(swatchMarkup);
1755
+ // ------------------------------------------------------------ lower panels
1756
+ function panelHeading(x, y, title, suffix = null) {
1633
1757
  elements.push(svgText({
1634
- x: legendX + swatchWidth + 9,
1635
- y: legendBaseline,
1636
- value: label,
1637
- fill: COLORS.secondary,
1638
- size: 13.5,
1758
+ x: x + 16,
1759
+ y: y + 25,
1760
+ value: title,
1761
+ fill: COLORS.leftAxis,
1762
+ size: 12,
1763
+ weight: 600,
1764
+ spacing: "1.08",
1639
1765
  }));
1640
- legendX += swatchWidth + 9 + textWidth(label, 13.5) + 24;
1641
- };
1642
- for (const model of legendModels) {
1643
- legendItem(
1644
- svgRect(legendX, legendBaseline - 10, 13, 11, { fill: styleForModel(model) }),
1645
- 13,
1646
- model,
1647
- );
1648
- }
1649
- if (hasFast && legendModels.length) {
1650
- legendItem(
1651
- svgRect(legendX, legendBaseline - 10, 13, 11, {
1652
- fill: fastShade(styleForModel(legendModels[0])),
1653
- }),
1654
- 13,
1655
- "Darker shade = fast mode",
1656
- );
1657
- }
1658
- if (hasLine) {
1659
- if (hasHeldSegment) {
1660
- legendItem(
1661
- `<line x1="${legendX}" y1="${legendBaseline - 5}" x2="${legendX + 17}" y2="${legendBaseline - 5}" stroke="${COLORS.line}" stroke-width="3"/><line x1="${legendX + 25}" y1="${legendBaseline - 5}" x2="${legendX + 42}" y2="${legendBaseline - 5}" stroke="${COLORS.line}" stroke-width="2.25" stroke-dasharray="5 5" opacity=".7"/>`,
1662
- 42,
1663
- "Limit: reported / awaiting update",
1664
- );
1665
- } else {
1666
- legendItem(
1667
- svgRect(legendX, legendBaseline - 6, 20, 3, { fill: COLORS.line }),
1668
- 20,
1669
- "OpenAI weekly-limit reading",
1670
- );
1766
+ if (suffix) {
1767
+ elements.push(svgText({
1768
+ x: x + 16 + spacedWidth(title, 12, 600, 1.08) + 8,
1769
+ y: y + 25,
1770
+ value: suffix,
1771
+ fill: COLORS.muted,
1772
+ size: 11.5,
1773
+ }));
1671
1774
  }
1672
1775
  }
1673
1776
 
1674
- // ---- Cache rate by period (compressed strip) ----
1675
- elements.push(`<line x1="${outer}" y1="${cacheRuleY}" x2="${contentRight}" y2="${cacheRuleY}" stroke="${COLORS.rule}" stroke-width="1"/>`);
1676
- elements.push(svgText({
1677
- x: outer,
1678
- y: cacheHeaderBaseline,
1679
- value: "CACHE RATE BY PERIOD",
1680
- fill: COLORS.muted,
1681
- size: 12,
1682
- spacing: "1.32",
1683
- }));
1684
- if (hasCache) {
1685
- const cacheLegendItems = [
1686
- { swatch: COLORS.cached, label: "Cached" },
1687
- { swatch: COLORS.uncached, label: "Uncached" },
1688
- {
1689
- swatch: null,
1690
- label: `${percent(cacheData.rate)} weighted · ${compact(cacheData.cachedInputTokens)} of ${compact(cacheData.inputTokens)} input cached`,
1691
- },
1692
- ];
1693
- let cacheLegendX = contentRight - cacheLegendItems.reduce(
1694
- (sum, item) =>
1695
- sum + (item.swatch ? 20 : 0) + textWidth(item.label, 12.5) + 18,
1696
- -18,
1777
+ function buildCacheByDayPanel(x, y, panelWidth, panelHeight) {
1778
+ const hourlyMode = meta.granularity === "hour" &&
1779
+ Array.isArray(vm.hourly) &&
1780
+ vm.hourly.length > 0;
1781
+ const hourlyRows = hourlyMode ? hourlyChartRows(vm.hourly, timeZone) : null;
1782
+ panelHeading(
1783
+ x,
1784
+ y,
1785
+ hourlyMode ? "CACHE EFFICIENCY BY HOUR" : "CACHE EFFICIENCY BY DAY",
1786
+ "(input-weighted)",
1697
1787
  );
1698
- for (const item of cacheLegendItems) {
1699
- if (item.swatch) {
1700
- elements.push(svgRect(cacheLegendX, cacheHeaderBaseline - 10, 13, 11, {
1701
- rx: 2,
1702
- fill: item.swatch,
1703
- }));
1704
- cacheLegendX += 20;
1705
- }
1788
+ const inner = panelWidth - 32;
1789
+ const left = x + 16;
1790
+
1791
+ const binSize = hourlyMode
1792
+ ? 1
1793
+ : chooseBinSize(meta.rangeDays, inner, {
1794
+ minBinWidth: 18,
1795
+ preferDaily: true,
1796
+ });
1797
+ const cacheBins = hourlyMode ? hourlyRows : binDailyRows(vm.daily, binSize);
1798
+ const rated = cacheBins.filter((bin) => bin.inputTokens > 0);
1799
+ if (!rated.length) {
1706
1800
  elements.push(svgText({
1707
- x: cacheLegendX,
1708
- y: cacheHeaderBaseline,
1709
- value: item.label,
1801
+ x: left,
1802
+ y: y + 60,
1803
+ value: "No measured input-token breakdown in this range",
1710
1804
  fill: COLORS.muted,
1711
1805
  size: 12.5,
1712
1806
  }));
1713
- cacheLegendX += textWidth(item.label, 12.5) + 18;
1807
+ return;
1714
1808
  }
1715
- for (const value of [100, 50, 0]) {
1716
- const y = cachePlotBottom - (value / 100) * cachePlotHeight;
1717
- elements.push(`<line x1="${plotLeft}" y1="${y.toFixed(2)}" x2="${plotRight}" y2="${y.toFixed(2)}" stroke="${value === 0 ? COLORS.baseline : COLORS.grid}" stroke-width="1"/>`);
1809
+
1810
+ const rates = rated.map((bin) => (bin.cachedInputTokens / bin.inputTokens) * 100);
1811
+ const minRate = Math.min(...rates);
1812
+ // Zoomed axis: at least a 20-point span, floor snapped to tens, 0–80.
1813
+ let floor = Math.max(0, Math.min(80, Math.floor((minRate - 5) / 10) * 10));
1814
+ floor = Math.min(floor, 80);
1815
+ if (floor > 0) {
1718
1816
  elements.push(svgText({
1719
- x: plotLeft - 14,
1720
- y: y + 4,
1721
- value: `${value}%`,
1817
+ x: x + panelWidth - 16,
1818
+ y: y + 25,
1819
+ value: "ZOOMED SCALE",
1722
1820
  fill: COLORS.muted,
1723
- size: 11.5,
1821
+ size: 9.5,
1822
+ weight: 600,
1823
+ spacing: "0.65",
1824
+ anchor: "end",
1825
+ }));
1826
+ }
1827
+
1828
+ const lineTop = y + 52;
1829
+ const lineHeight = 64;
1830
+ const lineBottom = lineTop + lineHeight;
1831
+ const axisLabels = [
1832
+ { value: 100, y: lineTop },
1833
+ { value: (100 + floor) / 2, y: lineTop + lineHeight / 2 },
1834
+ { value: floor, y: lineBottom },
1835
+ ];
1836
+ const axisWidth = 34;
1837
+ for (const label of axisLabels) {
1838
+ elements.push(svgText({
1839
+ x: left + axisWidth - 6,
1840
+ y: label.y + 4,
1841
+ value: `${Math.round(label.value)}%`,
1842
+ fill: COLORS.muted,
1843
+ size: 10.5,
1724
1844
  anchor: "end",
1725
1845
  mono: true,
1726
1846
  }));
1847
+ elements.push(svgLine(left + axisWidth, label.y, x + panelWidth - 16, label.y, {
1848
+ stroke: COLORS.grid,
1849
+ "stroke-width": 1,
1850
+ }));
1727
1851
  }
1728
- const cacheSlotWidth = plotWidth / cacheData.binCount;
1729
- const cacheBarWidth = Math.min(
1730
- 74,
1731
- Math.max(MIN_BAR_WIDTH, cacheSlotWidth * 0.6),
1732
- );
1733
- const showCacheRateLabels = cacheSlotWidth >= 46 && cacheData.binCount <= 20;
1734
- cacheData.bins.forEach((bin, binIndex) => {
1735
- const centerX = plotLeft + (binIndex + 0.5) * cacheSlotWidth;
1736
- const barX = centerX - cacheBarWidth / 2;
1737
- if (Number.isFinite(bin.rate)) {
1738
- elements.push(svgRect(barX, cachePlotTop, cacheBarWidth, cachePlotHeight, {
1739
- rx: 3,
1740
- fill: COLORS.uncached,
1741
- opacity: ".88",
1852
+ const chartLeft = left + axisWidth + 6;
1853
+ const chartWidth = x + panelWidth - 16 - chartLeft;
1854
+ const slot = chartWidth / cacheBins.length;
1855
+ const yForRate = (rate) =>
1856
+ lineBottom - ((Math.max(floor, Math.min(100, rate)) - floor) / (100 - floor)) * lineHeight;
1857
+
1858
+ const linePoints = [];
1859
+ cacheBins.forEach((bin, index) => {
1860
+ if (!(bin.inputTokens > 0)) {
1861
+ linePoints.push(null);
1862
+ return;
1863
+ }
1864
+ linePoints.push({
1865
+ x: chartLeft + (index + 0.5) * slot,
1866
+ y: yForRate((bin.cachedInputTokens / bin.inputTokens) * 100),
1867
+ rate: (bin.cachedInputTokens / bin.inputTokens) * 100,
1868
+ estimated: bin.estimated,
1869
+ });
1870
+ });
1871
+ for (let index = 0; index < linePoints.length - 1; index += 1) {
1872
+ const from = linePoints[index];
1873
+ const to = linePoints[index + 1];
1874
+ if (!from || !to) continue;
1875
+ elements.push(svgLine(from.x, from.y, to.x, to.y, {
1876
+ stroke: COLORS.cache,
1877
+ "stroke-width": 2,
1878
+ "stroke-linecap": "round",
1879
+ }));
1880
+ }
1881
+ const rateLabelStep = cacheBins.length <= 8 ? 1 : Math.ceil(cacheBins.length / 8);
1882
+ const cacheDateLabelIndices = selectDateLabelIndices(cacheBins, {
1883
+ timeZone,
1884
+ slotWidth: slot,
1885
+ labelStep: rateLabelStep,
1886
+ labelSize: 10,
1887
+ labelForBin: hourlyMode
1888
+ ? (bin) => bin.hourLabel
1889
+ : (bin) => binDateLabel(bin, timeZone),
1890
+ });
1891
+ linePoints.forEach((point, index) => {
1892
+ if (!point) return;
1893
+ elements.push(`<circle cx="${point.x.toFixed(2)}" cy="${point.y.toFixed(2)}" r="3.2" fill="${COLORS.cache}"/>`);
1894
+ if (cacheDateLabelIndices.has(index)) {
1895
+ elements.push(svgText({
1896
+ x: point.x,
1897
+ y: point.y - 8,
1898
+ value: approximateLabel(pct(point.rate), point.estimated),
1899
+ fill: COLORS.secondary,
1900
+ size: 10.5,
1901
+ anchor: "middle",
1902
+ }));
1903
+ }
1904
+ });
1905
+
1906
+ // Input-volume columns beneath the rate line.
1907
+ const columnsTop = lineBottom + 26;
1908
+ const columnsHeight = 44;
1909
+ const columnsBottom = columnsTop + columnsHeight;
1910
+ const maxInput = Math.max(...cacheBins.map((bin) => bin.inputTokens), 1);
1911
+ elements.push(svgText({
1912
+ x: left + axisWidth - 6,
1913
+ y: columnsBottom - columnsHeight / 2 + 4,
1914
+ value: "Input",
1915
+ fill: COLORS.muted,
1916
+ size: 10.5,
1917
+ anchor: "end",
1918
+ }));
1919
+ cacheBins.forEach((bin, index) => {
1920
+ const centerX = chartLeft + (index + 0.5) * slot;
1921
+ const columnWidth = Math.min(30, Math.max(8, slot * 0.5));
1922
+ if (bin.unobserved) {
1923
+ elements.push(svgRect(
1924
+ centerX - columnWidth / 2,
1925
+ lineTop,
1926
+ columnWidth,
1927
+ columnsBottom - lineTop,
1928
+ { fill: "rgba(255,255,255,.025)" },
1929
+ ));
1930
+ }
1931
+ const columnHeight = (bin.inputTokens / maxInput) * columnsHeight;
1932
+ if (columnHeight > 0.4) {
1933
+ elements.push(svgRect(centerX - columnWidth / 2, columnsBottom - columnHeight, columnWidth, columnHeight, {
1934
+ fill: COLORS.cache,
1935
+ opacity: 0.85,
1936
+ rx: 1.5,
1742
1937
  }));
1743
- const cachedHeight = cachePlotHeight * (bin.rate / 100);
1744
- if (cachedHeight > 0) {
1745
- elements.push(svgRect(
1746
- barX,
1747
- cachePlotBottom - cachedHeight,
1748
- cacheBarWidth,
1749
- cachedHeight,
1750
- { rx: 2, fill: COLORS.cached },
1751
- ));
1938
+ }
1939
+ if (cacheDateLabelIndices.has(index)) {
1940
+ if (bin.inputTokens > 0) {
1941
+ elements.push(svgText({
1942
+ x: centerX,
1943
+ y: columnsBottom - columnHeight - 5,
1944
+ value: approximateLabel(compact(bin.inputTokens), bin.estimated),
1945
+ fill: COLORS.muted,
1946
+ size: 10,
1947
+ anchor: "middle",
1948
+ }));
1752
1949
  }
1753
- if (showCacheRateLabels) {
1950
+ if (bin.unobserved) {
1754
1951
  elements.push(svgText({
1755
1952
  x: centerX,
1756
- y: cachePlotTop - 9,
1757
- value: percent(bin.rate),
1758
- fill: COLORS.secondary,
1759
- size: 11.5,
1760
- weight: 700,
1953
+ y: columnsBottom + 15,
1954
+ value: "UNOBSERVED",
1955
+ fill: COLORS.muted,
1956
+ size: 8.5,
1957
+ weight: 600,
1958
+ spacing: "0.45",
1959
+ anchor: "middle",
1960
+ }));
1961
+ } else {
1962
+ elements.push(svgText({
1963
+ x: centerX,
1964
+ y: columnsBottom + 15,
1965
+ value: hourlyMode ? bin.hourLabel : binDateLabel(bin, timeZone),
1966
+ fill: COLORS.muted,
1967
+ size: 10,
1761
1968
  anchor: "middle",
1762
- mono: true,
1763
1969
  }));
1764
1970
  }
1765
- } else {
1766
- // No measured input this period: an empty track with a midline dash.
1767
- elements.push(svgRect(barX, cachePlotTop, cacheBarWidth, cachePlotHeight, {
1768
- rx: 3,
1769
- fill: COLORS.cacheTrack,
1770
- stroke: COLORS.baseline,
1771
- "stroke-width": 1,
1772
- }));
1773
- elements.push(`<line x1="${(barX + 5).toFixed(2)}" y1="${cachePlotTop + cachePlotHeight / 2}" x2="${(barX + cacheBarWidth - 5).toFixed(2)}" y2="${cachePlotTop + cachePlotHeight / 2}" stroke="${COLORS.muted}" stroke-width="1"/>`);
1774
1971
  }
1775
1972
  });
1776
- if (Number.isFinite(cacheData.rate)) {
1777
- const lineY = cachePlotBottom - (cacheData.rate / 100) * cachePlotHeight;
1778
- elements.push(`<line x1="${plotLeft}" y1="${lineY.toFixed(2)}" x2="${plotRight}" y2="${lineY.toFixed(2)}" stroke="${COLORS.weighted}" stroke-width="1.6" stroke-dasharray="6 5"/>`);
1779
- elements.push(svgText({
1780
- x: plotRight + 8,
1781
- y: lineY + 4,
1782
- value: percent(cacheData.rate),
1783
- fill: COLORS.weighted,
1784
- size: 11.5,
1785
- weight: 700,
1786
- mono: true,
1787
- }));
1788
- }
1789
- } else {
1973
+
1974
+ // Summary strip.
1975
+ const stripY = y + panelHeight - 32;
1976
+ const approxUncached = vm.coverage.estimated;
1790
1977
  elements.push(svgText({
1791
- x: outer,
1792
- y: cacheHeaderBaseline + 24,
1793
- value: "No events with a usable input-token breakdown in this range.",
1794
- fill: COLORS.secondary,
1795
- size: 13,
1978
+ x: left,
1979
+ y: stripY + 15,
1980
+ value: truncateToWidth(
1981
+ `${approximateLabel(pct(summary.cacheRatePercent), summary.estimated)} input-weighted · ${approximateLabel(compact(summary.cachedInputTokens), summary.estimated)} of ${approximateLabel(compact(summary.inputTokens), summary.estimated)} input cached · ${approxUncached ? "≈" : ""}${compact(summary.uncachedInputTokens)} uncached`,
1982
+ inner,
1983
+ 11,
1984
+ ),
1985
+ fill: COLORS.cache,
1986
+ size: 11,
1796
1987
  }));
1797
1988
  }
1798
1989
 
1799
- // ---- Top projects + cache rate by model ----
1800
- elements.push(`<line x1="${outer}" y1="${bottomRuleY}" x2="${contentRight}" y2="${bottomRuleY}" stroke="${COLORS.rule}" stroke-width="1"/>`);
1801
- const sectionBaseline = bottomTop + 10;
1802
- const columnGap = 28;
1803
- const modelColumnWidth = Math.min(
1804
- 368,
1805
- Math.max(300, contentWidth * 0.32),
1806
- );
1807
- const leftColumnWidth =
1808
- contentWidth - columnGap - 28 - modelColumnWidth;
1809
- const leftColumnRight = outer + leftColumnWidth;
1810
- const dividerX = leftColumnRight + columnGap;
1811
- const modelColumnX = dividerX + 28;
1812
-
1813
- const projectHeader = "WHERE IT WENT · TOP PROJECTS";
1814
- elements.push(svgText({
1815
- x: outer,
1816
- y: sectionBaseline,
1817
- value: projectHeader,
1818
- fill: COLORS.muted,
1819
- size: 12,
1820
- spacing: "1.32",
1821
- }));
1822
- const topRows = rows.slice(0, 3);
1823
- const restRows = rows.slice(3);
1824
- const topTokens = topRows.reduce((sum, row) => sum + row.totalTokens, 0);
1825
- const topShare = totalTokens > 0
1826
- ? percent((topTokens / totalTokens) * 100)
1827
- : "—";
1828
- const fullProjectSummary = `${rows.length} ${rows.length === 1 ? "project" : "projects"} active · top ${topRows.length} = ${topShare} of tokens`;
1829
- const shortProjectSummary = `top ${topRows.length} = ${topShare}`;
1830
- const projectHeaderWidth =
1831
- textWidth(projectHeader, 12) +
1832
- Math.max(0, projectHeader.length - 1) * 1.32;
1833
- const projectSummary = textWidth(fullProjectSummary, 12.5) <=
1834
- leftColumnRight - outer - projectHeaderWidth - 16
1835
- ? fullProjectSummary
1836
- : shortProjectSummary;
1837
- elements.push(svgText({
1838
- x: leftColumnRight,
1839
- y: sectionBaseline,
1840
- value: projectSummary,
1841
- fill: COLORS.muted,
1842
- size: 12.5,
1843
- anchor: "end",
1844
- }));
1990
+ function buildProjectsPanel(x, y, panelWidth, panelHeight) {
1991
+ panelHeading(x, y, "WHERE IT WENT · TOP PROJECTS");
1992
+ elements.push(svgText({
1993
+ x: x + panelWidth - 16,
1994
+ y: y + 25,
1995
+ value: `${summary.activeProjects} ${summary.activeProjects === 1 ? "project" : "projects"} active`,
1996
+ fill: COLORS.muted,
1997
+ size: 11.5,
1998
+ anchor: "end",
1999
+ }));
1845
2000
 
1846
- const displayRows = topRows.map((row, index) => ({
1847
- rank: String(index + 1).padStart(2, "0"),
1848
- name: row.displayProject ?? row.project,
1849
- tokens: row.totalTokens,
1850
- fill: COLORS.leftAxis,
1851
- muted: false,
1852
- }));
1853
- if (restRows.length) {
1854
- displayRows.push({
1855
- rank: null,
1856
- name: restRows.length === 1
1857
- ? (restRows[0].displayProject ?? restRows[0].project)
1858
- : `${restRows.length} other projects`,
1859
- tokens: restRows.reduce((sum, row) => sum + row.totalTokens, 0),
1860
- fill: COLORS.remainderBar,
1861
- muted: true,
1862
- });
1863
- }
1864
- const rowGap = 12;
1865
- const rankX = outer;
1866
- const nameX = rankX + 22 + rowGap;
1867
- const projectBarX = nameX + 190 + rowGap;
1868
- const tokensRight = leftColumnRight - 62 - rowGap;
1869
- const projectBarWidth = tokensRight - (86 + rowGap) - projectBarX;
1870
- const projectNameWidth = projectBarX - nameX - rowGap;
1871
- displayRows.forEach((row, index) => {
1872
- const centerY = bottomTop + 29 + index * 29 + 9;
1873
- const projectName = truncateText(
1874
- row.name,
1875
- projectNameWidth,
1876
- 15,
1877
- row.muted ? 400 : 700,
1878
- );
1879
- if (row.rank) {
2001
+ const rows = vm.projects.map((row, index) => ({
2002
+ rank: String(index + 1).padStart(2, "0"),
2003
+ name: options.private ? `Project ${index + 1}` : row.displayProject,
2004
+ tokens: row.totalTokens,
2005
+ share: row.sharePercent,
2006
+ estimated: row.estimated,
2007
+ muted: false,
2008
+ }));
2009
+ if (vm.projectRemainder.count > 0) {
2010
+ rows.push({
2011
+ rank: String(rows.length + 1).padStart(2, "0"),
2012
+ name: vm.projectRemainder.count === 1
2013
+ ? "1 other project"
2014
+ : `${vm.projectRemainder.count} other projects`,
2015
+ tokens: vm.projectRemainder.totalTokens,
2016
+ share: vm.projectRemainder.sharePercent,
2017
+ estimated: vm.projectRemainder.estimated,
2018
+ muted: true,
2019
+ });
2020
+ }
2021
+ while (rows.length < PROJECT_PANEL_ROW_COUNT) {
2022
+ rows.push({
2023
+ rank: String(rows.length + 1).padStart(2, "0"),
2024
+ name: rows.length === 0 ? "No project activity in range" : "—",
2025
+ empty: true,
2026
+ muted: true,
2027
+ });
2028
+ }
2029
+
2030
+ const rowTop = y + 52;
2031
+ const rowGap = 33;
2032
+ const rankX = x + 16;
2033
+ const nameX = rankX + 26;
2034
+ const pctRight = x + panelWidth - 16;
2035
+ const tokensRight = pctRight - 52;
2036
+ const barWidth = Math.max(56, panelWidth * 0.2);
2037
+ const barX = tokensRight - 66 - barWidth;
2038
+ const nameWidth = barX - nameX - 12;
2039
+ rows.forEach((row, index) => {
2040
+ const centerY = rowTop + index * rowGap + 8;
2041
+ elements.push(`<g data-role="project-row" data-kind="${row.empty ? "placeholder" : "data"}" data-baseline="${centerY + 4}">`);
1880
2042
  elements.push(svgText({
1881
2043
  x: rankX,
1882
- y: centerY + 5,
2044
+ y: centerY + 4,
1883
2045
  value: row.rank,
1884
2046
  fill: COLORS.muted,
1885
- size: 13,
2047
+ size: 12,
1886
2048
  mono: true,
1887
2049
  }));
2050
+ elements.push(svgText({
2051
+ x: nameX,
2052
+ y: centerY + 4,
2053
+ value: truncateToWidth(row.name, nameWidth, 13.5, row.muted ? 400 : 700),
2054
+ fill: row.muted ? COLORS.muted : COLORS.ink,
2055
+ size: 13.5,
2056
+ weight: row.muted ? 400 : 700,
2057
+ }));
2058
+ if (!row.empty) {
2059
+ elements.push(svgRect(barX, centerY - 4, barWidth, 9, {
2060
+ rx: 2,
2061
+ fill: COLORS.projectTrack,
2062
+ }));
2063
+ const fillWidth = (Math.max(0, Math.min(100, row.share)) / 100) * barWidth;
2064
+ if (fillWidth > 0) {
2065
+ elements.push(svgRect(barX, centerY - 4, fillWidth, 9, {
2066
+ rx: 2,
2067
+ fill: row.muted ? COLORS.remainderBar : COLORS.leftAxis,
2068
+ }));
2069
+ }
2070
+ elements.push(svgText({
2071
+ x: tokensRight,
2072
+ y: centerY + 4,
2073
+ value: approximateLabel(compact(row.tokens), row.estimated),
2074
+ fill: row.muted ? COLORS.secondary : COLORS.ink,
2075
+ size: 13.5,
2076
+ weight: 700,
2077
+ anchor: "end",
2078
+ }));
2079
+ elements.push(svgText({
2080
+ x: pctRight,
2081
+ y: centerY + 4,
2082
+ value: approximateLabel(pct(row.share), summary.estimated),
2083
+ fill: COLORS.muted,
2084
+ size: 12,
2085
+ anchor: "end",
2086
+ }));
2087
+ }
2088
+ elements.push("</g>");
2089
+ });
2090
+
2091
+ if (summary.topFourProjectSharePercent !== null && vm.projects.length) {
2092
+ const stripY = y + panelHeight - 32;
2093
+ elements.push(svgText({
2094
+ x: x + 16,
2095
+ y: stripY + 15,
2096
+ value: `Top ${Math.min(4, vm.projects.length)} projects = ${approximateLabel(pct(summary.topFourProjectSharePercent), summary.estimated)} of tokens`,
2097
+ fill: COLORS.leftAxis,
2098
+ size: 11.5,
2099
+ }));
1888
2100
  }
1889
- elements.push(svgText({
1890
- x: nameX,
1891
- y: centerY + 5,
1892
- value: projectName,
1893
- fill: row.muted ? COLORS.muted : COLORS.ink,
1894
- size: 15,
1895
- weight: row.muted ? 400 : 700,
1896
- }));
1897
- elements.push(svgRect(projectBarX, centerY - 5, projectBarWidth, 10, {
1898
- rx: 2,
1899
- fill: COLORS.projectTrack,
1900
- }));
1901
- const share = totalTokens > 0 ? (row.tokens / totalTokens) * 100 : 0;
1902
- const fillWidth = (Math.min(100, share) / 100) * projectBarWidth;
1903
- if (fillWidth > 0) {
1904
- elements.push(svgRect(projectBarX, centerY - 5, fillWidth, 10, {
1905
- rx: 2,
1906
- fill: row.fill,
2101
+ }
2102
+
2103
+ function buildModelCachePanel(x, y, panelWidth, panelHeight) {
2104
+ panelHeading(x, y, "CACHE EFFICIENCY BY MODEL", "(input-weighted)");
2105
+
2106
+ // Combine minor models past the fourth row so the table always fits.
2107
+ const source = vm.models.filter((row) => row.totalTokens > 0);
2108
+ const rows = source.slice(0, 4).map((row) => ({ ...row }));
2109
+ const overflow = source.slice(4);
2110
+ if (overflow.length) {
2111
+ const merged = overflow.reduce(
2112
+ (sum, row) => {
2113
+ sum.cacheInputTokens += row.cacheInputTokens;
2114
+ sum.cachedInputTokens += row.cachedInputTokens;
2115
+ sum.estimated ||= row.estimated === true;
2116
+ return sum;
2117
+ },
2118
+ {
2119
+ model: `${overflow.length} other models`,
2120
+ cacheInputTokens: 0,
2121
+ cachedInputTokens: 0,
2122
+ estimated: false,
2123
+ },
2124
+ );
2125
+ // A single overflow row is still one identifiable model. Naming it
2126
+ // directly avoids making its cache rate look like an unexplained
2127
+ // aggregate; reserve the aggregate label for real multi-model overflow.
2128
+ merged.model = overflow.length === 1
2129
+ ? overflow[0].model
2130
+ : `${overflow.length} other models`;
2131
+ merged.uncachedInputTokens = Math.max(
2132
+ 0,
2133
+ merged.cacheInputTokens - merged.cachedInputTokens,
2134
+ );
2135
+ merged.cacheRatePercent = merged.cacheInputTokens > 0
2136
+ ? (merged.cachedInputTokens / merged.cacheInputTokens) * 100
2137
+ : null;
2138
+ merged.combined = overflow.length > 1;
2139
+ rows.push(merged);
2140
+ }
2141
+ if (!rows.length) {
2142
+ elements.push(svgText({
2143
+ x: x + 16,
2144
+ y: y + 60,
2145
+ value: "No measured input-token breakdown in this range",
2146
+ fill: COLORS.muted,
2147
+ size: 12.5,
1907
2148
  }));
2149
+ return;
1908
2150
  }
2151
+
2152
+ const inputRight = x + panelWidth - 104;
2153
+ const uncachedRight = x + panelWidth - 16;
1909
2154
  elements.push(svgText({
1910
- x: tokensRight,
1911
- y: centerY + 5,
1912
- value: compact(row.tokens),
1913
- fill: row.muted ? COLORS.secondary : COLORS.ink,
1914
- size: 15,
1915
- weight: 700,
2155
+ x: inputRight,
2156
+ y: y + 47,
2157
+ value: "INPUT",
2158
+ fill: COLORS.muted,
2159
+ size: 10.5,
2160
+ spacing: "0.84",
1916
2161
  anchor: "end",
1917
2162
  }));
1918
2163
  elements.push(svgText({
1919
- x: leftColumnRight,
1920
- y: centerY + 5,
1921
- value: percent(share),
2164
+ x: uncachedRight,
2165
+ y: y + 47,
2166
+ value: "UNCACHED",
1922
2167
  fill: COLORS.muted,
1923
- size: 13.5,
2168
+ size: 10.5,
2169
+ spacing: "0.84",
1924
2170
  anchor: "end",
1925
2171
  }));
1926
- });
1927
2172
 
1928
- elements.push(`<line x1="${dividerX}" y1="${bottomTop}" x2="${dividerX}" y2="${bottomTop + bottomBlockHeight}" stroke="${COLORS.rule}" stroke-width="1"/>`);
1929
- elements.push(svgText({
1930
- x: modelColumnX,
1931
- y: sectionBaseline,
1932
- value: "CACHE RATE BY MODEL",
1933
- fill: COLORS.muted,
1934
- size: 12,
1935
- spacing: "1.32",
1936
- }));
1937
- if (cacheModelRows.length === 0) {
2173
+ const rowTop = y + 64;
2174
+ const rowGap = 33;
2175
+ const nameX = x + 30;
2176
+ const rateX = x + Math.min(150, panelWidth * 0.34);
2177
+ const barX = rateX + 52;
2178
+ const barWidth = Math.max(44, inputRight - 66 - barX);
2179
+ rows.forEach((row, index) => {
2180
+ const centerY = rowTop + index * rowGap;
2181
+ elements.push(`<g data-role="model-cache-row" data-baseline="${centerY}">`);
2182
+ if (!row.combined) {
2183
+ elements.push(`<circle cx="${x + 19}" cy="${centerY - 4}" r="4" fill="${styleForModel(row.model)}"/>`);
2184
+ }
2185
+ elements.push(svgText({
2186
+ x: nameX,
2187
+ y: centerY,
2188
+ value: truncateToWidth(row.model, rateX - nameX - 8, 13, row.combined ? 400 : 600),
2189
+ fill: row.combined ? COLORS.muted : COLORS.ink,
2190
+ size: 13,
2191
+ weight: row.combined ? 400 : 600,
2192
+ }));
2193
+ const hasComponents = row.cacheRatePercent !== null;
2194
+ elements.push(svgText({
2195
+ x: rateX + 44,
2196
+ y: centerY,
2197
+ value: hasComponents
2198
+ ? approximateLabel(pct(row.cacheRatePercent), row.estimated)
2199
+ : "—",
2200
+ fill: COLORS.ink,
2201
+ size: 13,
2202
+ weight: 700,
2203
+ anchor: "end",
2204
+ mono: true,
2205
+ }));
2206
+ if (hasComponents && barWidth > 30) {
2207
+ elements.push(svgRect(barX, centerY - 8, barWidth, 8, {
2208
+ rx: 2,
2209
+ fill: COLORS.track,
2210
+ }));
2211
+ const cachedWidth = (row.cacheRatePercent / 100) * barWidth;
2212
+ if (cachedWidth > 0) {
2213
+ elements.push(svgRect(barX, centerY - 8, cachedWidth, 8, {
2214
+ rx: 2,
2215
+ fill: COLORS.cache,
2216
+ }));
2217
+ }
2218
+ if (barWidth - cachedWidth > 0.5) {
2219
+ elements.push(svgRect(barX + cachedWidth, centerY - 8, barWidth - cachedWidth, 8, {
2220
+ fill: COLORS.uncached,
2221
+ rx: 2,
2222
+ }));
2223
+ }
2224
+ }
2225
+ elements.push(svgText({
2226
+ x: inputRight,
2227
+ y: centerY,
2228
+ value: hasComponents
2229
+ ? approximateLabel(compact(row.cacheInputTokens), row.estimated)
2230
+ : "—",
2231
+ fill: COLORS.secondary,
2232
+ size: 12.5,
2233
+ anchor: "end",
2234
+ mono: true,
2235
+ }));
2236
+ elements.push(svgText({
2237
+ x: uncachedRight,
2238
+ y: centerY,
2239
+ value: hasComponents
2240
+ ? approximateLabel(compact(row.uncachedInputTokens), row.estimated)
2241
+ : "—",
2242
+ fill: COLORS.secondary,
2243
+ size: 12.5,
2244
+ anchor: "end",
2245
+ mono: true,
2246
+ }));
2247
+ elements.push("</g>");
2248
+ });
2249
+
1938
2250
  elements.push(svgText({
1939
- x: modelColumnX,
1940
- y: bottomTop + 29 + 14,
1941
- value: "No measured input to break out.",
1942
- fill: COLORS.secondary,
1943
- size: 13,
2251
+ x: x + 16,
2252
+ y: y + panelHeight - 15,
2253
+ value: "Uncached = input not served from cache (input-weighted)",
2254
+ fill: COLORS.muted,
2255
+ size: 11,
1944
2256
  }));
1945
2257
  }
1946
- const compactModelColumns = modelColumnWidth < 340;
1947
- const modelLabelSize = compactModelColumns ? 12.5 : 13.5;
1948
- const modelRateSize = compactModelColumns ? 11.5 : 12.5;
1949
- const widestModelLabel = cacheModelRows.reduce(
1950
- (width, model) => Math.max(
1951
- width,
1952
- textWidth(model.model, modelLabelSize, 700),
1953
- ),
1954
- 0,
1955
- );
1956
- const minimumRateRight =
1957
- modelColumnX +
1958
- 18 +
1959
- widestModelLabel +
1960
- 8 +
1961
- textWidth("100.0%", modelRateSize, 700);
1962
- const modelRateRight = Math.max(
1963
- modelColumnX + modelColumnWidth * 0.42,
1964
- minimumRateRight,
1965
- );
1966
- const modelBarX = modelRateRight + 15;
1967
- const modelInputReserve = Math.min(
1968
- 58,
1969
- Math.max(48, modelColumnWidth * 0.16),
1970
- );
1971
- const modelBarWidth = contentRight - modelInputReserve - modelBarX;
1972
- cacheModelRows.forEach((model, index) => {
1973
- const centerY = bottomTop + 29 + index * 30 + 9;
1974
- if (!model.muted) {
1975
- elements.push(`<circle cx="${(modelColumnX + 5).toFixed(2)}" cy="${centerY}" r="4" fill="${styleForModel(model.model)}"/>`);
1976
- }
1977
- elements.push(svgText({
1978
- x: modelColumnX + 18,
1979
- y: centerY + 4,
1980
- value: model.model,
1981
- fill: model.muted ? COLORS.muted : COLORS.ink,
1982
- size: modelLabelSize,
1983
- weight: model.muted ? 400 : 700,
1984
- }));
1985
- elements.push(svgText({
1986
- x: modelRateRight,
1987
- y: centerY + 4,
1988
- value: percent(model.rate),
1989
- fill: COLORS.secondary,
1990
- size: modelRateSize,
1991
- weight: 700,
1992
- anchor: "end",
1993
- mono: true,
1994
- }));
1995
- elements.push(svgRect(modelBarX, centerY - 5, modelBarWidth, 10, {
1996
- rx: 3,
1997
- fill: COLORS.uncached,
1998
- opacity: ".7",
1999
- }));
2000
- const rateFill = Number.isFinite(model.rate)
2001
- ? modelBarWidth * (model.rate / 100)
2002
- : 0;
2003
- if (rateFill > 0) {
2004
- elements.push(svgRect(modelBarX, centerY - 5, rateFill, 10, {
2005
- rx: 3,
2006
- fill: COLORS.cached,
2007
- }));
2258
+
2259
+ function buildLowerSection(top) {
2260
+ const gap = 14;
2261
+ const projectRowCount = PROJECT_PANEL_ROW_COUNT;
2262
+ const modelRowCount = Math.min(5, vm.models.filter((r) => r.totalTokens > 0).length || 1);
2263
+ const cacheHeight = 258;
2264
+ const projectsHeight = Math.max(150, 44 + projectRowCount * 33 + 46);
2265
+ const modelHeight = Math.max(150, 64 + modelRowCount * 33 + 30);
2266
+ if (wide) {
2267
+ const height = Math.max(cacheHeight, projectsHeight, modelHeight);
2268
+ const cacheWidth = contentWidth * 0.36;
2269
+ const projectsWidth = contentWidth * 0.31 - gap;
2270
+ const modelWidth = contentWidth - cacheWidth - projectsWidth - gap * 2;
2271
+ addVerticalDivider(
2272
+ outer + cacheWidth + gap / 2,
2273
+ top,
2274
+ height,
2275
+ "lower-column-divider",
2276
+ );
2277
+ addVerticalDivider(
2278
+ outer + cacheWidth + gap + projectsWidth + gap / 2,
2279
+ top,
2280
+ height,
2281
+ "lower-column-divider",
2282
+ );
2283
+ buildCacheByDayPanel(outer, top, cacheWidth, height);
2284
+ buildProjectsPanel(outer + cacheWidth + gap, top, projectsWidth, height);
2285
+ buildModelCachePanel(outer + cacheWidth + projectsWidth + gap * 2, top, modelWidth, height);
2286
+ return top + height;
2008
2287
  }
2009
- elements.push(svgText({
2010
- x: contentRight,
2011
- y: centerY + 4,
2012
- value: compact(model.inputTokens),
2013
- fill: COLORS.secondary,
2014
- size: 12.5,
2015
- weight: 700,
2016
- anchor: "end",
2017
- mono: true,
2018
- }));
2019
- });
2288
+ buildCacheByDayPanel(outer, top, contentWidth, cacheHeight);
2289
+ const rowTop = top + cacheHeight + gap;
2290
+ const half = (contentWidth - gap) / 2;
2291
+ const rowHeight = Math.max(projectsHeight, modelHeight);
2292
+ addVerticalDivider(
2293
+ outer + half + gap / 2,
2294
+ rowTop,
2295
+ rowHeight,
2296
+ "lower-column-divider",
2297
+ );
2298
+ buildProjectsPanel(outer, rowTop, half, rowHeight);
2299
+ buildModelCachePanel(outer + half + gap, rowTop, half, rowHeight);
2300
+ return rowTop + rowHeight;
2301
+ }
2020
2302
 
2021
- elements.push("</svg>");
2022
- return elements.join("\n");
2303
+ // ---------------------------------------------------------------- compose
2304
+ const body = [];
2305
+ const headerBottom = buildHeaderSection();
2306
+ const kpiBottom = buildKpiSection(headerBottom + 10);
2307
+ const mixBottom = buildModelMixSection(kpiBottom + 10);
2308
+ const chartBottom = buildDailyChartSection(mixBottom + 10);
2309
+ const lowerBottom = buildLowerSection(chartBottom + 14);
2310
+ const height = Math.ceil(lowerBottom + 16);
2311
+
2312
+ const description =
2313
+ "Dark report card: total usage, input-weighted cache efficiency, fast-mode share, and active-project KPI cards beside the sampled weekly-limit state; a model-mix strip; stacked daily token columns by model with hatched fast-mode overlays and the sampled weekly meter drawn as solid confirmed intervals and dashed unobserved gaps; plus daily cache efficiency, top projects, and per-model cache tables.";
2314
+ body.push(
2315
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-title trend-description">`,
2316
+ `<title id="trend-title">${escapeXml(`Token Ledger · ${meta.rangeDays}-day trend`)}</title>`,
2317
+ `<desc id="trend-description">${escapeXml(description)}</desc>`,
2318
+ defs,
2319
+ `<rect width="100%" height="100%" fill="${COLORS.background}"/>`,
2320
+ ...elements,
2321
+ "</svg>",
2322
+ );
2323
+ return body.join("\n");
2023
2324
  }
2024
2325
 
2025
2326
  export async function writeTrendPng(svg, outputPath) {