@hank-warren/pi-stats 0.4.1 → 0.4.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @hank-warren/pi-stats
2
2
 
3
+ ## 0.4.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Label the month the heatmap grid ends in. The axis keyed each month change off its column's Monday and dropped a label that no longer fit at the right edge, so the current month was missing from the axis on 56% of days. Columns are now named for the month holding their Thursday, and the rightmost label is clamped left instead of dropped.
8
+
3
9
  ## 0.4.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -99,3 +99,7 @@ Sessions remain authoritative. For fast repeat opens, the extension stores one d
99
99
  The location follows `PI_CODING_AGENT_DIR`. The cache contains file stat identity, session IDs/timestamps/working directories, model names, tool names with call and error counts, usage counters, and de-duplication fingerprints. It never stores prompts, responses, tool arguments, tool output, or other conversation content. It is written atomically with mode `0600` and can be deleted at any time; the next `/stats` invocation rebuilds it.
100
100
 
101
101
  No network or billing API is used. Displayed costs are the estimates already recorded by Pi.
102
+
103
+ ## Changelog
104
+
105
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
package/cache.ts CHANGED
@@ -11,7 +11,7 @@ const SKIP_DIRECTORIES = new Set(["subagent-artifacts"]);
11
11
  /** Extensions record out-of-transcript model usage in <agentDir>/<extension>/usage.jsonl. */
12
12
  const USAGE_SIDECAR_NAMES = new Set(["usage.jsonl", "usage.jsonl.1"]);
13
13
 
