tledger 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -159,7 +159,9 @@ named pools are not stitched into that meter. Remaining percentage,
159
159
  observation time, and the selected window are local observations; the reset
160
160
  type (weekly expiry versus restart) is derived by comparing the prior window's
161
161
  reset timestamp with the first reading of the new window. None of this is an
162
- official account-wide quota or billing record.
162
+ official account-wide quota or billing record. The percentage comes from the
163
+ newest OpenAI reading recorded in a completed local Codex response; Token
164
+ Ledger does not invent a newer percentage from token counts.
163
165
 
164
166
  The exported snapshot contains token metadata, model/use-type labels, project
165
167
  labels, and display titles. It omits message bodies, reasoning text, tool
@@ -74,6 +74,7 @@ const FONT_FAMILY = "system-ui, -apple-system, 'Segoe UI', sans-serif";
74
74
  const MONO_FAMILY = "ui-monospace, Menlo, monospace";
75
75
  const FAST_MODE_LABEL_COLOR = "#a78bfa";
76
76
  const MIN_BAR_WIDTH = 26;
77
+ const METER_PANEL_HEADING = "WEEKLY LIMIT · PACE & RUNWAY";
77
78
 
78
79
  export function escapeXml(value) {
79
80
  return String(value)
@@ -198,7 +199,7 @@ function zonedMidnight(dateString, timeZone) {
198
199
  const [year, month, day] = dateParts(dateString);
199
200
  const utcGuess = Date.UTC(year, month - 1, day);
200
201
  const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess), timeZone));
201
- return new Date(first.getTime() - timeZoneOffsetMs(first, timeZone));
202
+ return new Date(utcGuess - timeZoneOffsetMs(first, timeZone));
202
203
  }
203
204
 
204
205
  function localDateLabel(dateString, timeZone) {
@@ -225,6 +226,26 @@ function timestampDateLabel(timestampMs, timeZone) {
225
226
  }).format(new Date(timestampMs));
226
227
  }
227
228
 
