From c6348ab2cc20fbb48b558c28ad087bc2013f2895 Mon Sep 17 00:00:00 2001 From: Anton Date: Tue, 21 Jul 2026 15:11:12 +0200 Subject: [PATCH] fix(cate.usage): stop calling the widest bucket "all time" Claude Code deletes transcripts past cleanupPeriodDays (30 by default), so ccusage only ever sees the retained window. The panel was summing that and labelling it "All time", which reads as lifetime spend when it is really the last 30 days. Rename Summary.allTime to Summary.retained, add UsageReport.coverageStart (earliest day with usage on disk), and label the tile "Since ". Hover explains the window and why older data is gone. --- extensions/cate.usage/README.md | 13 ++++++-- extensions/cate.usage/manifest.json | 2 +- extensions/cate.usage/src/public/app.ts | 16 ++++++++-- extensions/cate.usage/src/shape.test.ts | 40 ++++++++++++++++++++----- extensions/cate.usage/src/shape.ts | 23 ++++++++++++-- 5 files changed, 79 insertions(+), 15 deletions(-) diff --git a/extensions/cate.usage/README.md b/extensions/cate.usage/README.md index 558bf53..ef4cf06 100644 --- a/extensions/cate.usage/README.md +++ b/extensions/cate.usage/README.md @@ -1,4 +1,4 @@ -Agent usage and cost dashboard powered by ccusage. Reads the local Claude Code data (~/.claude) on the machine running the extension server and shows a stat strip for today, this week, this month, and all time, a daily cost/token timeline, and a per-model cost split. No data leaves the machine. +Agent usage and cost dashboard powered by ccusage. Reads the local Claude Code data (~/.claude) on the machine running the extension server and shows a stat strip for today, this week, this month, and everything still on disk, a daily cost/token timeline, and a per-model cost split. No data leaves the machine. # cate.usage @@ -6,7 +6,7 @@ A server-backed Cate extension that turns the machine's Claude Code usage logs into a single-screen dashboard panel (nothing scrolls): - A stat strip: cost and tokens for today, this ISO week, this calendar month, - and all time. + and the full retained window (labelled "Since ", never "all time"). - The timeline: a daily bar chart (cost or tokens, 30/60/90 day window) with hover details, flexing to the panel height. - A per-model chip strip under the chart: cost and cost share per model, full @@ -35,6 +35,15 @@ fails (offline machine), it falls back to ccusage's bundled table and the panel shows an "offline pricing" badge, since models newer than the pinned ccusage may then report zero cost. +### Retention: there is no "all time" + +Claude Code deletes its own transcripts once they pass `cleanupPeriodDays` +(30 by default, set in `~/.claude/settings.json`). ccusage can only price what +survives, so the widest bucket this panel can honestly show is "everything +still on disk" — it is labelled `Since `, and the report +carries that day as `coverageStart`. On a default install that window is the +last 30 days no matter how long the agent has actually been in use. + Note: usage data lives on the machine where the extension server runs. For a remote workspace that is the remote host, so the dashboard shows that machine's agent usage, not the local laptop's. If no Claude Code data exists there, the diff --git a/extensions/cate.usage/manifest.json b/extensions/cate.usage/manifest.json index c9d987a..d32f7d6 100644 --- a/extensions/cate.usage/manifest.json +++ b/extensions/cate.usage/manifest.json @@ -2,7 +2,7 @@ "id": "cate.usage", "name": "Agent Usage", "version": "1.1.0", - "description": "Agent usage and cost dashboard powered by ccusage. Reads the local Claude Code data (~/.claude) on the machine running the extension server and shows cost and tokens for today, this week, this month, and all time, a daily cost/token timeline, and a per-model cost split. No data leaves the machine.", + "description": "Agent usage and cost dashboard powered by ccusage. Reads the local Claude Code data (~/.claude) on the machine running the extension server and shows cost and tokens for today, this week, this month, and everything still on disk, a daily cost/token timeline, and a per-model cost split. No data leaves the machine.", "panels": [ { "id": "dashboard", diff --git a/extensions/cate.usage/src/public/app.ts b/extensions/cate.usage/src/public/app.ts index 6e3f20d..d4aa89d 100644 --- a/extensions/cate.usage/src/public/app.ts +++ b/extensions/cate.usage/src/public/app.ts @@ -436,8 +436,9 @@ function renderChart(container: HTMLElement, points: DailyPoint[], which: Metric // --- sections --------------------------------------------------------------------- -function stat(label: string, period: PeriodSummary): HTMLElement { +function stat(label: string, period: PeriodSummary, hint?: string): HTMLElement { const wrap = el('div', 'us-stat') + if (hint) wrap.title = hint wrap.appendChild(el('span', 'us-stat__label', label)) const row = el('div', 'us-stat__row') row.appendChild(el('span', 'us-stat__value', fmtUsd(period.cost))) @@ -578,7 +579,18 @@ function render(): void { statsEl.appendChild(stat('Today', report.summary.today)) statsEl.appendChild(stat('Week', report.summary.thisWeek)) statsEl.appendChild(stat('Month', report.summary.thisMonth)) - statsEl.appendChild(stat('All time', report.summary.allTime)) + // NOT "all time": agents purge their own transcripts (Claude Code drops + // ~/.claude/projects entries past `cleanupPeriodDays`, 30 by default), so + // this tile covers only what is still on disk. Label it with that window. + statsEl.appendChild( + stat( + report.coverageStart ? `Since ${fmtDayLabel(report.coverageStart)}` : 'On disk', + report.summary.retained, + report.coverageStart + ? `Everything still on disk: ${report.coverageStart} to ${report.today}. Not all-time — Claude Code deletes transcripts older than cleanupPeriodDays (30 by default), and usage from before ${report.coverageStart} is already gone.` + : undefined, + ), + ) // Hero timeline card: metric + window toggles, flexing chart, model footer. const metricSeg = segmented(['cost', 'tokens'], metric, (m) => (m === 'cost' ? 'Cost' : 'Tokens'), (m) => { diff --git a/extensions/cate.usage/src/shape.test.ts b/extensions/cate.usage/src/shape.test.ts index a120179..e89fad0 100644 --- a/extensions/cate.usage/src/shape.test.ts +++ b/extensions/cate.usage/src/shape.test.ts @@ -143,24 +143,24 @@ describe('summarize', () => { expect(s.today.cost).toBe(3) expect(s.thisWeek.cost).toBe(50) // 40 + 7 + 3 expect(s.thisMonth.cost).toBe(10) // 7 + 3 - expect(s.allTime.cost).toBe(150) - expect(s.allTime.days).toBe(4) + expect(s.retained.cost).toBe(150) + expect(s.retained.days).toBe(4) }) it('sums token totals across all four buckets', () => { const s = summarize(daily, today) expect(s.today.totalTokens).toBe(10_000) - expect(s.allTime.totalTokens).toBe(40_000) - expect(s.allTime.inputTokens).toBe(4000) - expect(s.allTime.outputTokens).toBe(8000) + expect(s.retained.totalTokens).toBe(40_000) + expect(s.retained.inputTokens).toBe(4000) + expect(s.retained.outputTokens).toBe(8000) }) it('ignores rows dated after today and handles empty input', () => { const s = summarize([day('2026-07-05', { totalCost: 99 })], today) - expect(s.allTime.cost).toBe(0) + expect(s.retained.cost).toBe(0) const empty = summarize([], today) expect(empty.today.cost).toBe(0) - expect(empty.allTime.days).toBe(0) + expect(empty.retained.days).toBe(0) }) }) @@ -337,6 +337,30 @@ describe('buildReport', () => { expect(report.pricingSource).toBe('online') }) + it('reports the retained window rather than implying all-time coverage', () => { + // MULTI_MODEL_DAY is 2026-06-03; the retained bucket starts there, not at + // whenever the user actually started using the agent. + const report = buildReport(raw, '2026-07-04', { days: 30 }) + expect(report.coverageStart).toBe('2026-06-03') + expect(report.summary.retained.days).toBe(2) + }) + + it('has no coverage start when nothing is on disk', () => { + const report = buildReport( + { claudePaths: ['/home/user/.claude'], pricingSource: 'online', daily: [], sessions: [], monthly: [] }, + '2026-07-04', + ) + expect(report.coverageStart).toBeNull() + }) + + it('ignores future-dated rows when picking the coverage start', () => { + const report = buildReport( + { ...raw, daily: [day('2099-01-01', { totalCost: 1 }), ...raw.daily] }, + '2026-07-04', + ) + expect(report.coverageStart).toBe('2026-06-03') + }) + it('flags the no-claude-data empty state', () => { const report = buildReport( { claudePaths: [], pricingSource: 'none', daily: [], sessions: [], monthly: [] }, @@ -345,7 +369,7 @@ describe('buildReport', () => { expect(report.available).toBe(false) expect(report.reason).toBe('no-claude-data') expect(report.daily).toHaveLength(30) // zero-filled, chart-safe - expect(report.summary.allTime.cost).toBe(0) + expect(report.summary.retained.cost).toBe(0) }) it('flags the no-usage-entries empty state when a data dir exists but is empty', () => { diff --git a/extensions/cate.usage/src/shape.ts b/extensions/cate.usage/src/shape.ts index 6dba5ba..61905fd 100644 --- a/extensions/cate.usage/src/shape.ts +++ b/extensions/cate.usage/src/shape.ts @@ -65,7 +65,11 @@ export interface Summary { today: PeriodSummary thisWeek: PeriodSummary thisMonth: PeriodSummary - allTime: PeriodSummary + /** Everything on disk. NOT all-time: agents purge their own transcripts + * (Claude Code deletes ~/.claude/projects entries older than + * `cleanupPeriodDays`, 30 by default), so this covers only what survived. + * Pair it with UsageReport.coverageStart when labelling. */ + retained: PeriodSummary } export interface DailyPoint { @@ -104,6 +108,9 @@ export interface UsageReport { reason?: 'no-claude-data' | 'no-usage-entries' generatedAt: string today: string + /** First day with usage on disk, or null when there is none. The summary's + * `retained` bucket spans coverageStart..today and nothing earlier. */ + coverageStart: string | null claudePaths: string[] pricingSource: 'online' | 'offline' | 'none' summary: Summary @@ -172,10 +179,21 @@ export function summarize(daily: DailyRow[], today: string): Summary { today: sumPeriod(upToToday.filter((r) => r.date === today)), thisWeek: sumPeriod(upToToday.filter((r) => r.date >= week)), thisMonth: sumPeriod(upToToday.filter((r) => r.date >= month)), - allTime: sumPeriod(upToToday), + retained: sumPeriod(upToToday), } } +/** Earliest day with usage on disk (null when there is none) — the real start + * of the retained window, which the panel labels instead of "all time". */ +export function coverageStartOf(daily: DailyRow[], today: string): string | null { + let first: string | null = null + for (const row of daily) { + if (row.date > today) continue + if (first === null || row.date < first) first = row.date + } + return first +} + /** The last `days` calendar days ending at `today`, zero-filled so the chart * has one point per day even when nothing ran. */ export function dailySeries(daily: DailyRow[], today: string, days: number): DailyPoint[] { @@ -306,6 +324,7 @@ export function buildReport(raw: RawUsage, today: string, opts: ReportOptions = ...(available ? {} : { reason: hasPaths ? ('no-usage-entries' as const) : ('no-claude-data' as const) }), generatedAt: new Date().toISOString(), today, + coverageStart: coverageStartOf(raw.daily, today), claudePaths: raw.claudePaths, pricingSource: raw.pricingSource, summary: summarize(raw.daily, today),