14
- export interface ScanOptions {
14
+ interface ScanOptions {
15
15
  agentDir: string;
16
16
  activeSessionDir?: string;
17
17
  env?: NodeJS.ProcessEnv;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-stats",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "A compact /stats dashboard for all-time Pi token usage, including persisted subagents.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-stats#readme",
25
25
  "engines": {
26
- "node": ">=18.0.0"
26
+ "node": ">=22.19.0"
27
27
  },
28
28
  "pi": {
29
29
  "extensions": [
package/stats.ts CHANGED
@@ -43,7 +43,7 @@ function firstNumber(source: Record<string, unknown>, names: string[]): number {
43
43
  return 0;
44
44
  }
45
45
 
46
- export function normalizeUsage(value: unknown): UsageTotals | undefined {
46
+ function normalizeUsage(value: unknown): UsageTotals | undefined {
47
47
  const usage = object(value);
48
48
  if (!usage) return undefined;
49
49
  const cost = object(usage.cost);
@@ -375,7 +375,7 @@ function hasPersistedSubagentChild(record: UsageRecord, sessionPaths: ReadonlySe
375
375
  return record.toolName === "subagent" && Boolean(record.childSessionFiles?.some((file) => sessionPaths.has(resolve(file))));
376
376
  }
377
377
 
378
- export function deduplicateToolCalls(sessions: readonly SessionRecord[]): ToolCallRecord[] {
378
+ function deduplicateToolCalls(sessions: readonly SessionRecord[]): ToolCallRecord[] {
379
379
  const seen = new Set<string>();
380
380
  const result: ToolCallRecord[] = [];
381
381
  for (const session of sessions) {
package/types.ts CHANGED
@@ -8,7 +8,7 @@ export interface UsageTotals {
8
8
  calls: number;
9
9
  }
10
10
 
11
- export type SessionSource = "main" | "subagent";
11
+ type SessionSource = "main" | "subagent";
12
12
 
13
13
  export interface UsageRecord {
14
14
  fingerprint: string;
package/widget.ts CHANGED
@@ -10,12 +10,12 @@ import {
10
10
  type UsageTotals,
11
11
  } from "./types.ts";
12
12
 
13
- export type WidgetState =
13
+ type WidgetState =
14
14
  | { kind: "loading"; completed: number; total: number }
15
15
  | { kind: "error"; message: string }
16
16
  | { kind: "ready"; snapshot: StatsSnapshot };
17
17
 
18
- export interface StatsWidgetOptions {
18
+ interface StatsWidgetOptions {
19
19
  theme: Theme;
20
20
  requestRender: () => void;
21
21
  onClose: () => void;
@@ -37,7 +37,7 @@ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "
37
37
  const RANGES: StatsRange[] = ["all", "7d", "30d"];
38
38
  const RANGE_LABELS: Record<StatsRange, string> = { all: "All time", "7d": "Last 7 days", "30d": "Last 30 days" };
39
39
 
40
- export type StatsTab = "overview" | "models" | "tools" | "projects";
40
+ type StatsTab = "overview" | "models" | "tools" | "projects";
41
41
  const TABS: StatsTab[] = ["overview", "models", "tools", "projects"];
42
42
  const TAB_LABELS: Record<StatsTab, string> = { overview: "Overview", models: "Models", tools: "Tools", projects: "Projects" };
43
43
 
@@ -76,6 +76,45 @@ function localDay(time: number): string {
76
76
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
77
77
  }
78
78
 
79
+ /**
80
+ * The heatmap's month label row: one character per week column, `weeks` wide.
81
+ *
82
+ * A column belongs to the month holding its Thursday, so it is named for whichever month
83
+ * owns four or more of its seven days. Labels are placed right to left because only the
84
+ * right edge is constrained: each one sits at its month's first column unless its
85
+ * neighbour crowds it, and shifts left just enough to stay clear.
86
+ */
87
+ export function monthAxis(start: Date, weeks: number): string {
88
+ const monthOf = (week: number): number => {
89
+ const thursday = new Date(start);
90
+ thursday.setDate(thursday.getDate() + week * 7 + 3);
91
+ return thursday.getMonth();
92
+ };
93
+ /** Three characters plus a space, so neighbouring labels never touch. */
94
+ const spacing = 4;
95
+
96
+ const boundaries: { column: number; month: number }[] = [];
97
+ for (let week = 0; week < weeks; week++) {
98
+ const month = monthOf(week);
99
+ if (month !== monthOf(week - 1)) boundaries.push({ column: week, month });
100
+ }
101
+ // A grid too short to leave its month has no boundary to label, so name it after its
102
+ // last column like every other grid.
103
+ if (boundaries.length === 0) boundaries.push({ column: 0, month: monthOf(weeks - 1) });
104
+
105
+ const placed: { column: number; month: number }[] = [];
106
+ let rightmost = weeks - 3;
107
+ for (let index = boundaries.length - 1; index >= 0 && rightmost >= 0; index--) {
108
+ const column = Math.min(boundaries[index]!.column, rightmost);
109
+ placed.unshift({ column, month: boundaries[index]!.month });
110
+ rightmost = column - spacing;
111
+ }
112
+
113
+ let row = "";
114
+ for (const { column, month } of placed) row = row.padEnd(column, " ") + MONTHS[month]!;
115
+ return row.padEnd(weeks, " ").slice(0, weeks);
116
+ }
117
+
79
118
  function padRight(value: string, width: number): string {
80
119
  const clipped = truncateToWidth(value, Math.max(0, width), "");
81
120
  return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
@@ -424,22 +463,7 @@ export class StatsWidget implements Component {
424
463
  const gap = Math.max(1, width - visibleWidth(heading) - visibleWidth(legend) - 1);
425
464
  const lines = [`${heading}${" ".repeat(gap)}${theme.fg("dim", legend)}`];
426
465
 
427
- let months = " ".repeat(HEATMAP_LABEL_WIDTH);
428
- let lastLabel = -4;
429
- for (let week = 0; week < weeks; week++) {
430
- const date = new Date(start);
431
- date.setDate(date.getDate() + week * 7);
432
- const previous = new Date(date);
433
- previous.setDate(previous.getDate() - 7);
434
- if ((week === 0 || date.getMonth() !== previous.getMonth()) && week - lastLabel >= 4 && week + 3 <= weeks) {
435
- months += MONTHS[date.getMonth()]!;
436
- lastLabel = week;
437
- week += 2;
438
- } else {
439
- months += " ";
440
- }
441
- }
442
- lines.push(theme.fg("dim", months));
466
+ lines.push(theme.fg("dim", " ".repeat(HEATMAP_LABEL_WIDTH) + monthAxis(start, weeks)));
443
467
 
444
468
  for (let weekday = 0; weekday < 7; weekday++) {
445
469
  const label = weekday === 0 ? "Mon" : weekday === 2 ? "Wed" : weekday === 4 ? "Fri" : " ";