tledger 0.2.0 → 0.3.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,9 +2,15 @@ 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 { creditsForUsage } from "./token-ledger-rates.mjs";
5
+ import {
6
+ buildBurnDayBins,
7
+ buildUsageTrend,
8
+ weeklyQuotaObservations,
9
+ } from "./token-ledger-trend.mjs";
10
+ import { FAST_MODE_MULTIPLIER } from "./token-ledger-rates.mjs";
7
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
14
 
9
15
  const MODEL_ORDER = [
10
16
  "Luna",
@@ -38,20 +44,38 @@ const COLORS = {
38
44
  background: "#0e1420",
39
45
  panel: "#151d2c",
40
46
  panelBorder: "#273246",
47
+ meterPanel: "#1b1712",
48
+ meterPanelBorder: "rgba(246,183,60,.4)",
41
49
  ink: "#f2f5fa",
42
50
  secondary: "#aeb8c9",
43
51
  muted: "#77839a",
44
52
  grid: "#1c2534",
45
53
  baseline: "#33405a",
54
+ rule: "rgba(255,255,255,.1)",
55
+ track: "rgba(255,255,255,.09)",
56
+ projectTrack: "rgba(255,255,255,.07)",
46
57
  line: "#f6b73c",
58
+ meterAxis: "#cf9a37",
47
59
  chipFill: "#151d2c",
48
60
  leftAxis: "#7ea2f0",
61
+ deltaUp: "#7fb37a",
62
+ deltaUpFill: "rgba(127,179,122,.14)",
63
+ deltaDown: "#e08a86",
64
+ deltaDownFill: "rgba(217,83,79,.16)",
65
+ remainderBar: "#475569",
66
+ onFill: "rgba(255,255,255,.82)",
67
+ cached: "#2ec4a1",
68
+ uncached: "#d88362",
69
+ weighted: "#c7d2e8",
70
+ cacheTrack: "#202a3a",
49
71
  };
50
72
 
51
73
  const FONT_FAMILY = "system-ui, -apple-system, 'Segoe UI', sans-serif";
74
+ const MONO_FAMILY = "ui-monospace, Menlo, monospace";
52
75
  const FAST_MODE_LABEL_COLOR = "#a78bfa";
76
+ const MIN_BAR_WIDTH = 26;
53
77
 
54
- function escapeXml(value) {
78
+ export function escapeXml(value) {
55
79
  return String(value)
56
80
  .replaceAll("&", "&")
57
81
  .replaceAll("<", "&lt;")
@@ -60,25 +84,41 @@ function escapeXml(value) {
60
84
  .replaceAll("'", "&apos;");
61
85
  }
62
86
 
63
- function compact(value, digits = 2) {
87
+ export function compact(value, digits = 2) {
64
88
  if (!Number.isFinite(value)) return "—";
65
89
  const absolute = Math.abs(value);
66
- for (const [divisor, suffix] of [
90
+ const units = [
67
91
  [1_000_000_000, "B"],
68
92
  [1_000_000, "M"],
69
93
  [1_000, "K"],
70
- ]) {
71
- if (absolute >= divisor) {
72
- const scaled = value / divisor;
73
- const precision = scaled >= 100 ? 0 : scaled >= 10 ? 1 : digits;
74
- return `${scaled.toFixed(precision)}${suffix}`;
94
+ ];
95
+ for (let index = 0; index < units.length; index += 1) {
96
+ const [divisor, suffix] = units[index];
97
+ if (absolute < divisor) continue;
98
+ const scaled = value / divisor;
99
+ const magnitude = Math.abs(scaled);
100
+ const precision = magnitude >= 100 ? 0 : magnitude >= 10 ? 1 : digits;
101
+ // Values that round to 1000 of a unit belong to the next unit up
102
+ // (999,999 → 1.00M, not 1000K).
103
+ if (index > 0 && Number(magnitude.toFixed(precision)) >= 1_000) {
104
+ return compact(Math.sign(value) * divisor * 1_000, digits);
75
105
  }
106
+ return `${scaled.toFixed(precision)}${suffix}`;
76
107
  }
77
108
  return Math.round(value).toLocaleString("en-US");
78
109
  }
79
110
 
80
111
  function percent(value) {
81
- return `${Number(value).toFixed(value >= 10 ? 1 : 2)}%`;
112
+ const numeric = Number(value);
113
+ if (!Number.isFinite(numeric)) return "—";
114
+ if (numeric > 0 && numeric < 0.1) return "<0.1%";
115
+ return `${numeric.toFixed(1)}%`;
116
+ }
117
+
118
+ function meterLabel(value) {
119
+ const numeric = Number(value);
120
+ if (!Number.isFinite(numeric)) return "—";
121
+ return `${numeric.toFixed(Number.isInteger(numeric) ? 0 : 1)}%`;
82
122
  }
83
123
 
84
124
  function niceCeiling(value) {
@@ -119,50 +159,6 @@ function sortedModelEntries(values) {
119
159
  .sort(([left], [right]) => modelSort(left, right));
120
160
  }
121
161
 
122
- function eventRateCardCredits(event) {
123
- const computed = creditsForUsage(event.model, event);
124
- if (Number.isFinite(computed) && computed >= 0) {
125
- return event.serviceTier === "priority" ? computed * 1.5 : computed;
126
- }
127
- const stored = Number(event.rateCardCredits);
128
- if (
129
- event.rateCardCredits !== null &&
130
- event.rateCardCredits !== undefined &&
131
- Number.isFinite(stored) &&
132
- stored >= 0
133
- ) {
134
- return stored;
135
- }
136
- return null;
137
- }
138
-
139
- function rateCardSummary(snapshot, bounds) {
140
- const startMs = bounds.start.getTime();
141
- const endMs = bounds.end.getTime();
142
- let totalTokens = 0;
143
- let ratedTokens = 0;
144
- let credits = 0;
145
- for (const event of snapshot.events ?? []) {
146
- const timestampMs = new Date(event.timestamp).getTime();
147
- if (!Number.isFinite(timestampMs) || timestampMs < startMs || timestampMs >= endMs) {
148
- continue;
149
- }
150
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
151
- totalTokens += tokens;
152
- const eventCredits = eventRateCardCredits(event);
153
- if (Number.isFinite(eventCredits) && eventCredits >= 0) {
154
- ratedTokens += tokens;
155
- credits += eventCredits;
156
- }
157
- }
158
- return {
159
- totalTokens,
160
- ratedTokens,
161
- credits,
162
- coveragePercent: totalTokens > 0 ? (ratedTokens / totalTokens) * 100 : 0,
163
- };
164
- }
165
-
166
162
  function dateParts(dateString) {
167
163
  return dateString.split("-").map(Number);
168
164
  }
@@ -175,7 +171,7 @@ function dateStringFromParts(year, month, day) {
175
171
  .join("-");
176
172
  }
177
173
 
178
- function shiftCalendarDate(dateString, amount) {
174
+ export function shiftCalendarDate(dateString, amount) {
179
175
  const [year, month, day] = dateParts(dateString);
180
176
  const date = new Date(Date.UTC(year, month - 1, day + amount));
181
177
  return dateStringFromParts(
@@ -220,12 +216,12 @@ function localWeekdayLabel(dateString, timeZone) {
220
216
  }).format(zonedMidnight(dateString, timeZone));
221
217
  }
222
218
 
223
- function localDateTimeLabel(timestampMs, timeZone) {
224
- if (!Number.isFinite(timestampMs)) return "unknown time";
219
+ function timestampDateLabel(timestampMs, timeZone) {
220
+ if (!Number.isFinite(timestampMs)) return "unknown";
225
221
  return new Intl.DateTimeFormat("en-US", {
226
222
  timeZone,
227
- dateStyle: "medium",
228
- timeStyle: "short",
223
+ month: "short",
224
+ day: "numeric",
229
225
  }).format(new Date(timestampMs));
230
226
  }
231
227
 
@@ -236,7 +232,46 @@ function binDateLabel(bin, timeZone) {
236
232
  return `${start}–${localDateLabel(lastDate, timeZone).replace(/^[A-Za-z]+ /, "")}`;
237
233
  }
238
234
 
239
- function svgText({
235
+ // Rough sans-serif advance widths in em units, for placing inline runs
236
+ // (value + chip, legend items, pace rows). SVG has no flow layout.
237
+ export function textWidth(text, size, weight = 400) {
238
+ let units = 0;
239
+ for (const character of String(text)) {
240
+ if (/[il.,:;'|!]/.test(character)) units += 0.3;
241
+ else if (/[Ijtfr\-()[\] ]/.test(character)) units += 0.37;
242
+ else if (/[mwMW@%]/.test(character)) units += 0.92;
243
+ else if (/[A-Z]/.test(character)) units += 0.7;
244
+ else if (/[0-9+±×−]/.test(character)) units += 0.58;
245
+ else units += 0.55;
246
+ }
247
+ return units * size * (weight >= 700 ? 1.05 : 1);
248
+ }
249
+
250
+ export function truncateText(text, maxWidth, size, weight = 400) {
251
+ let value = String(text ?? "").replace(/\.{3,}/g, "…");
252
+ if (!(maxWidth > 0) || textWidth(value, size, weight) <= maxWidth) return value;
253
+ if (value.includes("…")) {
254
+ const leading = `${value.split("…", 1)[0].trimEnd()}…`;
255
+ if (textWidth(leading, size, weight) <= maxWidth) return leading;
256
+ value = leading;
257
+ }
258
+ const ellipsis = "…";
259
+ const ellipsisWidth = textWidth(ellipsis, size, weight);
260
+ if (ellipsisWidth >= maxWidth) return ellipsis;
261
+
262
+ const characters = [...value];
263
+ let low = 0;
264
+ let high = characters.length;
265
+ while (low < high) {
266
+ const middle = Math.ceil((low + high) / 2);
267
+ const candidate = `${characters.slice(0, middle).join("")}${ellipsis}`;
268
+ if (textWidth(candidate, size, weight) <= maxWidth) low = middle;
269
+ else high = middle - 1;
270
+ }
271
+ return `${characters.slice(0, low).join("").trimEnd()}${ellipsis}`;
272
+ }
273
+
274
+ export function svgText({
240
275
  x,
241
276
  y,
242
277
  value,
@@ -246,75 +281,72 @@ function svgText({
246
281
  anchor = "start",
247
282
  spacing = null,
248
283
  opacity = null,
284
+ mono = false,
249
285
  }) {
250
286
  const spacingAttr = spacing ? ` letter-spacing="${spacing}"` : "";
251
287
  const opacityAttr = opacity !== null ? ` opacity="${opacity}"` : "";
252
- return `<text x="${x}" y="${y}" fill="${fill}" font-family="${FONT_FAMILY}" font-size="${size}px" font-weight="${weight}" text-anchor="${anchor}"${spacingAttr}${opacityAttr}>${escapeXml(value)}</text>`;
253
- }
254
-
255
- // Approximate text fitting for card and footer copy: shrink a little, then
256
- // ellipsize, so text never crosses its container border.
257
- function fitLine(text, size, maxWidth, minSize = 10) {
258
- const widthOf = (value, fontSize) => value.length * fontSize * 0.62;
259
- let fitted = size;
260
- while (widthOf(text, fitted) > maxWidth && fitted > minSize) fitted -= 0.5;
261
- if (widthOf(text, fitted) <= maxWidth) return { text, size: fitted };
262
- const capacity = Math.max(1, Math.floor(maxWidth / (fitted * 0.62)) - 1);
263
- return { text: `${text.slice(0, capacity)}…`, size: fitted };
264
- }
265
-
266
- function linePath(points) {
267
- return points
268
- .map((point, index) => `${index === 0 ? "M" : "L"}${point.x.toFixed(2)},${point.y.toFixed(2)}`)
269
- .join(" ");
288
+ const family = mono ? MONO_FAMILY : FONT_FAMILY;
289
+ 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>`;
270
290
  }
271
291
 
272
- function roundedTopRect(x, y, width, height, radius, fill) {
273
- const r = Math.min(radius, width / 2, height);
274
- return `<path d="M${x.toFixed(2)},${(y + height).toFixed(2)} L${x.toFixed(2)},${(y + r).toFixed(2)} Q${x.toFixed(2)},${y.toFixed(2)} ${(x + r).toFixed(2)},${y.toFixed(2)} L${(x + width - r).toFixed(2)},${y.toFixed(2)} Q${(x + width).toFixed(2)},${y.toFixed(2)} ${(x + width).toFixed(2)},${(y + r).toFixed(2)} L${(x + width).toFixed(2)},${(y + height).toFixed(2)} Z" fill="${fill}"/>`;
275
- }
276
-
277
- function xPosition(timestampMs, bounds, plotLeft, plotWidth) {
278
- const span = bounds.end.getTime() - bounds.start.getTime();
279
- const ratio = span > 0
280
- ? (timestampMs - bounds.start.getTime()) / span
281
- : 0;
282
- return plotLeft + Math.max(0, Math.min(1, ratio)) * plotWidth;
292
+ export function svgRect(x, y, width, height, attrs = {}) {
293
+ const pieces = [
294
+ `x="${Number(x).toFixed(2)}"`,
295
+ `y="${Number(y).toFixed(2)}"`,
296
+ `width="${Math.max(0, Number(width)).toFixed(2)}"`,
297
+ `height="${Math.max(0, Number(height)).toFixed(2)}"`,
298
+ ];
299
+ for (const [key, value] of Object.entries(attrs)) {
300
+ if (value === null || value === undefined) continue;
301
+ pieces.push(`${key}="${value}"`);
302
+ }
303
+ return `<rect ${pieces.join(" ")}/>`;
283
304
  }
284
305
 
285
- function buildQuotaLine(trend, bounds, plotLeft, plotWidth, chartTop, chartHeight) {
286
- const points = (trend.points ?? [])
287
- .filter((point) => point.timestampMs >= bounds.start.getTime() && point.timestampMs <= bounds.end.getTime())
288
- .sort((left, right) => left.timestampMs - right.timestampMs);
289
- if (!points.length) return { points: [], resetPoints: [], yForRemaining: null };
290
-
291
- const resetPoints = [];
292
- const linePoints = [];
293
- const resets = [...(trend.resets ?? [])].sort((left, right) => left.timestampMs - right.timestampMs);
294
- let resetIndex = 0;
295
-
296
- const yForRemaining = (value) =>
297
- chartTop + chartHeight - (Math.max(0, Math.min(100, value)) / 100) * chartHeight;
298
- for (const point of points) {
299
- while (resetIndex < resets.length && resets[resetIndex].timestampMs <= point.timestampMs) {
300
- const reset = resets[resetIndex];
301
- if (reset.timestampMs >= bounds.start.getTime() && linePoints.length) {
302
- const x = xPosition(reset.timestampMs, bounds, plotLeft, plotWidth);
303
- const previous = linePoints.at(-1);
304
- linePoints.push({ x, y: previous.y });
305
- linePoints.push({ x, y: yForRemaining(100), reset: true });
306
- resetPoints.push({ x, timestampMs: reset.timestampMs, kind: reset.kind });
307
- }
308
- resetIndex += 1;
306
+ // Fritsch–Carlson monotone cubic through the points; keeps the meter line
307
+ // smooth without overshooting between observations.
308
+ function monotonePath(points) {
309
+ const count = points.length;
310
+ if (count < 2) return "";
311
+ const round = (value) => Math.round(value * 100) / 100;
312
+ const dx = [];
313
+ const slope = [];
314
+ for (let index = 0; index < count - 1; index += 1) {
315
+ dx[index] = Math.max(0.01, points[index + 1].x - points[index].x);
316
+ slope[index] = (points[index + 1].y - points[index].y) / dx[index];
317
+ }
318
+ const tangent = [slope[0]];
319
+ for (let index = 1; index < count - 1; index += 1) {
320
+ tangent.push(
321
+ slope[index - 1] * slope[index] <= 0
322
+ ? 0
323
+ : (slope[index - 1] + slope[index]) / 2,
324
+ );
325
+ }
326
+ tangent.push(slope[count - 2]);
327
+ for (let index = 0; index < count - 1; index += 1) {
328
+ if (slope[index] === 0) {
329
+ tangent[index] = 0;
330
+ tangent[index + 1] = 0;
331
+ continue;
309
332
  }
310
- linePoints.push({
311
- x: xPosition(point.timestampMs, bounds, plotLeft, plotWidth),
312
- y: yForRemaining(point.remainingPercent),
313
- timestampMs: point.timestampMs,
314
- remainingPercent: point.remainingPercent,
315
- });
333
+ const alpha = tangent[index] / slope[index];
334
+ const beta = tangent[index + 1] / slope[index];
335
+ const magnitude = alpha * alpha + beta * beta;
336
+ if (magnitude > 9) {
337
+ const tau = 3 / Math.sqrt(magnitude);
338
+ tangent[index] = tau * alpha * slope[index];
339
+ tangent[index + 1] = tau * beta * slope[index];
340
+ }
341
+ }
342
+ let path = `M ${round(points[0].x)} ${round(points[0].y)}`;
343
+ for (let index = 0; index < count - 1; index += 1) {
344
+ const h = dx[index];
345
+ path += ` C ${round(points[index].x + h / 3)} ${round(points[index].y + (tangent[index] * h) / 3)}` +
346
+ ` ${round(points[index + 1].x - h / 3)} ${round(points[index + 1].y - (tangent[index + 1] * h) / 3)}` +
347
+ ` ${round(points[index + 1].x)} ${round(points[index + 1].y)}`;
316
348
  }
317
- return { points: linePoints, resetPoints, yForRemaining };
349
+ return path;
318
350
  }
319
351
 
320
352
  function labelEvery(binCount) {
@@ -323,51 +355,27 @@ function labelEvery(binCount) {
323
355
  return 3;
324
356
  }
325
357
 
326
- function chip(x, y, value, { anchor = "middle", small = false } = {}) {
327
- const textSize = small ? 10.5 : 12;
328
- const paddingX = small ? 7 : 9;
329
- const chipHeight = small ? 19 : 23;
330
- const chipWidth = String(value).length * (textSize * 0.62) + paddingX * 2;
331
- const left = anchor === "middle" ? x - chipWidth / 2 : anchor === "end" ? x - chipWidth : x;
332
- return [
333
- `<rect x="${left.toFixed(2)}" y="${(y - chipHeight / 2).toFixed(2)}" width="${chipWidth.toFixed(2)}" height="${chipHeight}" rx="6" fill="${COLORS.chipFill}" stroke="${COLORS.line}" stroke-width="1.25"/>`,
334
- svgText({
335
- x: left + chipWidth / 2,
336
- y: y + textSize * 0.36,
337
- value,
338
- fill: COLORS.line,
339
- size: textSize,
340
- weight: 650,
341
- anchor: "middle",
342
- }),
343
- ].join("\n");
344
- }
345
-
346
- function priorRangeTotals(snapshot, bounds, days) {
347
- const startMs = zonedMidnight(
348
- shiftCalendarDate(bounds.startDateString, -days),
349
- bounds.timeZone,
350
- ).getTime();
351
- const endMs = bounds.start.getTime();
358
+ function fallbackProjectRows(snapshot, bounds) {
359
+ const startMs = bounds.start.getTime();
360
+ const endMs = bounds.end.getTime();
352
361
  const totals = new Map();
353
- for (const event of snapshot.events ?? []) {
362
+ for (const event of usageBucketsInRange(snapshot, startMs, endMs)) {
354
363
  const timestampMs = new Date(event.timestamp).getTime();
355
- if (!Number.isFinite(timestampMs) || timestampMs < startMs || timestampMs >= endMs) {
356
- continue;
357
- }
364
+ if (!Number.isFinite(timestampMs)) continue;
358
365
  const tokens = Math.max(0, Number(event.totalTokens) || 0);
359
366
  if (!(tokens > 0)) continue;
360
- const model = (() => {
361
- const value = String(event.model || "unknown").trim().toLowerCase();
362
- for (const label of MODEL_ORDER) {
363
- if (label === "Other" || label === "Unknown" || label === "Unattributed") continue;
364
- }
365
- return value;
366
- })();
367
- void model;
368
- totals.set(event.model, tokens);
367
+ const project = String(event.project || "Unlabelled activity")
368
+ .replace(/[\t\r\n]+/g, " ")
369
+ .trim() || "Unlabelled activity";
370
+ totals.set(project, (totals.get(project) ?? 0) + tokens);
369
371
  }
370
- return { startMs, endMs };
372
+ return [...totals.entries()]
373
+ .map(([project, totalTokens]) => ({
374
+ project,
375
+ displayProject: project,
376
+ totalTokens,
377
+ }))
378
+ .sort((left, right) => right.totalTokens - left.totalTokens);
371
379
  }
372
380
 
373
381
  export function renderTrendImage({
@@ -376,14 +384,22 @@ export function renderTrendImage({
376
384
  trend = buildUsageTrend(snapshot, bounds),
377
385
  days = bounds.rangeDays ?? 7,
378
386
  options = {},
387
+ projectRows = null,
379
388
  }) {
380
389
  const width = Math.max(900, Math.min(2_400, Number(options.imageWidth) || 1_280));
381
390
  const outer = 32;
382
- const margin = { left: 84, right: 96 };
383
- const plotLeft = margin.left;
384
- const plotWidth = width - margin.left - margin.right;
391
+ const plotLeft = 96;
392
+ const plotRight = width - 96;
393
+ const plotWidth = plotRight - plotLeft;
394
+ const contentRight = width - outer;
395
+ const contentWidth = width - outer * 2;
385
396
 
386
- const actual = buildActualTokenBins(snapshot, bounds, days, plotWidth);
397
+ // Keep daily bars while they fit at the minimum readable width; aggregate
398
+ // longer windows into multi-day columns so bars and labels never overlap.
399
+ const actual = buildActualTokenBins(snapshot, bounds, days, plotWidth, {
400
+ minBinWidth: MIN_BAR_WIDTH,
401
+ preferDaily: true,
402
+ });
387
403
  const burn = buildBurnDayBins(trend, bounds, { days, binSize: actual.binSize });
388
404
  const meterUsable = Boolean(trend.available && burn.totalPercent > 0);
389
405
  const percentMode = Boolean(options.drain) && meterUsable;
@@ -395,547 +411,1474 @@ export function renderTrendImage({
395
411
  );
396
412
  const hasLine = Boolean(trend.available && (trend.points ?? []).length > 1);
397
413
 
398
- // Range totals for the stat cards.
399
414
  const totalTokens = [...actual.totals.values()].reduce((sum, value) => sum + value, 0);
415
+ const fastTokens = [...(actual.fastTotals?.values() ?? [])].reduce(
416
+ (sum, value) => sum + value,
417
+ 0,
418
+ );
419
+ const hasFast = !percentMode && fastTokens > 0;
420
+
400
421
  const modelCards = [...actual.totals.entries()]
401
422
  .filter(([, value]) => value > 0 && totalTokens > 0 && value / totalTokens >= 0.01)
402
423
  .sort((left, right) => right[1] - left[1])
403
424
  .slice(0, 3)
404
425
  .map(([model, value]) => ({ model, tokens: value }));
405
- const fastTokens = [...(actual.fastTotals?.values() ?? [])].reduce((sum, value) => sum + value, 0);
406
- const hasFast = !percentMode && fastTokens > 0;
407
426
 
408
- // Prior-period per-model totals for the delta line.
409
- const prior = priorRangeTotals(snapshot, bounds, days);
410
- const priorTotals = new Map();
411
- for (const event of snapshot.events ?? []) {
412
- const timestampMs = new Date(event.timestamp).getTime();
413
- if (!Number.isFinite(timestampMs) || timestampMs < prior.startMs || timestampMs >= prior.endMs) {
414
- continue;
415
- }
416
- const tokens = Math.max(0, Number(event.totalTokens) || 0);
417
- if (!(tokens > 0)) continue;
418
- const label = MODEL_ORDER.find((candidate) =>
419
- String(event.model || "").toLowerCase().includes(candidate.toLowerCase().split(" ")[0]),
420
- );
421
- void label;
422
- }
423
- // Reuse the bin labeler for prior-period totals so model naming matches.
427
+ // Prior-period per-model totals feed the delta chips.
424
428
  const priorBounds = {
425
429
  ...bounds,
426
430
  startDateString: shiftCalendarDate(bounds.startDateString, -days),
427
431
  endDateString: shiftCalendarDate(bounds.endDateString, -days),
428
- start: new Date(prior.startMs),
429
- end: new Date(prior.endMs),
432
+ start: zonedMidnight(
433
+ shiftCalendarDate(bounds.startDateString, -days),
434
+ bounds.timeZone,
435
+ ),
436
+ end: bounds.start,
430
437
  };
431
- const priorActual = buildActualTokenBins(snapshot, priorBounds, days, plotWidth);
432
- for (const [model, value] of priorActual.totals) priorTotals.set(model, value);
438
+ const priorTotals = buildActualTokenBins(snapshot, priorBounds, days, plotWidth, {
439
+ minBinWidth: MIN_BAR_WIDTH,
440
+ preferDaily: true,
441
+ }).totals;
433
442
 
434
443
  const latestQuotaPoint = [...(trend.points ?? [])]
435
444
  .filter((point) => point.timestampMs <= bounds.end.getTime())
436
445
  .at(-1);
437
- const rateCard = rateCardSummary(snapshot, bounds);
438
- const expiries = (trend.resets ?? []).filter((reset) => reset.kind === "weekly-expiry").length;
439
- const restarts = (trend.resets ?? []).filter((reset) => reset.kind !== "weekly-expiry").length;
446
+ const latestQuotaReadMs = Number.isFinite(trend.observedThroughMs)
447
+ ? trend.observedThroughMs
448
+ : null;
449
+ const resetsInRange = trend.resets ?? [];
450
+ const weeklyObservationsAll = weeklyQuotaObservations(snapshot).filter(
451
+ (observation) => observation.timestampMs < bounds.end.getTime(),
452
+ );
453
+ const latestResetsAtSec = weeklyObservationsAll.at(-1)?.resetsAt ?? null;
454
+
455
+ const rows = projectRows ?? fallbackProjectRows(snapshot, bounds);
456
+
457
+ // Cache bins share the trend chart's bin size so both charts' columns stay
458
+ // vertically aligned.
459
+ const cacheData = buildCacheReportData(
460
+ snapshot,
461
+ bounds,
462
+ days,
463
+ plotWidth,
464
+ actual.binSize,
465
+ );
466
+ const hasCache = cacheData.inputTokens > 0;
467
+ const cacheModelRows = (() => {
468
+ if (!hasCache) return [];
469
+ const models = cacheData.models;
470
+ if (models.length <= 4) return models;
471
+ const rest = models.slice(3);
472
+ const restInput = rest.reduce((sum, model) => sum + model.inputTokens, 0);
473
+ const restCached = rest.reduce(
474
+ (sum, model) => sum + model.cachedInputTokens,
475
+ 0,
476
+ );
477
+ return [...models.slice(0, 3), {
478
+ model: `${rest.length} other models`,
479
+ inputTokens: restInput,
480
+ cachedInputTokens: restCached,
481
+ rate: restInput > 0 ? (restCached / restInput) * 100 : null,
482
+ muted: true,
483
+ }];
484
+ })();
485
+
486
+ // ---- Pace & runway (computed early: its height shapes the top row) ----
487
+ const generatedAtMs = new Date(snapshot.generatedAt).getTime();
488
+ const paceLines = [];
489
+ let paceNote = null;
490
+ let paceRunwayBar = null;
491
+ const dailyAverage = totalTokens / Math.max(1, days);
492
+ if (hasLine && meterUsable && latestQuotaPoint && totalTokens > 0) {
493
+ const tokensPerPercent = totalTokens / burn.totalPercent;
494
+ const burnPerDay = dailyAverage / tokensPerPercent;
495
+ const runwayDays = burnPerDay > 0
496
+ ? latestQuotaPoint.remainingPercent / burnPerDay
497
+ : null;
498
+ if (runwayDays !== null) {
499
+ paceLines.push({
500
+ value: `${runwayDays.toFixed(1)} days`,
501
+ size: 23,
502
+ weight: 800,
503
+ color: COLORS.line,
504
+ detail: "of meter left at this pace",
505
+ });
506
+ }
507
+ paceLines.push({
508
+ value: `${compact(dailyAverage)} / day`,
509
+ size: 17,
510
+ weight: 700,
511
+ color: COLORS.ink,
512
+ detail: `${days}-day average · ${burnPerDay.toFixed(1)}% of meter`,
513
+ });
514
+ paceLines.push({
515
+ value: `${compact(tokensPerPercent)} / 1%`,
516
+ size: 17,
517
+ weight: 700,
518
+ color: COLORS.ink,
519
+ detail: "tokens per meter point",
520
+ });
521
+ const daysToReset = latestResetsAtSec !== null && Number.isFinite(generatedAtMs)
522
+ ? (latestResetsAtSec * 1_000 - generatedAtMs) / 86_400_000
523
+ : null;
524
+ if (runwayDays !== null && daysToReset !== null && daysToReset > 0) {
525
+ const resetIn = Math.max(1, Math.round(daysToReset));
526
+ const resetInLabel = `${resetIn} ${resetIn === 1 ? "day" : "days"}`;
527
+ paceRunwayBar = { runwayDays, daysToReset, resetInLabel };
528
+ const gap = runwayDays - daysToReset;
529
+ if (Math.abs(gap) <= 1.5) {
530
+ paceNote = `Next weekly reset in ${resetInLabel}. Current pace lands within ~${Math.max(1, Math.round(Math.abs(gap)))} day of it.`;
531
+ } else if (gap > 0) {
532
+ paceNote = `Next weekly reset in ${resetInLabel}. Current pace leaves ~${Math.round(gap)} days of headroom past it.`;
533
+ } else {
534
+ paceNote = `Next weekly reset in ${resetInLabel}. Current pace runs the meter out ~${Math.round(-gap)} days before it.`;
535
+ }
536
+ }
537
+ } else {
538
+ paceLines.push({
539
+ value: `${compact(dailyAverage)} / day`,
540
+ size: 17,
541
+ weight: 700,
542
+ color: COLORS.ink,
543
+ detail: `${days}-day average`,
544
+ });
545
+ paceNote = "No usable weekly meter drain in this range, so runway cannot be estimated.";
546
+ }
440
547
 
441
548
  // ---- Layout ----
442
- const headerTop = 48;
443
- const cardTop = 100;
444
- const cardHeight = 100;
445
- const chartTop = cardTop + cardHeight + 64;
446
- const chartHeight = 470;
447
- const chartBottom = chartTop + chartHeight;
448
- const xLabelBand = 58;
449
- const legendY = chartBottom + xLabelBand + 26;
450
- const footerTop = legendY + 26;
451
- const footerHeight = 96;
452
- const height = footerTop + footerHeight + outer;
453
-
454
- const title = `TOKEN LEDGER · ${days}-DAY TREND`;
549
+ // One uniform-height top band: a 2x2 quad of stat cells beside one unified
550
+ // weekly meter + pace panel.
551
+ const headerBaseline = 53;
552
+ const cardTop = 82;
553
+ const topGap = 24;
554
+ const hasMeterCard = Boolean(hasLine && latestQuotaPoint);
555
+ const statCardCount =
556
+ modelCards.length + (hasFast ? 1 : 0) + (percentMode ? 1 : 0);
557
+ const pacePanelWidth = hasMeterCard
558
+ ? Math.min(560, Math.max(480, contentWidth * 0.55))
559
+ : 432;
560
+ const pacePanelX = contentRight - pacePanelWidth;
561
+ const paceTextX = pacePanelX + 18;
562
+ const paceInnerWidth = pacePanelWidth - 36;
563
+ const quadWidth = contentWidth - pacePanelWidth - topGap;
564
+ const quadColumns = statCardCount >= 2 ? 2 : 1;
565
+ const quadRows = Math.max(1, Math.ceil(statCardCount / quadColumns));
566
+ const paceNoteLines = [];
567
+ if (paceNote) {
568
+ let current = "";
569
+ for (const word of paceNote.split(" ")) {
570
+ const candidate = current ? `${current} ${word}` : word;
571
+ if (textWidth(candidate, 11.5) > paceInnerWidth && current) {
572
+ paceNoteLines.push(current);
573
+ current = word;
574
+ } else {
575
+ current = candidate;
576
+ }
577
+ }
578
+ if (current) paceNoteLines.push(current);
579
+ }
580
+ // Baseline offsets inside the unified panel: meter headline, optional
581
+ // runway timeline, the two-column stat pair, then the note.
582
+ const paceHeadlineBaseline = hasMeterCard ? 58 : 54;
583
+ const paceStatValueBaseline = paceHeadlineBaseline + (paceRunwayBar ? 62 : 34);
584
+ const paceStatsPresent = paceLines.length > (hasMeterCard ? 0 : 1);
585
+ const paceNoteStart =
586
+ (paceStatsPresent ? paceStatValueBaseline + 16 : paceHeadlineBaseline) + 24;
587
+ const topRowHeight = Math.max(
588
+ hasMeterCard ? 170 : 150,
589
+ paceNoteStart + (paceNoteLines.length - 1) * 16 + 14,
590
+ );
591
+ const chartBlockTop = cardTop + topRowHeight + 16;
592
+ const plotTop = chartBlockTop + 40;
593
+ const plotHeight = 430;
594
+ const plotBottom = plotTop + plotHeight;
595
+ const chartBlockBottom = plotBottom + 70;
596
+ const legendBaseline = chartBlockBottom + 25;
597
+ const cacheRuleY = legendBaseline + 23;
598
+ const cacheHeaderBaseline = cacheRuleY + 27;
599
+ const cachePlotTop = cacheHeaderBaseline + 34;
600
+ const cachePlotHeight = 128;
601
+ const cachePlotBottom = hasCache
602
+ ? cachePlotTop + cachePlotHeight
603
+ : cacheHeaderBaseline + 26;
604
+ const bottomRuleY = cachePlotBottom + 30;
605
+ const bottomTop = bottomRuleY + 20;
606
+ const projectRowCount = Math.min(4, rows.length > 3 ? 4 : rows.length);
607
+ const bottomBlockHeight = Math.max(
608
+ 29 + projectRowCount * 29,
609
+ 29 + Math.max(1, cacheModelRows.length) * 30,
610
+ 120,
611
+ );
612
+ const height = bottomTop + bottomBlockHeight + 34;
613
+
455
614
  const yearLabel = bounds.endDateString.slice(0, 4);
456
- const subtitle = `${localDateLabel(bounds.startDateString, bounds.timeZone)} – ${localDateLabel(bounds.endDateString, bounds.timeZone)}, ${yearLabel} · ${bounds.timeZone}${latestQuotaPoint ? ` · Latest remaining: ${percent(latestQuotaPoint.remainingPercent)}` : ""}`;
615
+ const title = percentMode
616
+ ? `TOKEN LEDGER · ${days}-DAY METER DRAIN`
617
+ : `TOKEN LEDGER · ${days}-DAY TREND`;
618
+ const subtitle = `${localDateLabel(bounds.startDateString, bounds.timeZone)} – ${localDateLabel(bounds.endDateString, bounds.timeZone)}, ${yearLabel} · ${bounds.timeZone}`;
457
619
  const description = percentMode
458
- ? "Dark dashboard: stat cards for each model, then stacked columns of the observed weekly-limit percentage consumed per day split by model via rate-card credit weights, overlaid with the observed weekly meter remaining as an amber line with value chips."
459
- : "Dark dashboard: stat cards for each model, then stacked columns of local token volume per day by model with per-segment token and share labels, overlaid with the observed weekly meter remaining as an amber line with value chips. Darker shades within a segment are fast-mode usage.";
620
+ ? "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 observed weekly meter remaining as an amber line with continuous reset boundaries, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates."
621
+ : "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 observed weekly meter remaining as a smoothed amber line with reset breaks and callout pills, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates.";
460
622
 
461
623
  const elements = [
462
- `<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">`,
463
- `<title id="trend-title">${escapeXml(`Token Ledger · ${days}-day trend`)}</title>`,
624
+ `<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"}">`,
625
+ `<title id="trend-title">${escapeXml(percentMode ? `Token Ledger · ${days}-day meter drain` : `Token Ledger · ${days}-day trend`)}</title>`,
464
626
  `<desc id="trend-description">${escapeXml(description)}</desc>`,
627
+ `<defs><clipPath id="trend-plot-clip"><rect x="${plotLeft}" y="${plotTop}" width="${plotWidth}" height="${plotHeight}"/></clipPath></defs>`,
465
628
  `<rect width="100%" height="100%" fill="${COLORS.background}"/>`,
466
- svgText({ x: outer, y: headerTop, value: title, size: 26, weight: 750, spacing: "0.02em" }),
467
- svgText({ x: outer, y: headerTop + 26, value: subtitle, fill: COLORS.secondary, size: 13 }),
629
+ svgText({
630
+ x: outer,
631
+ y: headerBaseline,
632
+ value: title,
633
+ size: 27,
634
+ weight: 800,
635
+ spacing: "-0.27",
636
+ }),
637
+ svgText({
638
+ x: contentRight,
639
+ y: headerBaseline,
640
+ value: subtitle,
641
+ fill: COLORS.muted,
642
+ size: 14,
643
+ anchor: "end",
644
+ }),
468
645
  ];
469
646
 
470
- // ---- Stat cards ----
471
- const card = (x, cardWidth, body) => {
472
- elements.push(`<rect x="${x.toFixed(2)}" y="${cardTop}" width="${cardWidth.toFixed(2)}" height="${cardHeight}" rx="10" fill="${COLORS.panel}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
473
- body(x + 16, cardTop);
474
- };
475
- const deltaLine = (model, tokens) => {
476
- const priorValue = priorTotals.get(model) ?? 0;
477
- if (priorValue < 1_000_000) return `no prior ${days}d baseline`;
478
- const ratio = tokens / priorValue;
479
- if (ratio >= 5) return `${ratio.toFixed(1)}× vs prior ${days}d`;
480
- const delta = (ratio - 1) * 100;
481
- const signed = `${delta >= 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`;
482
- return `${signed} vs prior ${days}d`;
483
- };
484
- const cardGap = 12;
485
- const cardCount = modelCards.length + (hasFast ? 1 : 0) + (hasLine ? 1 : 0);
486
- const keyCardScale = 1.45;
487
- const unitWidth = (width - outer * 2 - cardGap * cardCount) / (cardCount + keyCardScale);
488
- let cardX = outer;
647
+ // ---- KPI cards ----
648
+ const cards = [];
649
+ let meterCard = null;
489
650
  for (const { model, tokens } of modelCards) {
490
- card(cardX, unitWidth, (x, y) => {
491
- elements.push(`<circle cx="${x + 6}" cy="${y + 25}" r="6" fill="${styleForModel(model)}"/>`);
492
- elements.push(svgText({ x: x + 20, y: y + 30, value: model, fill: COLORS.ink, size: 14, weight: 650 }));
493
- elements.push(svgText({ x, y: y + 58, value: compact(tokens), fill: COLORS.ink, size: 20, weight: 700 }));
494
- elements.push(svgText({
495
- x: x + unitWidth - 32,
496
- y: y + 58,
497
- value: totalTokens > 0 ? percent((tokens / totalTokens) * 100) : "—",
498
- fill: COLORS.secondary,
499
- size: 14,
500
- weight: 600,
501
- anchor: "end",
502
- }));
503
- const delta = fitLine(deltaLine(model, tokens), 11.5, unitWidth - 32);
504
- elements.push(svgText({ x, y: y + 82, value: delta.text, fill: COLORS.muted, size: delta.size }));
651
+ const share = totalTokens > 0 ? (tokens / totalTokens) * 100 : 0;
652
+ const priorValue = priorTotals.get(model) ?? 0;
653
+ let chip = null;
654
+ if (priorValue >= 1_000_000) {
655
+ const ratio = tokens / priorValue;
656
+ const delta = (ratio - 1) * 100;
657
+ chip = {
658
+ text: ratio >= 5
659
+ ? `${ratio.toFixed(1)}×`
660
+ : `${delta >= 0 ? "+" : "−"}${Math.abs(delta).toFixed(1)}%`,
661
+ color: delta >= 0 ? COLORS.deltaUp : COLORS.deltaDown,
662
+ fill: delta >= 0 ? COLORS.deltaUpFill : COLORS.deltaDownFill,
663
+ };
664
+ }
665
+ cards.push({
666
+ swatch: styleForModel(model),
667
+ label: percentMode ? `${model} · tokens` : model,
668
+ labelColor: COLORS.muted,
669
+ value: compact(tokens),
670
+ valueColor: COLORS.ink,
671
+ chip,
672
+ suffix: null,
673
+ track: COLORS.track,
674
+ fill: styleForModel(model),
675
+ barPercent: share,
676
+ caption: `${percent(share)} of ${percentMode ? "actual " : ""}tokens`,
677
+ captionShort: percent(share),
678
+ panel: COLORS.panel,
679
+ border: COLORS.panelBorder,
505
680
  });
506
- cardX += unitWidth + cardGap;
507
681
  }
508
682
  if (hasFast) {
509
- card(cardX, unitWidth, (x, y) => {
510
- elements.push(`<circle cx="${x + 6}" cy="${y + 25}" r="6" fill="${FAST_MODE_LABEL_COLOR}"/>`);
511
- elements.push(svgText({ x: x + 20, y: y + 30, value: "Fast Mode", fill: COLORS.ink, size: 14, weight: 650 }));
512
- elements.push(svgText({ x, y: y + 58, value: "1.50× rate", fill: COLORS.ink, size: 20, weight: 700 }));
513
- const fastSub = fitLine(
514
- `${percent((fastTokens / Math.max(1, totalTokens)) * 100)} of tokens`,
515
- 11.5,
516
- unitWidth - 32,
517
- );
683
+ const fastShare = totalTokens > 0 ? (fastTokens / totalTokens) * 100 : 0;
684
+ cards.push({
685
+ swatch: FAST_MODE_LABEL_COLOR,
686
+ label: "Fast mode",
687
+ labelColor: COLORS.muted,
688
+ value: `${FAST_MODE_MULTIPLIER.toFixed(2)}×`,
689
+ valueColor: COLORS.ink,
690
+ chip: null,
691
+ suffix: "rate",
692
+ track: COLORS.track,
693
+ fill: FAST_MODE_LABEL_COLOR,
694
+ barPercent: fastShare,
695
+ caption: `${percent(fastShare)} of tokens · darker bar shade`,
696
+ captionShort: `${percent(fastShare)} of tokens`,
697
+ panel: COLORS.panel,
698
+ border: COLORS.panelBorder,
699
+ });
700
+ }
701
+ if (percentMode) {
702
+ cards.push({
703
+ swatch: COLORS.line,
704
+ label: "Observed drain",
705
+ labelColor: COLORS.meterAxis,
706
+ value: `${burn.totalPercent.toFixed(1)} pts`,
707
+ valueColor: COLORS.line,
708
+ chip: null,
709
+ suffix: null,
710
+ track: "rgba(246,183,60,.16)",
711
+ fill: COLORS.line,
712
+ barPercent: burn.totalPercent,
713
+ caption: "observed total · model split estimated",
714
+ captionShort: "model split estimated",
715
+ panel: COLORS.panel,
716
+ border: COLORS.panelBorder,
717
+ });
718
+ }
719
+ if (hasLine && latestQuotaPoint) {
720
+ const lastReset = resetsInRange.at(-1);
721
+ const resetCaption = lastReset
722
+ ? `reset ${timestampDateLabel(lastReset.timestampMs, bounds.timeZone)}`
723
+ : latestResetsAtSec
724
+ ? `resets ${timestampDateLabel(latestResetsAtSec * 1_000, bounds.timeZone)}`
725
+ : "no reset in range";
726
+ meterCard = ({
727
+ swatch: COLORS.line,
728
+ label: "Weekly meter",
729
+ labelColor: COLORS.meterAxis,
730
+ value: meterLabel(latestQuotaPoint.remainingPercent),
731
+ valueColor: COLORS.line,
732
+ chip: null,
733
+ suffix: "remaining",
734
+ track: "rgba(246,183,60,.2)",
735
+ fill: COLORS.line,
736
+ barPercent: latestQuotaPoint.remainingPercent,
737
+ caption: latestQuotaReadMs === null
738
+ ? resetCaption
739
+ : `${resetCaption} · read ${timestampDateLabel(latestQuotaReadMs, bounds.timeZone)}`,
740
+ captionShort: resetCaption,
741
+ panel: COLORS.meterPanel,
742
+ border: COLORS.meterPanelBorder,
743
+ });
744
+ }
745
+ if (cards.length) {
746
+ // A 2x2 quad of stat cells inside one panel with hairline dividers.
747
+ const cellWidth = quadWidth / quadColumns;
748
+ const cellHeight = topRowHeight / quadRows;
749
+ elements.push(svgRect(outer, cardTop, quadWidth, topRowHeight, {
750
+ rx: 7,
751
+ fill: COLORS.panel,
752
+ }));
753
+ if (quadColumns > 1) {
754
+ const dividerX = outer + cellWidth;
755
+ elements.push(`<line x1="${dividerX.toFixed(2)}" y1="${cardTop + 1}" x2="${dividerX.toFixed(2)}" y2="${cardTop + topRowHeight - 1}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
756
+ }
757
+ if (quadRows > 1) {
758
+ const dividerY = cardTop + cellHeight;
759
+ 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"/>`);
760
+ }
761
+ cards.forEach((card, index) => {
762
+ const cellX = outer + (index % quadColumns) * cellWidth;
763
+ const cellY = cardTop + Math.floor(index / quadColumns) * cellHeight;
764
+ const contentX = cellX + 17;
765
+ const innerWidth = cellWidth - 34;
766
+ const innerRight = contentX + innerWidth;
767
+ const labelText = card.label.toUpperCase();
768
+ // Each corner carries something: label top-left, delta chip top-right,
769
+ // value bottom-left, share caption bottom-right, bar along the bottom.
770
+ elements.push(`<circle cx="${(contentX + 3.5).toFixed(2)}" cy="${(cellY + 19).toFixed(2)}" r="3.5" fill="${card.swatch}"/>`);
518
771
  elements.push(svgText({
519
- x,
520
- y: y + 82,
521
- value: fastSub.text,
522
- fill: COLORS.muted,
523
- size: fastSub.size,
772
+ x: contentX + 15,
773
+ y: cellY + 23,
774
+ value: labelText,
775
+ fill: card.labelColor,
776
+ size: 10.5,
777
+ spacing: ".9",
778
+ }));
779
+ const labelBaseline = cellY + 23;
780
+ if (card.chip) {
781
+ const chipTextWidth = textWidth(card.chip.text, 10, 700);
782
+ const chipX = innerRight - chipTextWidth - 10;
783
+ const labelEnd =
784
+ contentX +
785
+ 15 +
786
+ textWidth(labelText, 10.5) +
787
+ Math.max(0, labelText.length - 1) * 0.9;
788
+ if (chipX >= labelEnd + 10) {
789
+ elements.push(svgRect(chipX, labelBaseline - 11, chipTextWidth + 10, 15, {
790
+ rx: 3,
791
+ fill: card.chip.fill,
792
+ }));
793
+ elements.push(svgText({
794
+ x: chipX + 5,
795
+ y: labelBaseline,
796
+ value: card.chip.text,
797
+ fill: card.chip.color,
798
+ size: 10,
799
+ weight: 700,
800
+ }));
801
+ }
802
+ }
803
+ const valueBaseline = cellY + cellHeight / 2 + 9;
804
+ elements.push(svgText({
805
+ x: contentX,
806
+ y: valueBaseline,
807
+ value: card.value,
808
+ fill: card.valueColor,
809
+ size: 21,
810
+ weight: 800,
811
+ spacing: "-0.5",
812
+ }));
813
+ let valueEnd = contentX + textWidth(card.value, 21, 800);
814
+ if (card.suffix) {
815
+ elements.push(svgText({
816
+ x: valueEnd + 10,
817
+ y: valueBaseline,
818
+ value: card.suffix,
819
+ fill: COLORS.secondary,
820
+ size: 10.5,
821
+ }));
822
+ valueEnd += 10 + textWidth(card.suffix, 10.5);
823
+ }
824
+ const captionAvail = innerRight - valueEnd - 16;
825
+ const caption = card.captionShort && textWidth(card.caption, 11) > captionAvail
826
+ ? card.captionShort
827
+ : card.caption;
828
+ if (textWidth(caption, 11) <= captionAvail) {
829
+ elements.push(svgText({
830
+ x: innerRight,
831
+ y: valueBaseline,
832
+ value: caption,
833
+ fill: COLORS.secondary,
834
+ size: 11,
835
+ anchor: "end",
836
+ }));
837
+ }
838
+ const barY = cellY + cellHeight - 16;
839
+ elements.push(svgRect(contentX, barY, innerWidth, 3, {
840
+ rx: 1.5,
841
+ fill: card.track,
524
842
  }));
843
+ const fillWidth = (Math.min(100, Math.max(0, card.barPercent)) / 100) * innerWidth;
844
+ if (fillWidth > 0) {
845
+ elements.push(svgRect(contentX, barY, fillWidth, 3, {
846
+ rx: 1.5,
847
+ fill: card.fill,
848
+ }));
849
+ }
525
850
  });
526
- cardX += unitWidth + cardGap;
851
+ elements.push(svgRect(outer, cardTop, quadWidth, topRowHeight, {
852
+ rx: 7,
853
+ fill: "none",
854
+ stroke: COLORS.panelBorder,
855
+ "stroke-width": 1,
856
+ }));
527
857
  }
528
- if (hasLine) {
529
- card(cardX, unitWidth, (x, y) => {
530
- elements.push(`<circle cx="${x + 6}" cy="${y + 25}" r="6" fill="${COLORS.line}"/>`);
531
- elements.push(svgText({ x: x + 20, y: y + 30, value: "Weekly Meter", fill: COLORS.ink, size: 14, weight: 650 }));
858
+
859
+ // ---- Unified weekly meter + pace panel (top right) ----
860
+ elements.push(svgRect(pacePanelX, cardTop, pacePanelWidth, topRowHeight, {
861
+ rx: 7,
862
+ fill: meterCard ? meterCard.panel : COLORS.panel,
863
+ stroke: meterCard ? meterCard.border : COLORS.panelBorder,
864
+ "stroke-width": 1,
865
+ }));
866
+ const paceRight = pacePanelX + pacePanelWidth - 18;
867
+ if (meterCard) {
868
+ elements.push(`<circle cx="${(paceTextX + 3.5).toFixed(2)}" cy="${cardTop + 19}" r="3.5" fill="${meterCard.swatch}"/>`);
869
+ }
870
+ elements.push(svgText({
871
+ x: paceTextX + (meterCard ? 15 : 0),
872
+ y: cardTop + 23,
873
+ value: meterCard ? "WEEKLY METER · PACE & RUNWAY" : "PACE & RUNWAY",
874
+ fill: meterCard ? meterCard.labelColor : COLORS.muted,
875
+ size: 10.5,
876
+ spacing: "1.2",
877
+ }));
878
+ if (meterCard) {
879
+ // Provenance rides the label row; the meter reading is the headline with
880
+ // the projected runway right-aligned beside it.
881
+ elements.push(svgText({
882
+ x: paceRight,
883
+ y: cardTop + 23,
884
+ value: meterCard.caption,
885
+ fill: COLORS.muted,
886
+ size: 10.5,
887
+ anchor: "end",
888
+ }));
889
+ elements.push(svgText({
890
+ x: paceTextX,
891
+ y: cardTop + paceHeadlineBaseline,
892
+ value: meterCard.value,
893
+ fill: meterCard.valueColor,
894
+ size: 28,
895
+ weight: 800,
896
+ spacing: "-0.6",
897
+ }));
898
+ elements.push(svgText({
899
+ x: paceTextX + textWidth(meterCard.value, 28, 800) + 10,
900
+ y: cardTop + paceHeadlineBaseline,
901
+ value: meterCard.suffix,
902
+ fill: COLORS.secondary,
903
+ size: 11,
904
+ }));
905
+ if (paceLines.length > 1) {
906
+ const runwayValue = paceLines[0].value;
907
+ const runwayDetail = "left at this pace";
908
+ const detailWidth = textWidth(runwayDetail, 11);
532
909
  elements.push(svgText({
533
- x,
534
- y: y + 58,
535
- value: latestQuotaPoint ? percent(latestQuotaPoint.remainingPercent) : "—",
536
- fill: COLORS.ink,
537
- size: 20,
538
- weight: 700,
910
+ x: paceRight - detailWidth - 8,
911
+ y: cardTop + paceHeadlineBaseline,
912
+ value: runwayValue,
913
+ fill: paceLines[0].color,
914
+ size: 18,
915
+ weight: 800,
916
+ anchor: "end",
539
917
  }));
540
- const meterSub = fitLine(
541
- latestQuotaPoint
542
- ? `remaining · ${localDateLabel(bounds.endDateString, bounds.timeZone)}`
543
- : "no observations",
544
- 11.5,
545
- unitWidth - 32,
546
- );
547
918
  elements.push(svgText({
548
- x,
549
- y: y + 82,
550
- value: meterSub.text,
919
+ x: paceRight,
920
+ y: cardTop + paceHeadlineBaseline,
921
+ value: runwayDetail,
551
922
  fill: COLORS.muted,
552
- size: meterSub.size,
923
+ size: 11,
924
+ anchor: "end",
553
925
  }));
554
- });
555
- cardX += unitWidth + cardGap;
556
- }
557
- const keyCardWidth = unitWidth * keyCardScale;
558
- card(cardX, keyCardWidth, (x, y) => {
559
- // Mini stacked-bar glyph.
560
- elements.push(`<rect x="${x}" y="${y + 24}" width="5" height="10" rx="1" fill="${styleForModel("Luna")}"/>`);
561
- elements.push(`<rect x="${x}" y="${y + 17}" width="5" height="6" rx="1" fill="${styleForModel("Sol")}"/>`);
562
- elements.push(`<rect x="${x + 7}" y="${y + 20}" width="5" height="14" rx="1" fill="${styleForModel("Luna")}"/>`);
563
- const keyLineOne = fitLine(
564
- percentMode
565
- ? "Bars = observed limit drain"
566
- : "Bars = actual token volume",
567
- 12,
568
- keyCardWidth - 54,
569
- );
926
+ }
927
+ } else {
928
+ const paceHeadline = paceLines[0];
570
929
  elements.push(svgText({
571
- x: x + 22,
572
- y: y + 30,
573
- value: keyLineOne.text,
574
- fill: COLORS.secondary,
575
- size: keyLineOne.size,
930
+ x: paceTextX,
931
+ y: cardTop + paceHeadlineBaseline,
932
+ value: paceHeadline.value,
933
+ fill: paceHeadline.color,
934
+ size: 26,
935
+ weight: 800,
936
+ spacing: "-0.52",
576
937
  }));
577
- elements.push(`<line x1="${x}" y1="${y + 56}" x2="${x + 12}" y2="${y + 56}" stroke="${COLORS.line}" stroke-width="2.5" stroke-linecap="round"/>`);
578
- elements.push(`<circle cx="${x + 6}" cy="${y + 56}" r="2.5" fill="${COLORS.line}"/>`);
579
- const keyLineTwo = fitLine(
580
- "Line = weekly meter remaining (%)",
581
- 12,
582
- keyCardWidth - 54,
583
- );
584
938
  elements.push(svgText({
585
- x: x + 22,
586
- y: y + 60,
587
- value: keyLineTwo.text,
588
- fill: COLORS.secondary,
589
- size: keyLineTwo.size,
939
+ x: paceTextX + textWidth(paceHeadline.value, 26, 800) + 10,
940
+ y: cardTop + paceHeadlineBaseline,
941
+ value: paceHeadline.detail,
942
+ fill: COLORS.muted,
943
+ size: 12.5,
590
944
  }));
591
- const keyLineThree = fitLine(
592
- "Darker segment shade = fast mode",
593
- 11,
594
- keyCardWidth - 54,
945
+ }
946
+ if (paceRunwayBar) {
947
+ // Runway timeline: amber fill = days of meter left, tick = the next
948
+ // weekly reset, both on a shared day scale.
949
+ const scaleDays = Math.max(paceRunwayBar.runwayDays, paceRunwayBar.daysToReset) * 1.06;
950
+ const trackY = cardTop + paceHeadlineBaseline + 12;
951
+ elements.push(svgRect(paceTextX, trackY, paceInnerWidth, 5, {
952
+ rx: 2.5,
953
+ fill: "rgba(246,183,60,.14)",
954
+ }));
955
+ const runwayWidth = Math.min(1, paceRunwayBar.runwayDays / scaleDays) * paceInnerWidth;
956
+ if (runwayWidth > 0) {
957
+ elements.push(svgRect(paceTextX, trackY, runwayWidth, 5, {
958
+ rx: 2.5,
959
+ fill: COLORS.line,
960
+ }));
961
+ }
962
+ const tickX = paceTextX +
963
+ Math.min(1, paceRunwayBar.daysToReset / scaleDays) * paceInnerWidth;
964
+ elements.push(`<line x1="${tickX.toFixed(2)}" y1="${trackY - 3}" x2="${tickX.toFixed(2)}" y2="${trackY + 8}" stroke="${COLORS.secondary}" stroke-width="2"/>`);
965
+ elements.push(svgText({
966
+ x: paceTextX,
967
+ y: trackY + 22,
968
+ value: "now",
969
+ fill: COLORS.muted,
970
+ size: 10.5,
971
+ }));
972
+ const resetLabel = `reset in ${paceRunwayBar.resetInLabel}`;
973
+ const resetLabelWidth = textWidth(resetLabel, 10.5);
974
+ const nowLabelWidth = textWidth("now", 10.5);
975
+ const resetLabelX = Math.max(
976
+ paceTextX + nowLabelWidth + 8 + resetLabelWidth / 2,
977
+ Math.min(tickX, paceTextX + paceInnerWidth - resetLabelWidth / 2 - 2),
595
978
  );
596
979
  elements.push(svgText({
597
- x: x + 22,
598
- y: y + 82,
599
- value: keyLineThree.text,
980
+ x: resetLabelX,
981
+ y: trackY + 22,
982
+ value: resetLabel,
983
+ fill: COLORS.muted,
984
+ size: 10.5,
985
+ anchor: "middle",
986
+ }));
987
+ }
988
+ const paceStatLines = meterCard
989
+ ? (paceLines.length > 1 ? paceLines.slice(1) : paceLines)
990
+ : paceLines.slice(1);
991
+ paceStatLines.forEach((line, index) => {
992
+ const columnX = paceTextX + index * (paceInnerWidth / 2 + 8);
993
+ elements.push(svgText({
994
+ x: columnX,
995
+ y: cardTop + paceStatValueBaseline,
996
+ value: line.value,
997
+ fill: line.color,
998
+ size: 17,
999
+ weight: 700,
1000
+ }));
1001
+ elements.push(svgText({
1002
+ x: columnX,
1003
+ y: cardTop + paceStatValueBaseline + 16,
1004
+ value: line.detail,
600
1005
  fill: COLORS.muted,
601
- size: keyLineThree.size,
1006
+ size: 11,
602
1007
  }));
603
1008
  });
1009
+ let paceNoteBaseline = cardTop + paceNoteStart;
1010
+ for (const line of paceNoteLines) {
1011
+ elements.push(svgText({
1012
+ x: paceTextX,
1013
+ y: paceNoteBaseline,
1014
+ value: line,
1015
+ fill: COLORS.muted,
1016
+ size: 11.5,
1017
+ }));
1018
+ paceNoteBaseline += 16;
1019
+ }
604
1020
 
605
1021
  // ---- Chart grid + axes ----
606
- for (const fraction of [0, 0.25, 0.5, 0.75, 1]) {
607
- const y = chartBottom - fraction * chartHeight;
608
- elements.push(`<line x1="${plotLeft}" y1="${y.toFixed(2)}" x2="${plotLeft + plotWidth}" y2="${y.toFixed(2)}" stroke="${fraction === 0 ? COLORS.baseline : COLORS.grid}" stroke-width="1"/>`);
1022
+ for (const fraction of [1, 0.75, 0.5, 0.25, 0]) {
1023
+ const y = plotBottom - fraction * plotHeight;
1024
+ 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"/>`);
609
1025
  elements.push(svgText({
610
- x: plotLeft - 12,
1026
+ x: plotLeft - 14,
611
1027
  y: y + 4,
612
1028
  value: percentMode
613
1029
  ? `${Number((maxBar * fraction).toFixed(1))}%`
614
- : compact(maxBar * fraction),
615
- fill: COLORS.secondary,
616
- size: 12,
1030
+ : fraction === 0
1031
+ ? "0"
1032
+ : compact(maxBar * fraction),
1033
+ fill: COLORS.muted,
1034
+ size: 13,
617
1035
  anchor: "end",
1036
+ mono: true,
618
1037
  }));
619
1038
  if (hasLine) {
620
1039
  elements.push(svgText({
621
- x: plotLeft + plotWidth + 14,
1040
+ x: plotRight + 14,
622
1041
  y: y + 4,
623
1042
  value: `${Math.round(fraction * 100)}%`,
624
- fill: COLORS.line,
625
- size: 12,
626
- weight: 600,
1043
+ fill: COLORS.meterAxis,
1044
+ size: 13,
1045
+ mono: true,
627
1046
  }));
628
1047
  }
629
1048
  }
630
1049
  elements.push(svgText({
631
- x: 26,
632
- y: chartTop + chartHeight / 2,
633
- value: percentMode ? "OBSERVED LIMIT DRAIN" : "ACTUAL TOKEN VOLUME",
1050
+ x: plotLeft,
1051
+ y: chartBlockTop + 18,
1052
+ value: percentMode
1053
+ ? "METER DRAIN · OBSERVED TOTAL, ESTIMATED MODEL SPLIT"
1054
+ : "TOKEN VOLUME · ACTUAL",
634
1055
  fill: COLORS.leftAxis,
635
- size: 12,
636
- weight: 650,
637
- anchor: "middle",
638
- spacing: "0.1em",
639
- }).replace("<text ", `<text transform="rotate(-90 26 ${chartTop + chartHeight / 2})" `));
1056
+ size: 11.5,
1057
+ spacing: "1.25",
1058
+ }));
640
1059
  if (hasLine) {
641
1060
  elements.push(svgText({
642
- x: width - 24,
643
- y: chartTop + chartHeight / 2,
644
- value: "WEEKLY METER REMAINING (%)",
645
- fill: COLORS.line,
646
- size: 12,
647
- weight: 650,
648
- anchor: "middle",
649
- spacing: "0.1em",
650
- }).replace("<text ", `<text transform="rotate(90 ${width - 24} ${chartTop + chartHeight / 2})" `));
1061
+ x: plotRight,
1062
+ y: chartBlockTop + 18,
1063
+ value: "WEEKLY METER · REMAINING",
1064
+ fill: COLORS.meterAxis,
1065
+ size: 11.5,
1066
+ anchor: "end",
1067
+ spacing: "1.25",
1068
+ }));
651
1069
  }
652
1070
 
653
1071
  // ---- Bars ----
654
1072
  const slotWidth = plotWidth / binCount;
655
- const barWidth = Math.min(104, Math.max(26, slotWidth * 0.62));
656
- const segmentGap = 2;
1073
+ const barWidth = Math.min(74, Math.max(MIN_BAR_WIDTH, slotWidth * 0.6));
1074
+ const barGeometry = bars.map((bin, binIndex) => {
1075
+ const centerX = plotLeft + (binIndex + 0.5) * slotWidth;
1076
+ return {
1077
+ bin,
1078
+ centerX,
1079
+ x: centerX - barWidth / 2,
1080
+ topY: plotBottom - (binTotalOf(bin) / maxBar) * plotHeight,
1081
+ };
1082
+ });
657
1083
 
658
- for (const [binIndex, bin] of bars.entries()) {
659
- const x = plotLeft + binIndex * slotWidth + (slotWidth - barWidth) / 2;
660
- const binTotal = binTotalOf(bin);
1084
+ const segmentLabels = [];
1085
+ for (const { bin, centerX, x } of barGeometry) {
661
1086
  const entries = sortedModelEntries(bin.values);
662
- let cumulative = 0;
663
- for (const [entryIndex, [model, value]] of entries.entries()) {
664
- const isTop = entryIndex === entries.length - 1;
665
- const fullHeight = (value / maxBar) * chartHeight;
666
- const gap = entryIndex === 0 ? 0 : segmentGap;
667
- const segmentHeight = Math.max(0, fullHeight - gap);
668
- const y = chartBottom - cumulative - fullHeight;
1087
+ let y = plotBottom;
1088
+ for (const [model, value] of entries) {
1089
+ const segmentHeight = (value / maxBar) * plotHeight;
1090
+ y -= segmentHeight;
1091
+ if (segmentHeight <= 0.4) continue;
669
1092
  const baseColor = styleForModel(model);
670
- if (segmentHeight > 0.4) {
671
- if (isTop) {
672
- elements.push(roundedTopRect(x, y, barWidth, segmentHeight, 5, baseColor));
673
- } else {
674
- elements.push(`<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${barWidth.toFixed(2)}" height="${segmentHeight.toFixed(2)}" fill="${baseColor}"/>`);
1093
+ const fastValue = percentMode ? 0 : (bin.fastValues?.get(model) ?? 0);
1094
+ const fastHeight = fastValue > 0 && value > 0
1095
+ ? segmentHeight * Math.min(1, fastValue / value)
1096
+ : 0;
1097
+ elements.push(svgRect(x, y, barWidth, segmentHeight - fastHeight, {
1098
+ fill: baseColor,
1099
+ "data-series": "usage-bars",
1100
+ "data-model": model,
1101
+ "data-value": value,
1102
+ "data-unit": percentMode ? "meter-points" : "tokens",
1103
+ }));
1104
+ if (fastHeight > 0.5) {
1105
+ elements.push(svgRect(x, y + segmentHeight - fastHeight, barWidth, fastHeight, {
1106
+ fill: fastShade(baseColor),
1107
+ "data-series": "usage-bars",
1108
+ "data-model": model,
1109
+ "data-value": fastValue,
1110
+ "data-unit": "tokens",
1111
+ "data-tier": "fast",
1112
+ }));
1113
+ }
1114
+ const valueLabel = percentMode ? percent(value) : compact(value);
1115
+ const fits = (text, size) => textWidth(text, size, 700) <= barWidth - 6;
1116
+ if (segmentHeight >= 32 && fits(model, 13) && fits(valueLabel, 15)) {
1117
+ const segmentCenter = y + segmentHeight / 2;
1118
+ segmentLabels.push(svgText({
1119
+ x: centerX,
1120
+ y: segmentCenter - 5,
1121
+ value: model,
1122
+ fill: COLORS.onFill,
1123
+ size: 13,
1124
+ anchor: "middle",
1125
+ }));
1126
+ segmentLabels.push(svgText({
1127
+ x: centerX,
1128
+ y: segmentCenter + 13,
1129
+ value: valueLabel,
1130
+ fill: "#ffffff",
1131
+ size: 15,
1132
+ weight: 700,
1133
+ anchor: "middle",
1134
+ }));
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ // ---- Meter line: per-cycle smoothed segments with reset breaks ----
1140
+ const yForRemaining = (value) =>
1141
+ plotTop + (1 - Math.max(0, Math.min(100, value)) / 100) * plotHeight;
1142
+ const xForTimestamp = (timestampMs) => {
1143
+ const span = bounds.end.getTime() - bounds.start.getTime();
1144
+ const ratio = span > 0 ? (timestampMs - bounds.start.getTime()) / span : 0;
1145
+ return plotLeft + Math.max(0, Math.min(1, ratio)) * plotWidth;
1146
+ };
1147
+
1148
+ let resetMarks = [];
1149
+ let binDots = [];
1150
+ let pills = [];
1151
+ const lineSegments = [];
1152
+ if (hasLine) {
1153
+ const cycles = new Map();
1154
+ for (const point of trend.points ?? []) {
1155
+ const cycle = cycles.get(point.cycle) ?? [];
1156
+ cycle.push(point);
1157
+ cycles.set(point.cycle, cycle);
1158
+ }
1159
+ const orderedCycles = [...cycles.entries()].sort(
1160
+ (left, right) => left[1][0].timestampMs - right[1][0].timestampMs,
1161
+ );
1162
+
1163
+ resetMarks = resetsInRange
1164
+ .filter((reset) => reset.kind !== "start")
1165
+ .map((reset) => ({
1166
+ ...reset,
1167
+ x: xForTimestamp(Math.max(bounds.start.getTime(), reset.timestampMs)),
1168
+ label: reset.kind === "weekly-expiry"
1169
+ ? "RESET · 100%"
1170
+ : "RESTART · 100%",
1171
+ }));
1172
+ const resetByCycle = new Map(resetMarks.map((reset) => [reset.cycle, reset]));
1173
+ const resetLabels = (() => {
1174
+ const maximum = 4;
1175
+ if (resetMarks.length <= maximum) return resetMarks;
1176
+ const selected = new Map();
1177
+ const add = (reset) => {
1178
+ if (reset) selected.set(reset.cycle, reset);
1179
+ };
1180
+ const scheduled = resetMarks.filter(
1181
+ (reset) => reset.kind === "weekly-expiry",
1182
+ );
1183
+ if (scheduled.length >= maximum) {
1184
+ for (let index = 0; index < maximum; index += 1) {
1185
+ add(scheduled[Math.round((index / (maximum - 1)) * (scheduled.length - 1))]);
675
1186
  }
676
- const fastValue = percentMode ? 0 : (bin.fastValues?.get(model) ?? 0);
677
- const fastHeight = fastValue > 0 && value > 0
678
- ? segmentHeight * Math.min(1, fastValue / value)
679
- : 0;
680
- if (fastHeight > 0.5) {
681
- if (isTop) {
682
- elements.push(roundedTopRect(x, y, barWidth, fastHeight, 5, fastShade(baseColor)));
683
- } else {
684
- elements.push(`<rect x="${x.toFixed(2)}" y="${y.toFixed(2)}" width="${barWidth.toFixed(2)}" height="${fastHeight.toFixed(2)}" fill="${fastShade(baseColor)}"/>`);
685
- }
1187
+ } else {
1188
+ scheduled.forEach(add);
1189
+ add(resetMarks[0]);
1190
+ add(resetMarks.findLast((reset) => reset.kind !== "weekly-expiry"));
1191
+ for (let index = 1; selected.size < maximum && index < resetMarks.length - 1; index += 1) {
1192
+ const candidateIndex = Math.round(
1193
+ (index / (maximum - 1)) * (resetMarks.length - 1),
1194
+ );
1195
+ add(resetMarks[candidateIndex]);
686
1196
  }
687
- // Per-segment labels: model name first, then value and share of the
688
- // column — each line only when it fits the segment.
689
- const share = binTotal > 0 ? (value / binTotal) * 100 : 0;
690
- const valueLabel = percentMode ? percent(value) : compact(value);
691
- const fits = (text, size) => text.length * size * 0.6 <= barWidth - 8;
692
- const candidates = [
693
- { value: model, size: 12, weight: 650, opacity: null },
694
- { value: valueLabel, size: 11.5, weight: 600, opacity: null },
695
- { value: `(${percent(share)})`, size: 10, weight: 400, opacity: 0.75 },
696
- ].filter((line) => fits(line.value, line.size));
697
- const lineHeight = 15;
698
- const maxLines = Math.min(
699
- candidates.length,
700
- Math.floor((segmentHeight - 6) / lineHeight),
701
- );
702
- if (maxLines > 0) {
703
- const lines = candidates.slice(0, maxLines);
704
- const blockTop = y + segmentHeight / 2 - ((lines.length - 1) * lineHeight) / 2;
705
- for (const [lineIndex, line] of lines.entries()) {
706
- elements.push(svgText({
707
- x: x + barWidth / 2,
708
- y: blockTop + lineIndex * lineHeight + 4,
709
- value: line.value,
710
- fill: "#ffffff",
711
- size: line.size,
712
- weight: line.weight,
713
- anchor: "middle",
714
- opacity: line.opacity,
715
- }));
1197
+ }
1198
+ return [...selected.values()]
1199
+ .sort((left, right) => left.x - right.x)
1200
+ .slice(-maximum);
1201
+ })();
1202
+ const labeledResetCycles = new Set(resetLabels.map((reset) => reset.cycle));
1203
+
1204
+ for (const [cycleIndex, [cycleId, cyclePoints]] of orderedCycles.entries()) {
1205
+ const points = cyclePoints.map((point) => ({
1206
+ x: xForTimestamp(point.timestampMs),
1207
+ y: yForRemaining(point.remainingPercent),
1208
+ remainingPercent: point.remainingPercent,
1209
+ timestampMs: point.timestampMs,
1210
+ }));
1211
+ const cycleReset = resetByCycle.get(cycleId);
1212
+ if (
1213
+ cycleReset &&
1214
+ points.length &&
1215
+ cycleReset.timestampMs < points[0].timestampMs
1216
+ ) {
1217
+ points.unshift({
1218
+ x: cycleReset.x,
1219
+ y: yForRemaining(100),
1220
+ remainingPercent: 100,
1221
+ timestampMs: cycleReset.timestampMs,
1222
+ syntheticReset: true,
1223
+ });
1224
+ }
1225
+
1226
+ // Carry the previous reading to the exact reset boundary. The former
1227
+ // renderer stopped at the last observation and restarted at a nudged
1228
+ // marker, leaving a visible gap that looked like a broken series.
1229
+ const nextCycleId = orderedCycles[cycleIndex + 1]?.[0];
1230
+ const nextReset = resetByCycle.get(nextCycleId);
1231
+ const lastPoint = points.at(-1);
1232
+ if (
1233
+ nextReset &&
1234
+ lastPoint &&
1235
+ lastPoint.timestampMs < nextReset.timestampMs
1236
+ ) {
1237
+ points.push({
1238
+ ...lastPoint,
1239
+ x: nextReset.x,
1240
+ timestampMs: nextReset.timestampMs,
1241
+ carriedToReset: true,
1242
+ });
1243
+ }
1244
+
1245
+ // Thin to at most one point per 2px so the path stays light while the
1246
+ // spline still follows every meaningful movement.
1247
+ const thinned = [];
1248
+ for (const point of points) {
1249
+ const { x, y } = point;
1250
+ const previous = thinned.at(-1);
1251
+ if (previous && x - previous.x < 2) {
1252
+ if (previous.syntheticReset && Math.abs(previous.y - y) > 0.5) {
1253
+ thinned.push({ ...point, x: Math.max(x, previous.x + 0.75) });
1254
+ } else {
1255
+ Object.assign(previous, point);
716
1256
  }
1257
+ } else {
1258
+ thinned.push({ ...point });
717
1259
  }
718
1260
  }
719
- cumulative += fullHeight;
1261
+ const path = monotonePath(thinned);
1262
+ if (path) {
1263
+ 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)"/>`);
1264
+ 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)}"/>`);
1265
+ lineSegments.push(thinned);
1266
+ }
1267
+ }
1268
+
1269
+ // One dot per labeled column: the last observation inside that column.
1270
+ const observed = (trend.points ?? []).filter((point) => point.observed);
1271
+ const step = labelEvery(binCount);
1272
+ const resetBinIndexes = new Set(resetMarks.map((reset) =>
1273
+ Math.max(0, Math.min(binCount - 1, Math.floor((reset.x - plotLeft) / slotWidth)))));
1274
+ for (let binIndex = 0; binIndex < binCount; binIndex += 1) {
1275
+ if (binIndex % step !== 0 && binIndex !== binCount - 1) continue;
1276
+ if (resetBinIndexes.has(binIndex)) continue;
1277
+ const binEndMs = zonedMidnight(
1278
+ bars[binIndex].endDateString,
1279
+ bounds.timeZone,
1280
+ ).getTime();
1281
+ const binStartMs = zonedMidnight(
1282
+ bars[binIndex].startDateString,
1283
+ bounds.timeZone,
1284
+ ).getTime();
1285
+ const point = observed.findLast(
1286
+ (candidate) =>
1287
+ candidate.timestampMs >= binStartMs && candidate.timestampMs < binEndMs,
1288
+ );
1289
+ if (!point) continue;
1290
+ binDots.push({
1291
+ binIndex,
1292
+ x: xForTimestamp(point.timestampMs),
1293
+ y: yForRemaining(point.remainingPercent),
1294
+ remainingPercent: point.remainingPercent,
1295
+ cycle: point.cycle,
1296
+ });
1297
+ }
1298
+ for (const dot of binDots) {
1299
+ 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"/>`);
1300
+ }
1301
+
1302
+ // Stagger dense reset labels across lanes: a label joins the first lane
1303
+ // whose previous label sits far enough to its left.
1304
+ const laneRight = [];
1305
+ for (const reset of resetMarks) {
1306
+ const resetBinIndex = Math.max(
1307
+ 0,
1308
+ Math.min(binCount - 1, Math.floor((reset.x - plotLeft) / slotWidth)),
1309
+ );
1310
+ const resetBar = barGeometry[resetBinIndex];
1311
+ const crossesBar = resetBar &&
1312
+ reset.x >= resetBar.x - 2 &&
1313
+ reset.x <= resetBar.x + barWidth + 2;
1314
+ const resetLineBottom = crossesBar
1315
+ ? Math.max(plotTop + 36, resetBar.topY - 8)
1316
+ : plotBottom;
1317
+ 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"/>`);
1318
+ if (!labeledResetCycles.has(reset.cycle)) continue;
1319
+ const labelWidth = textWidth(reset.label, 11, 700) + 14;
1320
+ const labelCenterX = Math.max(
1321
+ plotLeft + labelWidth / 2,
1322
+ Math.min(plotRight - labelWidth / 2, reset.x),
1323
+ );
1324
+ const labelLeft = labelCenterX - labelWidth / 2;
1325
+ let lane = laneRight.findIndex((right) => labelLeft - right >= 8);
1326
+ if (lane < 0) {
1327
+ lane = laneRight.length < 3
1328
+ ? laneRight.length
1329
+ : laneRight.indexOf(Math.min(...laneRight));
1330
+ }
1331
+ laneRight[lane] = labelCenterX + labelWidth / 2;
1332
+ const labelBaseline = plotTop + 20 + lane * 21;
1333
+ elements.push(svgRect(
1334
+ labelCenterX - labelWidth / 2,
1335
+ labelBaseline - 14,
1336
+ labelWidth,
1337
+ 19,
1338
+ {
1339
+ rx: 5,
1340
+ fill: COLORS.background,
1341
+ stroke: "rgba(246,183,60,.42)",
1342
+ "stroke-width": 1,
1343
+ },
1344
+ ));
1345
+ elements.push(svgText({
1346
+ x: labelCenterX,
1347
+ y: labelBaseline,
1348
+ value: reset.label,
1349
+ fill: COLORS.line,
1350
+ size: 11,
1351
+ weight: 700,
1352
+ anchor: "middle",
1353
+ mono: true,
1354
+ }));
1355
+ }
1356
+
1357
+ // Keep the line readable with no more than two decision-useful labels:
1358
+ // the first reading after the latest reset (or the range start when no
1359
+ // reset exists) and the latest reading.
1360
+ const picked = [];
1361
+ const latestReset = resetMarks.at(-1);
1362
+ if (latestReset) {
1363
+ const afterReset = binDots.find((dot) => dot.x > latestReset.x + 2);
1364
+ if (afterReset) picked.push(afterReset);
1365
+ } else if (binDots.length) {
1366
+ picked.push(binDots[0]);
1367
+ }
1368
+ if (binDots.length > 1) picked.push(binDots.at(-1));
1369
+ const uniquePicks = [...new Map(picked.map((dot) => [dot.binIndex, dot])).values()];
1370
+ pills = uniquePicks.map((dot, pickIndex) => {
1371
+ const label = meterLabel(dot.remainingPercent);
1372
+ const pillWidth = textWidth(label, 12, 700) + 18;
1373
+ const preferLeft = pickIndex === uniquePicks.length - 1 ||
1374
+ dot.x + pillWidth + 16 > plotRight;
1375
+ let x = preferLeft ? dot.x - pillWidth - 13 : dot.x + 13;
1376
+ x = Math.max(plotLeft + 3, Math.min(plotRight - pillWidth - 3, x));
1377
+ let y = dot.y - 32;
1378
+ if (y < plotTop + 7) y = dot.y + 11;
1379
+ y = Math.max(plotTop + 7, Math.min(plotBottom - 31, y));
1380
+ return {
1381
+ x,
1382
+ y,
1383
+ w: pillWidth,
1384
+ h: 24,
1385
+ tx: x + pillWidth / 2,
1386
+ ty: y + 16,
1387
+ label,
1388
+ dotX: dot.x,
1389
+ dotY: dot.y,
1390
+ };
1391
+ });
1392
+ }
1393
+
1394
+ // ---- Bar totals and day labels (drawn over the line like the labels) ----
1395
+ elements.push(...segmentLabels);
1396
+ // Where the line passes through a horizontal span at band height, from the
1397
+ // thinned polylines; keeps each column total clear of the amber stroke.
1398
+ const lineTopIfCrossing = (x0, x1, bandTop, bandBottom) => {
1399
+ let top = Infinity;
1400
+ for (const segment of lineSegments) {
1401
+ for (let index = 0; index < segment.length - 1; index += 1) {
1402
+ const from = segment[index];
1403
+ const to = segment[index + 1];
1404
+ if (to.x < x0 || from.x > x1) continue;
1405
+ const clip0 = Math.max(x0, from.x);
1406
+ const clip1 = Math.min(x1, to.x);
1407
+ if (clip1 < clip0) continue;
1408
+ const yAt = (x) =>
1409
+ from.y + (to.x === from.x ? 0 : ((x - from.x) / (to.x - from.x)) * (to.y - from.y));
1410
+ const yLow = Math.min(yAt(clip0), yAt(clip1));
1411
+ const yHigh = Math.max(yAt(clip0), yAt(clip1));
1412
+ if (yLow <= bandBottom && yHigh >= bandTop) top = Math.min(top, yLow);
1413
+ }
720
1414
  }
721
- if (binTotal > 0) {
1415
+ return top;
1416
+ };
1417
+ const labelStep = labelEvery(binCount);
1418
+ const isLabeledColumn = (binIndex) =>
1419
+ binIndex % labelStep === 0 || binIndex === binCount - 1;
1420
+ for (const [binIndex, { bin, centerX, topY }] of barGeometry.entries()) {
1421
+ const binTotal = binTotalOf(bin);
1422
+ // Dense windows only caption the columns that carry date labels; a total
1423
+ // on all 30 daily columns would overlap its neighbours.
1424
+ if (binTotal > 0 && isLabeledColumn(binIndex)) {
1425
+ // The total label sits in the band just above the stack; step it above
1426
+ // the line only when the line actually crosses that band.
1427
+ const lineTop = lineTopIfCrossing(
1428
+ centerX - barWidth / 2 - 6,
1429
+ centerX + barWidth / 2 + 6,
1430
+ topY - 32,
1431
+ topY + 8,
1432
+ );
1433
+ const clearedTop = Number.isFinite(lineTop) ? Math.min(topY, lineTop) : topY;
722
1434
  elements.push(svgText({
723
- x: x + barWidth / 2,
724
- y: chartBottom - (binTotal / maxBar) * chartHeight - 10,
1435
+ x: centerX,
1436
+ y: clearedTop - 13,
725
1437
  value: percentMode
726
1438
  ? `${bin.approximate ? "≈" : ""}${percent(binTotal)}`
727
1439
  : compact(binTotal),
728
1440
  fill: COLORS.ink,
729
- size: 14.5,
730
- weight: 650,
1441
+ size: 16,
1442
+ weight: 700,
731
1443
  anchor: "middle",
732
1444
  }));
733
1445
  }
734
-
735
- if (binIndex % labelEvery(binCount) === 0 || binIndex === binCount - 1) {
1446
+ if (isLabeledColumn(binIndex)) {
736
1447
  const weekday = actual.binSize === 1
737
1448
  ? localWeekdayLabel(bin.startDateString, bounds.timeZone).toUpperCase()
738
1449
  : "";
739
1450
  if (weekday) {
740
1451
  elements.push(svgText({
741
- x: x + barWidth / 2,
742
- y: chartBottom + 24,
1452
+ x: centerX,
1453
+ y: plotBottom + 32,
743
1454
  value: weekday,
744
1455
  fill: COLORS.muted,
745
- size: 11,
746
- weight: 600,
1456
+ size: 13,
747
1457
  anchor: "middle",
1458
+ spacing: "1.56",
748
1459
  }));
749
1460
  }
750
1461
  elements.push(svgText({
751
- x: x + barWidth / 2,
752
- y: chartBottom + (weekday ? 42 : 30),
1462
+ x: centerX,
1463
+ y: plotBottom + (weekday ? 54 : 40),
753
1464
  value: binDateLabel(bin, bounds.timeZone),
754
1465
  fill: COLORS.secondary,
755
- size: 12.5,
1466
+ size: 15,
756
1467
  anchor: "middle",
757
1468
  }));
758
1469
  }
759
1470
  }
760
-
761
- // ---- Meter line, refill markers, chips ----
762
- const quota = hasLine
763
- ? buildQuotaLine(trend, bounds, plotLeft, plotWidth, chartTop, chartHeight)
764
- : { points: [], resetPoints: [] };
765
- if (hasLine && quota.points.length > 1) {
766
- for (const reset of quota.resetPoints) {
767
- elements.push(`<line x1="${reset.x.toFixed(2)}" y1="${chartTop}" x2="${reset.x.toFixed(2)}" y2="${chartBottom}" stroke="${COLORS.baseline}" stroke-width="1.25" stroke-dasharray="5 5"/>`);
768
- }
769
- elements.push(`<path d="${linePath(quota.points)}" fill="none" stroke="${COLORS.line}" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"/>`);
770
-
771
- // Chips: one meter reading per labeled column (the last observation in
772
- // that column), plus a refill chip at each restart or reset.
773
- const step = labelEvery(binCount);
774
- const observationPoints = quota.points.filter((point) => point.timestampMs);
775
- const chipPoints = [];
776
- for (let binIndex = 0; binIndex < binCount; binIndex += 1) {
777
- if (binIndex % step !== 0 && binIndex !== binCount - 1) continue;
778
- const binEndMs = zonedMidnight(
779
- bars[binIndex].endDateString,
780
- bounds.timeZone,
781
- ).getTime();
782
- const candidates = observationPoints.filter((point) => point.timestampMs < binEndMs);
783
- const point = candidates.at(-1);
784
- if (point) chipPoints.push(point);
785
- }
786
- const lastPoint = observationPoints.at(-1);
787
- if (lastPoint) chipPoints.push(lastPoint);
788
- const seen = new Set();
789
- const barGeometry = (xValue) => {
790
- const binIndex = Math.max(0, Math.min(binCount - 1,
791
- Math.floor((xValue - plotLeft) / slotWidth)));
792
- const barLeft = plotLeft + binIndex * slotWidth + (slotWidth - barWidth) / 2;
793
- const total = binTotalOf(bars[binIndex]);
794
- const topY = chartBottom - (total / maxBar) * chartHeight;
795
- return { barLeft, barRight: barLeft + barWidth, topY, total };
796
- };
797
- for (const point of chipPoints) {
798
- const key = `${point.x.toFixed(0)}`;
799
- if (seen.has(key)) continue;
800
- seen.add(key);
801
- elements.push(`<circle cx="${point.x.toFixed(2)}" cy="${point.y.toFixed(2)}" r="4.5" fill="${COLORS.line}" stroke="${COLORS.background}" stroke-width="2"/>`);
802
- const geometry = barGeometry(point.x);
803
- const overBar = point.x >= geometry.barLeft - 8 && point.x <= geometry.barRight + 8;
804
- const insideBar = overBar && point.y > geometry.topY - 6 && geometry.total > 0;
805
- let chipX = point.x;
806
- let chipY = point.y - 24;
807
- let anchor = "middle";
808
- if (insideBar) {
809
- // Slide the chip into the slot gap beside the column.
810
- const rightX = geometry.barRight + 12;
811
- if (rightX + 64 <= plotLeft + plotWidth) {
812
- chipX = rightX;
813
- anchor = "start";
814
- } else {
815
- chipX = geometry.barLeft - 12;
816
- anchor = "end";
817
- }
818
- chipY = point.y;
819
- } else if (overBar && Math.abs(point.y - geometry.topY) < 60 && geometry.total > 0) {
820
- // Keep clear of the column-total label just above the cap.
821
- chipY = geometry.topY - 44;
822
- } else if (point.y < chartTop + 44) {
823
- chipY = point.y + 26;
824
- }
825
- elements.push(chip(chipX, chipY, percent(point.remainingPercent), { anchor }));
826
- }
827
- let previousRefillX = -Infinity;
828
- let refillLane = 0;
829
- for (const reset of quota.resetPoints) {
830
- const label = reset.kind === "weekly-expiry" ? "RESET 100%" : "RESTART 100%";
831
- // Stagger dense refill chips across two lanes so they stay legible.
832
- refillLane = reset.x - previousRefillX < 112 ? (refillLane + 1) % 2 : 0;
833
- previousRefillX = reset.x;
834
- elements.push(chip(reset.x, chartTop - 18 - refillLane * 24, label, { small: true }));
835
- }
1471
+ for (const pill of pills) {
1472
+ const leaderX = pill.x > pill.dotX ? pill.x : pill.x + pill.w;
1473
+ const leaderY = Math.max(pill.y + 6, Math.min(pill.y + pill.h - 6, pill.dotY));
1474
+ 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"/>`);
1475
+ elements.push(svgRect(pill.x, pill.y, pill.w, pill.h, {
1476
+ rx: 5,
1477
+ fill: COLORS.background,
1478
+ stroke: COLORS.line,
1479
+ "stroke-width": 1,
1480
+ }));
1481
+ elements.push(svgText({
1482
+ x: pill.tx,
1483
+ y: pill.ty,
1484
+ value: pill.label,
1485
+ fill: COLORS.line,
1486
+ size: 12,
1487
+ weight: 700,
1488
+ anchor: "middle",
1489
+ mono: true,
1490
+ }));
836
1491
  }
837
1492
 
838
- // ---- Legend strip ----
1493
+ // ---- Legend row ----
839
1494
  const legendModels = sortedModelEntries(
840
1495
  percentMode ? burn.totals : actual.totals,
841
1496
  ).map(([model]) => model);
842
- const legendParts = legendModels.map((model) => ({ swatch: styleForModel(model), label: model }));
843
- let legendX = plotLeft;
844
- for (const part of legendParts) {
845
- elements.push(`<rect x="${legendX}" y="${legendY - 11}" width="13" height="13" rx="3" fill="${part.swatch}"/>`);
846
- elements.push(svgText({ x: legendX + 20, y: legendY, value: part.label, fill: COLORS.secondary, size: 12.5 }));
847
- legendX += 20 + part.label.length * 7.4 + 28;
848
- }
849
- if (hasLine) {
850
- elements.push(`<line x1="${legendX}" y1="${legendY - 5}" x2="${legendX + 22}" y2="${legendY - 5}" stroke="${COLORS.line}" stroke-width="2.75" stroke-linecap="round"/>`);
851
- elements.push(`<circle cx="${legendX + 11}" cy="${legendY - 5}" r="3" fill="${COLORS.line}"/>`);
1497
+ let legendX = outer;
1498
+ const legendItem = (swatchMarkup, swatchWidth, label) => {
1499
+ elements.push(swatchMarkup);
852
1500
  elements.push(svgText({
853
- x: legendX + 30,
854
- y: legendY,
855
- value: "Observed weekly meter remaining (%)",
1501
+ x: legendX + swatchWidth + 9,
1502
+ y: legendBaseline,
1503
+ value: label,
856
1504
  fill: COLORS.secondary,
857
- size: 12.5,
1505
+ size: 13.5,
858
1506
  }));
1507
+ legendX += swatchWidth + 9 + textWidth(label, 13.5) + 24;
1508
+ };
1509
+ for (const model of legendModels) {
1510
+ legendItem(
1511
+ svgRect(legendX, legendBaseline - 10, 13, 11, { fill: styleForModel(model) }),
1512
+ 13,
1513
+ model,
1514
+ );
1515
+ }
1516
+ if (hasFast && legendModels.length) {
1517
+ legendItem(
1518
+ svgRect(legendX, legendBaseline - 10, 13, 11, {
1519
+ fill: fastShade(styleForModel(legendModels[0])),
1520
+ }),
1521
+ 13,
1522
+ "Darker shade = fast mode",
1523
+ );
1524
+ }
1525
+ if (hasLine) {
1526
+ legendItem(
1527
+ svgRect(legendX, legendBaseline - 6, 20, 3, { fill: COLORS.line }),
1528
+ 20,
1529
+ percentMode
1530
+ ? "Amber = observed meter remaining · columns = estimated model split"
1531
+ : "Weekly meter remaining · observed",
1532
+ );
859
1533
  }
860
1534
 
861
- // ---- Footer strip ----
862
- elements.push(`<rect x="${outer}" y="${footerTop}" width="${width - outer * 2}" height="${footerHeight}" rx="10" fill="${COLORS.panel}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
863
- const generatedAtMs = new Date(snapshot.generatedAt).getTime();
864
- const meterTime = latestQuotaPoint
865
- ? localDateTimeLabel(latestQuotaPoint.timestampMs, bounds.timeZone)
866
- : "unknown";
867
- const snapshotTime = Number.isFinite(generatedAtMs)
868
- ? localDateTimeLabel(generatedAtMs, bounds.timeZone)
869
- : "unknown";
870
- const footerCells = [
871
- {
872
- icon: "bars",
873
- lines: percentMode
874
- ? ["Bars = observed meter drops", `${percent(burn.totalPercent)} drained in range`, "split by rate-card credit weights"]
875
- : meterUsable
876
- ? ["Bars show actual token volume", `meter dropped ${percent(burn.totalPercent)} in range`, "≈ = drop spread over meter gaps"]
877
- : ["Bars show actual token volume", "no usable meter drain in range", ""],
878
- },
879
- {
880
- icon: "card",
881
- lines: [
882
- "Rate-card estimate",
883
- `${compact(rateCard.credits)} credits${hasFast ? " · fast ×1.5" : ""} · card ${trend.rateCardAsOf}`,
884
- "estimate only · not the meter",
885
- ],
886
- },
887
- {
888
- icon: "clock",
889
- lines: [
890
- `${expiries} weekly expir${expiries === 1 ? "y" : "ies"}, ${restarts} restart${restarts === 1 ? "" : "s"}`,
891
- "restarts are provider-initiated",
892
- "windows keyed by reset time",
893
- ],
894
- },
895
- {
896
- icon: "calendar",
897
- lines: ["Meter snapshots", `latest ${meterTime}`, `snapshot ${snapshotTime}`],
898
- },
899
- ];
900
- const cellWidth = (width - outer * 2) / footerCells.length;
901
- const drawIcon = (kind, x, y) => {
902
- const stroke = COLORS.secondary;
903
- if (kind === "bars") {
904
- elements.push(`<rect x="${x}" y="${y + 8}" width="4" height="10" rx="1" fill="${stroke}"/>`);
905
- elements.push(`<rect x="${x + 6}" y="${y + 3}" width="4" height="15" rx="1" fill="${stroke}"/>`);
906
- elements.push(`<rect x="${x + 12}" y="${y + 11}" width="4" height="7" rx="1" fill="${stroke}"/>`);
907
- } else if (kind === "card") {
908
- elements.push(`<rect x="${x}" y="${y + 3}" width="17" height="14" rx="2" fill="none" stroke="${stroke}" stroke-width="1.5"/>`);
909
- elements.push(`<line x1="${x}" y1="${y + 8}" x2="${x + 17}" y2="${y + 8}" stroke="${stroke}" stroke-width="1.5"/>`);
910
- } else if (kind === "clock") {
911
- elements.push(`<circle cx="${x + 8}" cy="${y + 10}" r="7.5" fill="none" stroke="${stroke}" stroke-width="1.5"/>`);
912
- elements.push(`<path d="M${x + 8},${y + 6} L${x + 8},${y + 10} L${x + 11},${y + 12}" fill="none" stroke="${stroke}" stroke-width="1.5" stroke-linecap="round"/>`);
913
- } else {
914
- elements.push(`<rect x="${x}" y="${y + 4}" width="16" height="13" rx="2" fill="none" stroke="${stroke}" stroke-width="1.5"/>`);
915
- elements.push(`<line x1="${x + 4}" y1="${y + 2}" x2="${x + 4}" y2="${y + 6}" stroke="${stroke}" stroke-width="1.5"/>`);
916
- elements.push(`<line x1="${x + 12}" y1="${y + 2}" x2="${x + 12}" y2="${y + 6}" stroke="${stroke}" stroke-width="1.5"/>`);
1535
+ // ---- Cache rate by period (compressed strip) ----
1536
+ elements.push(`<line x1="${outer}" y1="${cacheRuleY}" x2="${contentRight}" y2="${cacheRuleY}" stroke="${COLORS.rule}" stroke-width="1"/>`);
1537
+ elements.push(svgText({
1538
+ x: outer,
1539
+ y: cacheHeaderBaseline,
1540
+ value: "CACHE RATE BY PERIOD",
1541
+ fill: COLORS.muted,
1542
+ size: 12,
1543
+ spacing: "1.32",
1544
+ }));
1545
+ if (hasCache) {
1546
+ const cacheLegendItems = [
1547
+ { swatch: COLORS.cached, label: "Cached" },
1548
+ { swatch: COLORS.uncached, label: "Uncached" },
1549
+ {
1550
+ swatch: null,
1551
+ label: `${percent(cacheData.rate)} weighted · ${compact(cacheData.cachedInputTokens)} of ${compact(cacheData.inputTokens)} input cached`,
1552
+ },
1553
+ ];
1554
+ let cacheLegendX = contentRight - cacheLegendItems.reduce(
1555
+ (sum, item) =>
1556
+ sum + (item.swatch ? 20 : 0) + textWidth(item.label, 12.5) + 18,
1557
+ -18,
1558
+ );
1559
+ for (const item of cacheLegendItems) {
1560
+ if (item.swatch) {
1561
+ elements.push(svgRect(cacheLegendX, cacheHeaderBaseline - 10, 13, 11, {
1562
+ rx: 2,
1563
+ fill: item.swatch,
1564
+ }));
1565
+ cacheLegendX += 20;
1566
+ }
1567
+ elements.push(svgText({
1568
+ x: cacheLegendX,
1569
+ y: cacheHeaderBaseline,
1570
+ value: item.label,
1571
+ fill: COLORS.muted,
1572
+ size: 12.5,
1573
+ }));
1574
+ cacheLegendX += textWidth(item.label, 12.5) + 18;
917
1575
  }
918
- };
919
- for (const [cellIndex, cell] of footerCells.entries()) {
920
- const cellX = outer + cellIndex * cellWidth;
921
- if (cellIndex > 0) {
922
- elements.push(`<line x1="${cellX.toFixed(2)}" y1="${footerTop + 14}" x2="${cellX.toFixed(2)}" y2="${footerTop + footerHeight - 14}" stroke="${COLORS.panelBorder}" stroke-width="1"/>`);
1576
+ for (const value of [100, 50, 0]) {
1577
+ const y = cachePlotBottom - (value / 100) * cachePlotHeight;
1578
+ 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"/>`);
1579
+ elements.push(svgText({
1580
+ x: plotLeft - 14,
1581
+ y: y + 4,
1582
+ value: `${value}%`,
1583
+ fill: COLORS.muted,
1584
+ size: 11.5,
1585
+ anchor: "end",
1586
+ mono: true,
1587
+ }));
923
1588
  }
924
- drawIcon(cell.icon, cellX + 20, footerTop + 22);
925
- for (const [lineIndex, line] of cell.lines.entries()) {
926
- if (!line) continue;
927
- const fitted = fitLine(line, lineIndex === 0 ? 12.5 : 11.5, cellWidth - 52 - 18);
1589
+ const cacheSlotWidth = plotWidth / cacheData.binCount;
1590
+ const cacheBarWidth = Math.min(
1591
+ 74,
1592
+ Math.max(MIN_BAR_WIDTH, cacheSlotWidth * 0.6),
1593
+ );
1594
+ const showCacheRateLabels = cacheSlotWidth >= 46 && cacheData.binCount <= 20;
1595
+ cacheData.bins.forEach((bin, binIndex) => {
1596
+ const centerX = plotLeft + (binIndex + 0.5) * cacheSlotWidth;
1597
+ const barX = centerX - cacheBarWidth / 2;
1598
+ if (Number.isFinite(bin.rate)) {
1599
+ elements.push(svgRect(barX, cachePlotTop, cacheBarWidth, cachePlotHeight, {
1600
+ rx: 3,
1601
+ fill: COLORS.uncached,
1602
+ opacity: ".88",
1603
+ }));
1604
+ const cachedHeight = cachePlotHeight * (bin.rate / 100);
1605
+ if (cachedHeight > 0) {
1606
+ elements.push(svgRect(
1607
+ barX,
1608
+ cachePlotBottom - cachedHeight,
1609
+ cacheBarWidth,
1610
+ cachedHeight,
1611
+ { rx: 2, fill: COLORS.cached },
1612
+ ));
1613
+ }
1614
+ if (showCacheRateLabels) {
1615
+ elements.push(svgText({
1616
+ x: centerX,
1617
+ y: cachePlotTop - 9,
1618
+ value: percent(bin.rate),
1619
+ fill: COLORS.secondary,
1620
+ size: 11.5,
1621
+ weight: 700,
1622
+ anchor: "middle",
1623
+ mono: true,
1624
+ }));
1625
+ }
1626
+ } else {
1627
+ // No measured input this period: an empty track with a midline dash.
1628
+ elements.push(svgRect(barX, cachePlotTop, cacheBarWidth, cachePlotHeight, {
1629
+ rx: 3,
1630
+ fill: COLORS.cacheTrack,
1631
+ stroke: COLORS.baseline,
1632
+ "stroke-width": 1,
1633
+ }));
1634
+ 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"/>`);
1635
+ }
1636
+ });
1637
+ if (Number.isFinite(cacheData.rate)) {
1638
+ const lineY = cachePlotBottom - (cacheData.rate / 100) * cachePlotHeight;
1639
+ 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"/>`);
928
1640
  elements.push(svgText({
929
- x: cellX + 52,
930
- y: footerTop + 32 + lineIndex * 20,
931
- value: fitted.text,
932
- fill: lineIndex === 0 ? COLORS.secondary : COLORS.muted,
933
- size: fitted.size,
934
- weight: lineIndex === 0 ? 600 : 400,
1641
+ x: plotRight + 8,
1642
+ y: lineY + 4,
1643
+ value: percent(cacheData.rate),
1644
+ fill: COLORS.weighted,
1645
+ size: 11.5,
1646
+ weight: 700,
1647
+ mono: true,
935
1648
  }));
936
1649
  }
1650
+ } else {
1651
+ elements.push(svgText({
1652
+ x: outer,
1653
+ y: cacheHeaderBaseline + 24,
1654
+ value: "No events with a usable input-token breakdown in this range.",
1655
+ fill: COLORS.secondary,
1656
+ size: 13,
1657
+ }));
937
1658
  }
938
1659
 
1660
+ // ---- Top projects + cache rate by model ----
1661
+ elements.push(`<line x1="${outer}" y1="${bottomRuleY}" x2="${contentRight}" y2="${bottomRuleY}" stroke="${COLORS.rule}" stroke-width="1"/>`);
1662
+ const sectionBaseline = bottomTop + 10;
1663
+ const columnGap = 28;
1664
+ const modelColumnWidth = Math.min(
1665
+ 368,
1666
+ Math.max(300, contentWidth * 0.32),
1667
+ );
1668
+ const leftColumnWidth =
1669
+ contentWidth - columnGap - 28 - modelColumnWidth;
1670
+ const leftColumnRight = outer + leftColumnWidth;
1671
+ const dividerX = leftColumnRight + columnGap;
1672
+ const modelColumnX = dividerX + 28;
1673
+
1674
+ const projectHeader = "WHERE IT WENT · TOP PROJECTS";
1675
+ elements.push(svgText({
1676
+ x: outer,
1677
+ y: sectionBaseline,
1678
+ value: projectHeader,
1679
+ fill: COLORS.muted,
1680
+ size: 12,
1681
+ spacing: "1.32",
1682
+ }));
1683
+ const topRows = rows.slice(0, 3);
1684
+ const restRows = rows.slice(3);
1685
+ const topTokens = topRows.reduce((sum, row) => sum + row.totalTokens, 0);
1686
+ const topShare = totalTokens > 0
1687
+ ? percent((topTokens / totalTokens) * 100)
1688
+ : "—";
1689
+ const fullProjectSummary = `${rows.length} ${rows.length === 1 ? "project" : "projects"} active · top ${topRows.length} = ${topShare} of tokens`;
1690
+ const shortProjectSummary = `top ${topRows.length} = ${topShare}`;
1691
+ const projectHeaderWidth =
1692
+ textWidth(projectHeader, 12) +
1693
+ Math.max(0, projectHeader.length - 1) * 1.32;
1694
+ const projectSummary = textWidth(fullProjectSummary, 12.5) <=
1695
+ leftColumnRight - outer - projectHeaderWidth - 16
1696
+ ? fullProjectSummary
1697
+ : shortProjectSummary;
1698
+ elements.push(svgText({
1699
+ x: leftColumnRight,
1700
+ y: sectionBaseline,
1701
+ value: projectSummary,
1702
+ fill: COLORS.muted,
1703
+ size: 12.5,
1704
+ anchor: "end",
1705
+ }));
1706
+
1707
+ const displayRows = topRows.map((row, index) => ({
1708
+ rank: String(index + 1).padStart(2, "0"),
1709
+ name: row.displayProject ?? row.project,
1710
+ tokens: row.totalTokens,
1711
+ fill: COLORS.leftAxis,
1712
+ muted: false,
1713
+ }));
1714
+ if (restRows.length) {
1715
+ displayRows.push({
1716
+ rank: null,
1717
+ name: restRows.length === 1
1718
+ ? (restRows[0].displayProject ?? restRows[0].project)
1719
+ : `${restRows.length} other projects`,
1720
+ tokens: restRows.reduce((sum, row) => sum + row.totalTokens, 0),
1721
+ fill: COLORS.remainderBar,
1722
+ muted: true,
1723
+ });
1724
+ }
1725
+ const rowGap = 12;
1726
+ const rankX = outer;
1727
+ const nameX = rankX + 22 + rowGap;
1728
+ const projectBarX = nameX + 190 + rowGap;
1729
+ const tokensRight = leftColumnRight - 62 - rowGap;
1730
+ const projectBarWidth = tokensRight - (86 + rowGap) - projectBarX;
1731
+ const projectNameWidth = projectBarX - nameX - rowGap;
1732
+ displayRows.forEach((row, index) => {
1733
+ const centerY = bottomTop + 29 + index * 29 + 9;
1734
+ const projectName = truncateText(
1735
+ row.name,
1736
+ projectNameWidth,
1737
+ 15,
1738
+ row.muted ? 400 : 700,
1739
+ );
1740
+ if (row.rank) {
1741
+ elements.push(svgText({
1742
+ x: rankX,
1743
+ y: centerY + 5,
1744
+ value: row.rank,
1745
+ fill: COLORS.muted,
1746
+ size: 13,
1747
+ mono: true,
1748
+ }));
1749
+ }
1750
+ elements.push(svgText({
1751
+ x: nameX,
1752
+ y: centerY + 5,
1753
+ value: projectName,
1754
+ fill: row.muted ? COLORS.muted : COLORS.ink,
1755
+ size: 15,
1756
+ weight: row.muted ? 400 : 700,
1757
+ }));
1758
+ elements.push(svgRect(projectBarX, centerY - 5, projectBarWidth, 10, {
1759
+ rx: 2,
1760
+ fill: COLORS.projectTrack,
1761
+ }));
1762
+ const share = totalTokens > 0 ? (row.tokens / totalTokens) * 100 : 0;
1763
+ const fillWidth = (Math.min(100, share) / 100) * projectBarWidth;
1764
+ if (fillWidth > 0) {
1765
+ elements.push(svgRect(projectBarX, centerY - 5, fillWidth, 10, {
1766
+ rx: 2,
1767
+ fill: row.fill,
1768
+ }));
1769
+ }
1770
+ elements.push(svgText({
1771
+ x: tokensRight,
1772
+ y: centerY + 5,
1773
+ value: compact(row.tokens),
1774
+ fill: row.muted ? COLORS.secondary : COLORS.ink,
1775
+ size: 15,
1776
+ weight: 700,
1777
+ anchor: "end",
1778
+ }));
1779
+ elements.push(svgText({
1780
+ x: leftColumnRight,
1781
+ y: centerY + 5,
1782
+ value: percent(share),
1783
+ fill: COLORS.muted,
1784
+ size: 13.5,
1785
+ anchor: "end",
1786
+ }));
1787
+ });
1788
+
1789
+ elements.push(`<line x1="${dividerX}" y1="${bottomTop}" x2="${dividerX}" y2="${bottomTop + bottomBlockHeight}" stroke="${COLORS.rule}" stroke-width="1"/>`);
1790
+ elements.push(svgText({
1791
+ x: modelColumnX,
1792
+ y: sectionBaseline,
1793
+ value: "CACHE RATE BY MODEL",
1794
+ fill: COLORS.muted,
1795
+ size: 12,
1796
+ spacing: "1.32",
1797
+ }));
1798
+ if (cacheModelRows.length === 0) {
1799
+ elements.push(svgText({
1800
+ x: modelColumnX,
1801
+ y: bottomTop + 29 + 14,
1802
+ value: "No measured input to break out.",
1803
+ fill: COLORS.secondary,
1804
+ size: 13,
1805
+ }));
1806
+ }
1807
+ const compactModelColumns = modelColumnWidth < 340;
1808
+ const modelLabelSize = compactModelColumns ? 12.5 : 13.5;
1809
+ const modelRateSize = compactModelColumns ? 11.5 : 12.5;
1810
+ const widestModelLabel = cacheModelRows.reduce(
1811
+ (width, model) => Math.max(
1812
+ width,
1813
+ textWidth(model.model, modelLabelSize, 700),
1814
+ ),
1815
+ 0,
1816
+ );
1817
+ const minimumRateRight =
1818
+ modelColumnX +
1819
+ 18 +
1820
+ widestModelLabel +
1821
+ 8 +
1822
+ textWidth("100.0%", modelRateSize, 700);
1823
+ const modelRateRight = Math.max(
1824
+ modelColumnX + modelColumnWidth * 0.42,
1825
+ minimumRateRight,
1826
+ );
1827
+ const modelBarX = modelRateRight + 15;
1828
+ const modelInputReserve = Math.min(
1829
+ 58,
1830
+ Math.max(48, modelColumnWidth * 0.16),
1831
+ );
1832
+ const modelBarWidth = contentRight - modelInputReserve - modelBarX;
1833
+ cacheModelRows.forEach((model, index) => {
1834
+ const centerY = bottomTop + 29 + index * 30 + 9;
1835
+ if (!model.muted) {
1836
+ elements.push(`<circle cx="${(modelColumnX + 5).toFixed(2)}" cy="${centerY}" r="4" fill="${styleForModel(model.model)}"/>`);
1837
+ }
1838
+ elements.push(svgText({
1839
+ x: modelColumnX + 18,
1840
+ y: centerY + 4,
1841
+ value: model.model,
1842
+ fill: model.muted ? COLORS.muted : COLORS.ink,
1843
+ size: modelLabelSize,
1844
+ weight: model.muted ? 400 : 700,
1845
+ }));
1846
+ elements.push(svgText({
1847
+ x: modelRateRight,
1848
+ y: centerY + 4,
1849
+ value: percent(model.rate),
1850
+ fill: COLORS.secondary,
1851
+ size: modelRateSize,
1852
+ weight: 700,
1853
+ anchor: "end",
1854
+ mono: true,
1855
+ }));
1856
+ elements.push(svgRect(modelBarX, centerY - 5, modelBarWidth, 10, {
1857
+ rx: 3,
1858
+ fill: COLORS.uncached,
1859
+ opacity: ".7",
1860
+ }));
1861
+ const rateFill = Number.isFinite(model.rate)
1862
+ ? modelBarWidth * (model.rate / 100)
1863
+ : 0;
1864
+ if (rateFill > 0) {
1865
+ elements.push(svgRect(modelBarX, centerY - 5, rateFill, 10, {
1866
+ rx: 3,
1867
+ fill: COLORS.cached,
1868
+ }));
1869
+ }
1870
+ elements.push(svgText({
1871
+ x: contentRight,
1872
+ y: centerY + 4,
1873
+ value: compact(model.inputTokens),
1874
+ fill: COLORS.secondary,
1875
+ size: 12.5,
1876
+ weight: 700,
1877
+ anchor: "end",
1878
+ mono: true,
1879
+ }));
1880
+ });
1881
+
939
1882
  elements.push("</svg>");
940
1883
  return elements.join("\n");
941
1884
  }