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),