229
+ function timestampReadLabel(timestampMs, timeZone) {
230
+ if (!Number.isFinite(timestampMs)) return "unknown";
231
+ return new Intl.DateTimeFormat("en-US", {
232
+ timeZone,
233
+ month: "short",
234
+ day: "numeric",
235
+ hour: "numeric",
236
+ minute: "2-digit",
237
+ }).format(new Date(timestampMs));
238
+ }
239
+
240
+ function timestampTimeLabel(timestampMs, timeZone) {
241
+ if (!Number.isFinite(timestampMs)) return "unknown";
242
+ return new Intl.DateTimeFormat("en-US", {
243
+ timeZone,
244
+ hour: "numeric",
245
+ minute: "2-digit",
246
+ }).format(new Date(timestampMs));
247
+ }
248
+
228
249
  function binDateLabel(bin, timeZone) {
229
250
  const start = localDateLabel(bin.startDateString, timeZone);
230
251
  const lastDate = shiftCalendarDate(bin.endDateString, -1);
@@ -409,7 +430,7 @@ export function renderTrendImage({
409
430
  const maxBar = niceCeiling(
410
431
  bars.reduce((maximum, bin) => Math.max(maximum, binTotalOf(bin)), 0),
411
432
  );
412
- const hasLine = Boolean(trend.available && (trend.points ?? []).length > 1);
433
+ const hasLine = Boolean(trend.available && (trend.points ?? []).length > 0);
413
434
 
414
435
  const totalTokens = [...actual.totals.values()].reduce((sum, value) => sum + value, 0);
415
436
  const fastTokens = [...(actual.fastTotals?.values() ?? [])].reduce(
@@ -441,7 +462,9 @@ export function renderTrendImage({
441
462
  }).totals;
442
463
 
443
464
  const latestQuotaPoint = [...(trend.points ?? [])]
444
- .filter((point) => point.timestampMs <= bounds.end.getTime())
465
+ .filter(
466
+ (point) => point.observed && point.timestampMs <= bounds.end.getTime(),
467
+ )
445
468
  .at(-1);
446
469
  const latestQuotaReadMs = Number.isFinite(trend.observedThroughMs)
447
470
  ? trend.observedThroughMs
@@ -610,6 +633,57 @@ export function renderTrendImage({
610
633
  120,
611
634
  );
612
635
  const height = bottomTop + bottomBlockHeight + 34;
636
+ const rangeStartMs = bounds.start.getTime();
637
+ const rangeEndMs = bounds.end.getTime();
638
+ const requestedReportTimeMs = Number.isFinite(options.reportTimeMs)
639
+ ? options.reportTimeMs
640
+ : generatedAtMs;
641
+ const reportTimeMs = Number.isFinite(requestedReportTimeMs) &&
642
+ requestedReportTimeMs > rangeStartMs && requestedReportTimeMs < rangeEndMs
643
+ ? requestedReportTimeMs
644
+ : null;
645
+ const slotWidth = plotWidth / binCount;
646
+ const binTimeRanges = actual.bins.map((bin) => ({
647
+ startMs: zonedMidnight(bin.startDateString, bounds.timeZone).getTime(),
648
+ endMs: zonedMidnight(bin.endDateString, bounds.timeZone).getTime(),
649
+ }));
650
+ const finalBinTimeRange = binTimeRanges.at(-1);
651
+ const partialFinalBin = Boolean(
652
+ reportTimeMs !== null &&
653
+ finalBinTimeRange &&
654
+ reportTimeMs > finalBinTimeRange.startMs &&
655
+ reportTimeMs < finalBinTimeRange.endMs,
656
+ );
657
+ // The x axis is made of equal calendar-period slots. On an incomplete final
658
+ // day, stretch only the elapsed part of that slot so report time lands on
659
+ // the right edge instead of reserving space for hours that have not happened.
660
+ const xForTimestamp = (timestampMs) => {
661
+ if (!(timestampMs > rangeStartMs)) return plotLeft;
662
+ if (timestampMs >= (partialFinalBin ? reportTimeMs : rangeEndMs)) {
663
+ return plotRight;
664
+ }
665
+ let binIndex = binTimeRanges.findIndex(
666
+ (range) => timestampMs >= range.startMs && timestampMs < range.endMs,
667
+ );
668
+ if (binIndex < 0) {
669
+ binIndex = timestampMs < rangeStartMs ? 0 : binCount - 1;
670
+ }
671
+ const range = binTimeRanges[binIndex];
672
+ const effectiveEndMs = partialFinalBin && binIndex === binCount - 1
673
+ ? reportTimeMs
674
+ : range.endMs;
675
+ const span = Math.max(1, effectiveEndMs - range.startMs);
676
+ const ratio = Math.max(
677
+ 0,
678
+ Math.min(1, (timestampMs - range.startMs) / span),
679
+ );
680
+ return plotLeft + (binIndex + ratio) * slotWidth;
681
+ };
682
+ const yForRemaining = (value) =>
683
+ plotTop + (1 - Math.max(0, Math.min(100, value)) / 100) * plotHeight;
684
+ const reportTimeX = reportTimeMs === null
685
+ ? null
686
+ : xForTimestamp(reportTimeMs);
613
687
 
614
688
  const yearLabel = bounds.endDateString.slice(0, 4);
615
689
  const title = percentMode
@@ -617,11 +691,11 @@ export function renderTrendImage({
617
691
  : `TOKEN LEDGER · ${days}-DAY TREND`;
618
692
  const subtitle = `${localDateLabel(bounds.startDateString, bounds.timeZone)} – ${localDateLabel(bounds.endDateString, bounds.timeZone)}, ${yearLabel} · ${bounds.timeZone}`;
619
693
  const description = percentMode
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.";
694
+ ? "Dark report card: compact actual-token stat cards beside pace and runway, stacked columns of observed weekly-meter drain with an explicitly estimated per-model split, the OpenAI-reported weekly limit remaining as an amber line, a partial final day ending at report time, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates."
695
+ : "Dark report card: compact model stat cards with week-over-week delta chips beside pace and runway, stacked columns of local token volume by model with fast-mode usage in a darker shade, the OpenAI-reported weekly limit remaining as a smoothed amber line, a partial final day ending at report time, a compressed cache-rate-by-period strip, and top projects beside per-model cache rates.";
622
696
 
623
697
  const elements = [
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"}">`,
698
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-labelledby="trend-title trend-description" data-report-mode="${percentMode ? "meter-drain" : "actual-tokens"}" data-time-domain="${partialFinalBin ? "through-report" : "full-range"}">`,
625
699
  `<title id="trend-title">${escapeXml(percentMode ? `Token Ledger · ${days}-day meter drain` : `Token Ledger · ${days}-day trend`)}</title>`,
626
700
  `<desc id="trend-description">${escapeXml(description)}</desc>`,
627
701
  `<defs><clipPath id="trend-plot-clip"><rect x="${plotLeft}" y="${plotTop}" width="${plotWidth}" height="${plotHeight}"/></clipPath></defs>`,
@@ -719,13 +793,27 @@ export function renderTrendImage({
719
793
  if (hasLine && latestQuotaPoint) {
720
794
  const lastReset = resetsInRange.at(-1);
721
795
  const resetCaption = lastReset
722
- ? `reset ${timestampDateLabel(lastReset.timestampMs, bounds.timeZone)}`
796
+ ? `last reset ${timestampDateLabel(lastReset.timestampMs, bounds.timeZone)}`
723
797
  : latestResetsAtSec
724
- ? `resets ${timestampDateLabel(latestResetsAtSec * 1_000, bounds.timeZone)}`
798
+ ? `next reset ${timestampDateLabel(latestResetsAtSec * 1_000, bounds.timeZone)}`
725
799
  : "no reset in range";
800
+ const meterCaption = (() => {
801
+ if (latestQuotaReadMs === null) return resetCaption;
802
+ const candidates = [
803
+ `${resetCaption} · OpenAI reading ${timestampReadLabel(latestQuotaReadMs, bounds.timeZone)}`,
804
+ `OpenAI reading · ${timestampReadLabel(latestQuotaReadMs, bounds.timeZone)}`,
805
+ `OpenAI reading · ${timestampTimeLabel(latestQuotaReadMs, bounds.timeZone)}`,
806
+ ];
807
+ const available = Math.max(
808
+ 80,
809
+ paceInnerWidth - 15 - textWidth(METER_PANEL_HEADING, 10.5) - 18,
810
+ );
811
+ return candidates.find((candidate) => textWidth(candidate, 10.5) <= available) ??
812
+ `reported ${timestampTimeLabel(latestQuotaReadMs, bounds.timeZone)}`;
813
+ })();
726
814
  meterCard = ({
727
815
  swatch: COLORS.line,
728
- label: "Weekly meter",
816
+ label: "Weekly limit",
729
817
  labelColor: COLORS.meterAxis,
730
818
  value: meterLabel(latestQuotaPoint.remainingPercent),
731
819
  valueColor: COLORS.line,
@@ -734,9 +822,7 @@ export function renderTrendImage({
734
822
  track: "rgba(246,183,60,.2)",
735
823
  fill: COLORS.line,
736
824
  barPercent: latestQuotaPoint.remainingPercent,
737
- caption: latestQuotaReadMs === null
738
- ? resetCaption
739
- : `${resetCaption} · read ${timestampDateLabel(latestQuotaReadMs, bounds.timeZone)}`,
825
+ caption: meterCaption,
740
826
  captionShort: resetCaption,
741
827
  panel: COLORS.meterPanel,
742
828
  border: COLORS.meterPanelBorder,
@@ -870,7 +956,7 @@ export function renderTrendImage({
870
956
  elements.push(svgText({
871
957
  x: paceTextX + (meterCard ? 15 : 0),
872
958
  y: cardTop + 23,
873
- value: meterCard ? "WEEKLY METER · PACE & RUNWAY" : "PACE & RUNWAY",
959
+ value: meterCard ? METER_PANEL_HEADING : "PACE & RUNWAY",
874
960
  fill: meterCard ? meterCard.labelColor : COLORS.muted,
875
961
  size: 10.5,
876
962
  spacing: "1.2",
@@ -1060,7 +1146,7 @@ export function renderTrendImage({
1060
1146
  elements.push(svgText({
1061
1147
  x: plotRight,
1062
1148
  y: chartBlockTop + 18,
1063
- value: "WEEKLY METER · REMAINING",
1149
+ value: "WEEKLY LIMIT · OPENAI REPORTED",
1064
1150
  fill: COLORS.meterAxis,
1065
1151
  size: 11.5,
1066
1152
  anchor: "end",
@@ -1069,7 +1155,6 @@ export function renderTrendImage({
1069
1155
  }
1070
1156
 
1071
1157
  // ---- Bars ----
1072
- const slotWidth = plotWidth / binCount;
1073
1158
  const barWidth = Math.min(74, Math.max(MIN_BAR_WIDTH, slotWidth * 0.6));
1074
1159
  const barGeometry = bars.map((bin, binIndex) => {
1075
1160
  const centerX = plotLeft + (binIndex + 0.5) * slotWidth;
@@ -1137,17 +1222,10 @@ export function renderTrendImage({
1137
1222
  }
1138
1223
 
1139
1224
  // ---- 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
1225
  let resetMarks = [];
1149
1226
  let binDots = [];
1150
1227
  let pills = [];
1228
+ let hasHeldSegment = false;
1151
1229
  const lineSegments = [];
1152
1230
  if (hasLine) {
1153
1231
  const cycles = new Map();
@@ -1223,24 +1301,22 @@ export function renderTrendImage({
1223
1301
  });
1224
1302
  }
1225
1303
 
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.
1304
+ // Keep a visual connection to a known reset boundary, but render the
1305
+ // unsampled hold as dashed instead of making it look observed.
1229
1306
  const nextCycleId = orderedCycles[cycleIndex + 1]?.[0];
1230
1307
  const nextReset = resetByCycle.get(nextCycleId);
1231
1308
  const lastPoint = points.at(-1);
1232
- if (
1309
+ const resetCarry =
1233
1310
  nextReset &&
1234
1311
  lastPoint &&
1235
1312
  lastPoint.timestampMs < nextReset.timestampMs
1236
- ) {
1237
- points.push({
1238
- ...lastPoint,
1239
- x: nextReset.x,
1240
- timestampMs: nextReset.timestampMs,
1241
- carriedToReset: true,
1242
- });
1243
- }
1313
+ ? [lastPoint, {
1314
+ ...lastPoint,
1315
+ x: nextReset.x,
1316
+ timestampMs: nextReset.timestampMs,
1317
+ carriedToReset: true,
1318
+ }]
1319
+ : null;
1244
1320
 
1245
1321
  // Thin to at most one point per 2px so the path stays light while the
1246
1322
  // spline still follows every meaningful movement.
@@ -1264,6 +1340,37 @@ export function renderTrendImage({
1264
1340
  elements.push(`<path d="${path}" fill="none" stroke="${COLORS.line}" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" clip-path="url(#trend-plot-clip)" data-series="weekly-meter" data-cycle="${escapeXml(cycleId)}"/>`);
1265
1341
  lineSegments.push(thinned);
1266
1342
  }
1343
+ if (resetCarry) {
1344
+ const [from, to] = resetCarry;
1345
+ const heldPath = `M ${from.x.toFixed(2)} ${from.y.toFixed(2)} L ${to.x.toFixed(2)} ${to.y.toFixed(2)}`;
1346
+ elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.background}" stroke-width="5.5" stroke-linecap="round" opacity=".72" clip-path="url(#trend-plot-clip)"/>`);
1347
+ elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.line}" stroke-width="2.25" stroke-linecap="round" stroke-dasharray="5 6" opacity=".7" clip-path="url(#trend-plot-clip)" data-series="weekly-meter-held" data-reason="reset" data-cycle="${escapeXml(cycleId)}"/>`);
1348
+ lineSegments.push(resetCarry);
1349
+ hasHeldSegment = true;
1350
+ }
1351
+ }
1352
+
1353
+ const latestObservedPoint = [...(trend.points ?? [])]
1354
+ .filter(
1355
+ (point) =>
1356
+ point.observed &&
1357
+ reportTimeMs !== null &&
1358
+ point.timestampMs <= reportTimeMs,
1359
+ )
1360
+ .at(-1);
1361
+ if (latestObservedPoint && reportTimeMs !== null) {
1362
+ const from = {
1363
+ x: xForTimestamp(latestObservedPoint.timestampMs),
1364
+ y: yForRemaining(latestObservedPoint.remainingPercent),
1365
+ };
1366
+ const to = { x: xForTimestamp(reportTimeMs), y: from.y };
1367
+ if (to.x - from.x >= 2) {
1368
+ const heldPath = `M ${from.x.toFixed(2)} ${from.y.toFixed(2)} L ${to.x.toFixed(2)} ${to.y.toFixed(2)}`;
1369
+ elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.background}" stroke-width="5.5" stroke-linecap="round" opacity=".72" clip-path="url(#trend-plot-clip)"/>`);
1370
+ elements.push(`<path d="${heldPath}" fill="none" stroke="${COLORS.line}" stroke-width="2.25" stroke-linecap="round" stroke-dasharray="5 6" opacity=".7" clip-path="url(#trend-plot-clip)" data-series="weekly-meter-held" data-reason="report-time"/>`);
1371
+ lineSegments.push([from, to]);
1372
+ hasHeldSegment = true;
1373
+ }
1267
1374
  }
1268
1375
 
1269
1376
  // One dot per labeled column: the last observation inside that column.
@@ -1391,6 +1498,20 @@ export function renderTrendImage({
1391
1498
  });
1392
1499
  }
1393
1500
 
1501
+ if (reportTimeX !== null) {
1502
+ elements.push(`<line x1="${reportTimeX.toFixed(2)}" y1="${plotTop}" x2="${reportTimeX.toFixed(2)}" y2="${plotBottom}" stroke="${COLORS.muted}" stroke-width="1.25" stroke-dasharray="4 5" opacity=".72" data-marker="report-time"/>`);
1503
+ elements.push(svgText({
1504
+ x: reportTimeX - 7,
1505
+ y: plotTop - 8,
1506
+ value: `AS OF ${timestampTimeLabel(reportTimeMs, bounds.timeZone).toUpperCase()}`,
1507
+ fill: COLORS.muted,
1508
+ size: 10.5,
1509
+ weight: 700,
1510
+ anchor: "end",
1511
+ spacing: ".55",
1512
+ }));
1513
+ }
1514
+
1394
1515
  // ---- Bar totals and day labels (drawn over the line like the labels) ----
1395
1516
  elements.push(...segmentLabels);
1396
1517
  // Where the line passes through a horizontal span at band height, from the
@@ -1466,6 +1587,18 @@ export function renderTrendImage({
1466
1587
  size: 15,
1467
1588
  anchor: "middle",
1468
1589
  }));
1590
+ if (partialFinalBin && binIndex === binCount - 1) {
1591
+ elements.push(svgText({
1592
+ x: centerX,
1593
+ y: plotBottom + 70,
1594
+ value: `PARTIAL · THROUGH ${timestampTimeLabel(reportTimeMs, bounds.timeZone).toUpperCase()}`,
1595
+ fill: COLORS.muted,
1596
+ size: 10.5,
1597
+ weight: 700,
1598
+ anchor: "middle",
1599
+ spacing: ".45",
1600
+ }));
1601
+ }
1469
1602
  }
1470
1603
  }
1471
1604
  for (const pill of pills) {
@@ -1523,13 +1656,19 @@ export function renderTrendImage({
1523
1656
  );
1524
1657
  }
1525
1658
  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
- );
1659
+ if (hasHeldSegment) {
1660
+ legendItem(
1661
+ `<line x1="${legendX}" y1="${legendBaseline - 5}" x2="${legendX + 17}" y2="${legendBaseline - 5}" stroke="${COLORS.line}" stroke-width="3"/><line x1="${legendX + 25}" y1="${legendBaseline - 5}" x2="${legendX + 42}" y2="${legendBaseline - 5}" stroke="${COLORS.line}" stroke-width="2.25" stroke-dasharray="5 5" opacity=".7"/>`,
1662
+ 42,
1663
+ "Limit: reported / awaiting update",
1664
+ );
1665
+ } else {
1666
+ legendItem(
1667
+ svgRect(legendX, legendBaseline - 6, 20, 3, { fill: COLORS.line }),
1668
+ 20,
1669
+ "OpenAI weekly-limit reading",
1670
+ );
1671
+ }
1533
1672
  }
1534
1673
 
1535
1674
  // ---- Cache rate by period (compressed strip) ----
@@ -166,7 +166,7 @@ function zonedMidnight(
166
166
  const [year, month, day] = dateParts(dateString);
167
167
  const utcGuess = Date.UTC(year, month - 1, day);
168
168
  const first = new Date(utcGuess - timeZoneOffsetMs(new Date(utcGuess), formatter));
169
- return new Date(first.getTime() - timeZoneOffsetMs(first, formatter));
169
+ return new Date(utcGuess - timeZoneOffsetMs(first, formatter));
170
170
  }
171
171
 
172
172
  function localDateString(
@@ -151,12 +151,20 @@ export function trendModelLabel(value) {
151
151
 
152
152
  export function weeklyQuotaObservations(snapshot = {}) {
153
153
  let observations = (snapshot.quotaObservations ?? [])
154
- .map((observation) => ({
155
- ...observation,
156
- timestampMs: finiteTimestamp(observation.timestamp),
157
- resetsAt: Number(observation.resetsAt),
158
- usedPercent: Number(observation.usedPercent),
159
- }))
154
+ .map((observation) => {
155
+ const timestampMs = finiteTimestamp(observation.timestamp);
156
+ const lastSeenAtMs = finiteTimestamp(observation.lastSeenAt);
157
+ return {
158
+ ...observation,
159
+ timestampMs,
160
+ observedThroughMs:
161
+ timestampMs === null
162
+ ? lastSeenAtMs
163
+ : Math.max(timestampMs, lastSeenAtMs ?? timestampMs),
164
+ resetsAt: Number(observation.resetsAt),
165
+ usedPercent: Number(observation.usedPercent),
166
+ };
167
+ })
160
168
  .filter(
161
169
  (observation) =>
162
170
  Number(observation.windowMinutes) === WEEK_MINUTES &&
@@ -277,6 +285,12 @@ export function normalizeQuotaTimeline(observations) {
277
285
  : Math.max(usedPercent, observation.usedPercent);
278
286
  normalized.push({
279
287
  ...observation,
288
+ observedThroughMs: Math.min(
289
+ Number.isFinite(observation.observedThroughMs)
290
+ ? observation.observedThroughMs
291
+ : observation.timestampMs,
292
+ nextFirstMs,
293
+ ),
280
294
  cycle,
281
295
  reset: !emitted && previousEpoch !== null,
282
296
  resetKind,
@@ -617,6 +631,7 @@ export function buildUsageTrend(snapshot = {}, bounds) {
617
631
  }
618
632
  points.push({
619
633
  timestampMs: observation.timestampMs,
634
+ observedThroughMs: observation.observedThroughMs,
620
635
  cycle: observation.cycle,
621
636
  usedPercent: observation.normalizedUsedPercent,
622
637
  remainingPercent: 100 - observation.normalizedUsedPercent,
@@ -645,15 +660,46 @@ export function buildUsageTrend(snapshot = {}, bounds) {
645
660
  (point) => point.timestampMs > startMs && point.timestampMs < endMs,
646
661
  ),
647
662
  );
648
- const lastPoint = displayPoints.at(-1);
649
- if (lastPoint) {
650
- displayPoints.push({
651
- ...lastPoint,
652
- timestampMs: endMs,
653
- observed: false,
654
- carried: true,
655
- });
663
+
664
+ // Repeated equal meter readings are compacted into an observed span. Extend
665
+ // each displayed cycle only through its last real sample; never synthesize a
666
+ // flat line through the unobserved remainder of the report range.
667
+ const sourcePointsByCycle = new Map();
668
+ for (const point of points) {
669
+ const cyclePoints = sourcePointsByCycle.get(point.cycle) ?? [];
670
+ cyclePoints.push(point);
671
+ sourcePointsByCycle.set(point.cycle, cyclePoints);
656
672
  }
673
+ const displayedCycles = new Set(displayPoints.map((point) => point.cycle));
674
+ for (const cycle of displayedCycles) {
675
+ const cyclePoints = sourcePointsByCycle.get(cycle) ?? [];
676
+ const displayedCyclePoints = displayPoints.filter(
677
+ (point) => point.cycle === cycle,
678
+ );
679
+ const lastPoint = displayedCyclePoints.at(-1);
680
+ if (!lastPoint || !cyclePoints.length) continue;
681
+ const observedThroughMs = Math.max(
682
+ ...cyclePoints.map((point) => point.observedThroughMs),
683
+ );
684
+ const nextResetMs = resets.find((reset) => reset.cycle === cycle + 1)
685
+ ?.timestampMs;
686
+ const crossesNextReset = Number.isFinite(nextResetMs) &&
687
+ observedThroughMs >= nextResetMs;
688
+ const endpointMs = Math.min(observedThroughMs, endMs);
689
+ if (!crossesNextReset && endpointMs > lastPoint.timestampMs) {
690
+ displayPoints.push({
691
+ ...lastPoint,
692
+ timestampMs: endpointMs,
693
+ observedThroughMs: endpointMs,
694
+ observed: true,
695
+ carried: false,
696
+ confirmation: true,
697
+ });
698
+ }
699
+ }
700
+ displayPoints.sort(
701
+ (left, right) => left.timestampMs - right.timestampMs || left.cycle - right.cycle,
702
+ );
657
703
 
658
704
  const hasUnattributed = methods.has("unattributed");
659
705
  let allocationMethod = "unavailable";
@@ -699,7 +745,7 @@ export function buildUsageTrend(snapshot = {}, bounds) {
699
745
  ).length,
700
746
  allocationMethod,
701
747
  observedThroughMs:
702
- [...points].reverse().find((point) => point.timestampMs < endMs)
748
+ [...displayPoints].reverse().find((point) => point.observed)
703
749
  ?.timestampMs ?? null,
704
750
  rateCardAsOf: snapshot.provenance?.rateCardAsOf ?? RATE_CARD_AS_OF,
705
751
  };
@@ -947,6 +947,18 @@ export function snapshotCacheIsFresh(
947
947
  );
948
948
  }
949
949
 
950
+ export function shouldCheckSourceFreshness(
951
+ options = {},
952
+ snapshotMtimeMs,
953
+ nowMs = Date.now(),
954
+ ) {
955
+ // PNG reports are expected to reflect every completed local call. Checking
956
+ // source mtimes is much cheaper than parsing the rollouts again; the full
957
+ // collector only runs when one of those sources actually changed.
958
+ return Boolean(options.view === "trend" && options.image) ||
959
+ !snapshotCacheIsFresh(snapshotMtimeMs, nowMs);
960
+ }
961
+
950
962
  function snapshotAgeLabel(ageMs) {
951
963
  if (ageMs < 60 * 1_000) return "now";
952
964
  const minutes = Math.floor(ageMs / (60 * 1_000));
@@ -1005,7 +1017,7 @@ async function loadSnapshot(options) {
1005
1017
  `Could not inspect snapshot ${safeDisplayLabel(options.input, "snapshot")}: ${safeErrorMessage(error, [options.input])}`,
1006
1018
  );
1007
1019
  }
1008
- if (snapshotCacheIsFresh(snapshotStat.mtimeMs)) {
1020
+ if (!shouldCheckSourceFreshness(options, snapshotStat.mtimeMs)) {
1009
1021
  return readSnapshot(options.input);
1010
1022
  }
1011
1023
 
@@ -1027,7 +1039,16 @@ async function loadSnapshot(options) {
1027
1039
  return readSnapshot(options.input);
1028
1040
  }
1029
1041
 
1030
- function render(options, snapshot, bounds, events, rows, allRows, freshness) {
1042
+ function render(
1043
+ options,
1044
+ snapshot,
1045
+ bounds,
1046
+ events,
1047
+ rows,
1048
+ allRows,
1049
+ freshness,
1050
+ reportTimeMs,
1051
+ ) {
1031
1052
  if (options.view === "trend") {
1032
1053
  if (options.image && options.cacheRate) {
1033
1054
  return renderCacheReportImage({
@@ -1044,7 +1065,7 @@ function render(options, snapshot, bounds, events, rows, allRows, freshness) {
1044
1065
  bounds,
1045
1066
  trend,
1046
1067
  days: options.trendDays,
1047
- options,
1068
+ options: { ...options, reportTimeMs },
1048
1069
  projectRows: allRows,
1049
1070
  });
1050
1071
  }
@@ -1169,6 +1190,10 @@ export async function run(options, { nowMs } = {}) {
1169
1190
  if (writingImage) {
1170
1191
  process.stderr.write(`Token Ledger: generating ${imageLabel} PNG…\n`);
1171
1192
  }
1193
+ const reportTimeMs = hasInjectedNow ? now.getTime() : Date.now();
1194
+ const verifiedSourceTimeMs = options.autoRefresh && !options.inputExplicit
1195
+ ? reportTimeMs
1196
+ : undefined;
1172
1197
  const output = render(
1173
1198
  options,
1174
1199
  snapshot,
@@ -1178,8 +1203,9 @@ export async function run(options, { nowMs } = {}) {
1178
1203
  allRows,
1179
1204
  snapshotFreshness(
1180
1205
  snapshot,
1181
- hasInjectedNow ? now.getTime() : Date.now(),
1206
+ reportTimeMs,
1182
1207
  ),
1208
+ verifiedSourceTimeMs,
1183
1209
  );
1184
1210
  if (writingImage) {
1185
1211
  await mkdir(dirname(outputPath), { recursive: true });
@@ -739,7 +739,10 @@ function rememberQuota(quotaMap, rateLimits, occurrence) {
739
739
  ].join("|");
740
740
  const candidate = {
741
741
  id: `quota-${hash(key)}`,
742
+ // Keep the first and last occurrence of an unchanged reading without
743
+ // storing every repeated provider sample in the snapshot.
742
744
  timestamp: occurrence.timestamp,
745
+ lastSeenAt: occurrence.timestamp,
743
746
  usedPercent,
744
747
  windowMinutes,
745
748
  resetsAt,
@@ -753,11 +756,21 @@ function rememberQuota(quotaMap, rateLimits, occurrence) {
753
756
  const current = quotaMap.get(key);
754
757
  if (
755
758
  !current ||
756
- (!current.originalLikely && candidate.originalLikely) ||
757
- (current.originalLikely === candidate.originalLikely &&
758
- candidate.timestamp < current.timestamp)
759
+ (!current.originalLikely && candidate.originalLikely)
759
760
  ) {
760
761
  quotaMap.set(key, candidate);
762
+ } else if (current.originalLikely === candidate.originalLikely) {
763
+ quotaMap.set(key, {
764
+ ...current,
765
+ timestamp:
766
+ candidate.timestamp < current.timestamp
767
+ ? candidate.timestamp
768
+ : current.timestamp,
769
+ lastSeenAt:
770
+ candidate.timestamp > current.lastSeenAt
771
+ ? candidate.timestamp
772
+ : current.lastSeenAt,
773
+ });
761
774
  }
762
775
  }
763
776
  }
@@ -1204,7 +1217,7 @@ function buildSnapshot(context, options, titles) {
1204
1217
  })
1205
1218
  .filter((quota) => {
1206
1219
  if (!options.since) return true;
1207
- return Date.parse(quota.timestamp) >= sinceMs;
1220
+ return Date.parse(quota.lastSeenAt) >= sinceMs;
1208
1221
  })
1209
1222
  .sort(
1210
1223
  (left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp),
@@ -1230,7 +1243,7 @@ function buildSnapshot(context, options, titles) {
1230
1243
  ...(accountWideWeekly.length ? accountWideWeekly : weeklyCandidates),
1231
1244
  ]
1232
1245
  .sort(
1233
- (left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp),
1246
+ (left, right) => Date.parse(right.lastSeenAt) - Date.parse(left.lastSeenAt),
1234
1247
  )[0];
1235
1248
  const weeklyStart = weekly
1236
1249
  ? (weekly.resetsAt - weekly.windowMinutes * 60) * 1_000
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tledger",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A local-only terminal dashboard for Codex token usage",
5
5
  "license": "MIT",
6
6
  "keywords": [