claude-usage-dashboard 1.4.2 → 1.5.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
@@ -3,9 +3,9 @@
3
3
  [![npm version](https://img.shields.io/npm/v/claude-usage-dashboard)](https://www.npmjs.com/package/claude-usage-dashboard)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/claude-usage-dashboard)](https://www.npmjs.com/package/claude-usage-dashboard)
5
5
 
6
- > Find out what your Claude Code subscription is actually worth in API costs — across every machine you own.
6
+ > Find out what your Claude Code subscription is actually worth in API costs — and whether your quota is shrinking — across every machine you own.
7
7
 
8
- Your $200/month Max plan might be consuming **$15,000+/month** in API-equivalent value. This dashboard shows you exactly how much — per project, per session, per model, and across all your machines in one unified view. One command to start. Completely local.
8
+ Your $200/month Max plan might be consuming **$15,000+/month** in API-equivalent value. This dashboard shows you exactly how much — per project, per session, per model, and across all your machines in one unified view. It also tracks your 7-day quota cycles, projects token usage at full utilization, and keeps history so you can spot if your effective quota quietly changed. One command to start. Completely local.
9
9
 
10
10
  ```bash
11
11
  npx claude-usage-dashboard
@@ -15,31 +15,39 @@ npx claude-usage-dashboard
15
15
 
16
16
  ## What You'll See
17
17
 
18
- ### See Every Machine in One Place
18
+ ### :computer: See Every Machine in One Place
19
19
 
20
20
  Use Claude on a laptop, a desktop, and a work machine? Most dashboards only see the one they're running on. This one syncs across all of them.
21
21
 
22
22
  Point it at any shared folder — Google Drive, Dropbox, OneDrive, a NAS, an rsync target — and every machine's logs roll up into one unified view. No server. No account. Just a folder you already have.
23
23
 
24
- ### Know What You're Spending
24
+ ### :dollar: Know What You're Spending
25
25
 
26
26
  Real-time projected API cost at your current usage rate — weekly and monthly. At 5% quota utilization, you might be burning through **$3,600/week** equivalent. The dashboard calculates this from your actual quota window, not estimates.
27
27
 
28
- ### Track Your Quota in Real Time
28
+ ### :bar_chart: Track Your Quota in Real Time
29
29
 
30
30
  Live utilization gauges for 5-hour, 7-day, and per-model quotas pulled directly from the Anthropic API. Auto-detects your plan tier (Pro / Max 5x / Max 20x). Never get throttled by surprise again.
31
31
 
32
- ### Find What's Eating Your Tokens
32
+ ### :chart_with_upwards_trend: Track Your Quota Across Cycles
33
+
34
+ Anthropic doesn't publish exact token limits, and those limits can change without notice. The Quota Cycle History section gives you a running record of each 7-day cycle so you can see exactly how much you got and whether it stayed consistent.
35
+
36
+ For each cycle, the dashboard projects what your full token quota would be at 100% utilization — broken down by Input, Output, Cache Read, Cache Write, and Total. A horizontal bar chart overlays actual vs. projected per cycle, and the history table highlights projected tokens and cost with a delta column showing the change versus the previous cycle.
37
+
38
+ That delta is the key signal: if your projected quota drops from one cycle to the next without you changing how you work, something changed on the platform side. Track your actual consumption week-over-week so you have real numbers when something feels off — not just a gut feeling. Up to 10 past cycles are retained across all your machines.
39
+
40
+ ### :mag: Find What's Eating Your Tokens
33
41
 
34
42
  Per-project and per-session cost breakdowns show exactly where your usage goes. Sortable session table with cost, duration, and full token breakdown. Spot the expensive sessions instantly.
35
43
 
36
- ### Understand Your Cache Efficiency
44
+ ### :zap: Understand Your Cache Efficiency
37
45
 
38
46
  You'll probably discover that ~95% of your tokens are cache reads at 1/10th the cost. The dashboard visualizes cache read vs. cache write vs. uncached requests so you can see how efficiently Claude is using context.
39
47
 
40
48
  ### Everything Else
41
49
 
42
- **Multi-machine sync** — aggregate usage across all your devices via a shared folder · Hourly/daily/weekly/monthly token trends · Dollar and token toggle · Model distribution across Opus/Sonnet/Haiku · Active hours heatmap · Auto-refresh (30s) · Persistent filters via localStorage · Dark theme
50
+ **Multi-machine sync** — aggregate usage across all your devices via a shared folder · **Quota cycle tracking** — monitors each 7-day reset window, projects usage at 100% utilization, logs history to detect quota changes · Hourly/daily/weekly/monthly token trends · Dollar and token toggle · Model distribution across Opus/Sonnet/Haiku · Active hours heatmap · Auto-refresh (30s) · Persistent filters via localStorage · Dark theme
43
51
 
44
52
  ## Quick Start
45
53
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "claude-usage-dashboard",
3
- "version": "1.4.2",
4
- "description": "Claude Code usage dashboard with multi-machine sync — token costs, quota tracking, cache efficiency, across all your devices",
3
+ "version": "1.5.1",
4
+ "description": "Claude Code usage dashboard — token costs, quota cycle tracking, cache efficiency, multi-machine sync across all your devices",
5
5
  "main": "server/index.js",
6
6
  "bin": {
7
7
  "claude-usage-dashboard": "bin/cli.cjs"
@@ -259,7 +259,29 @@ body {
259
259
  }
260
260
  #quota-section .chart-container { min-height: auto; }
261
261
 
262
+ .delta-positive { color: var(--green); }
263
+ .delta-negative { color: var(--red); }
264
+ .table-container th.col-highlight,
265
+ .table-container td.col-highlight { color: var(--amber); font-weight: 600; }
266
+
267
+ .cycle-summary {
268
+ font-size: 12px;
269
+ font-weight: 400;
270
+ color: var(--amber);
271
+ margin-left: 8px;
272
+ }
273
+
274
+ .quota-cycles-layout {
275
+ display: grid;
276
+ grid-template-columns: 300px 1fr;
277
+ gap: 12px;
278
+ align-items: start;
279
+ }
280
+ .quota-cycles-chart { min-height: 160px; position: relative; }
281
+ .quota-cycles-table-wrap { overflow-x: auto; }
282
+
262
283
  @media (max-width: 768px) {
263
284
  .summary-cards { grid-template-columns: repeat(2, 1fr); }
264
285
  .chart-row-3 { grid-template-columns: 1fr; }
286
+ .quota-cycles-layout { grid-template-columns: 1fr; }
265
287
  }
package/public/index.html CHANGED
@@ -50,6 +50,21 @@
50
50
  <div id="chart-quota" class="chart-container"></div>
51
51
  </section>
52
52
 
53
+ <section class="chart-section" id="quota-cycles-section">
54
+ <div class="chart-header">
55
+ <h2>Quota Cycle History <span id="cycle-projection-summary" class="cycle-summary"></span></h2>
56
+ <div class="granularity-toggle" id="cycle-model-toggle">
57
+ <button data-cycle-model="overall" class="active">Overall</button>
58
+ <button data-cycle-model="opus">Opus</button>
59
+ <button data-cycle-model="sonnet">Sonnet</button>
60
+ </div>
61
+ </div>
62
+ <div class="quota-cycles-layout">
63
+ <div id="chart-quota-cycles" class="quota-cycles-chart"></div>
64
+ <div id="quota-cycles-table" class="table-container quota-cycles-table-wrap"></div>
65
+ </div>
66
+ </section>
67
+
53
68
  <section class="chart-section">
54
69
  <div class="chart-header">
55
70
  <h2>Token Consumption Trend</h2>
package/public/js/api.js CHANGED
@@ -14,3 +14,4 @@ export async function fetchCache(params = {}) { return (await fetch(`${BASE}/cac
14
14
  export async function fetchStatus() { return (await fetch(`${BASE}/status`)).json(); }
15
15
  export async function fetchQuota() { return (await fetch(`${BASE}/quota`)).json(); }
16
16
  export async function fetchSubscription() { return (await fetch(`${BASE}/subscription`)).json(); }
17
+ export async function fetchQuotaCycles() { return (await fetch(`${BASE}/quota-cycles`)).json(); }
package/public/js/app.js CHANGED
@@ -1,4 +1,4 @@
1
- import { fetchUsage, fetchModels, fetchProjects, fetchSessions, fetchCost, fetchCache, fetchStatus, fetchQuota, fetchSubscription } from './api.js';
1
+ import { fetchUsage, fetchModels, fetchProjects, fetchSessions, fetchCost, fetchCache, fetchStatus, fetchQuota, fetchSubscription, fetchQuotaCycles } from './api.js';
2
2
  import { initDatePicker } from './components/date-picker.js';
3
3
  import { initPlanSelector } from './components/plan-selector.js';
4
4
  import { renderTokenTrend } from './charts/token-trend.js';
@@ -8,6 +8,7 @@ import { renderCacheEfficiency } from './charts/cache-efficiency.js';
8
8
  import { renderProjectDistribution } from './charts/project-distribution.js';
9
9
  import { renderSessionTable } from './charts/session-stats.js';
10
10
  import { renderQuotaGauges } from './charts/quota-gauge.js';
11
+ import { renderQuotaCycles } from './charts/quota-cycles.js';
11
12
 
12
13
  const state = {
13
14
  dateRange: { from: null, to: null },
@@ -19,6 +20,7 @@ const state = {
19
20
  sessionPage: 1,
20
21
  sessionProject: '',
21
22
  autoRefresh: localStorage.getItem('autoRefresh') !== 'false',
23
+ cycleModel: 'overall',
22
24
  autoRefreshInterval: 30,
23
25
  _refreshTimer: null,
24
26
  quotaRefreshInterval: 120,
@@ -83,6 +85,16 @@ async function loadQuota() {
83
85
  });
84
86
  const el = document.getElementById('quota-last-updated');
85
87
  if (el && data.lastFetched) el.textContent = `Updated ${new Date(data.lastFetched).toLocaleTimeString()} ${getTimezoneAbbr()}`;
88
+ loadQuotaCyclesData();
89
+ } catch { /* silently degrade */ }
90
+ }
91
+
92
+ async function loadQuotaCyclesData() {
93
+ try {
94
+ const data = await fetchQuotaCycles();
95
+ renderQuotaCycles(document.getElementById('chart-quota-cycles'), data, {
96
+ modelKey: state.cycleModel,
97
+ });
86
98
  } catch { /* silently degrade */ }
87
99
  }
88
100
 
@@ -273,6 +285,16 @@ function init() {
273
285
  }
274
286
  });
275
287
 
288
+ document.getElementById('cycle-model-toggle').addEventListener('click', (e) => {
289
+ if (e.target.tagName === 'BUTTON') {
290
+ state.cycleModel = e.target.dataset.cycleModel;
291
+ document.querySelectorAll('#cycle-model-toggle button').forEach(btn => {
292
+ btn.classList.toggle('active', btn.dataset.cycleModel === state.cycleModel);
293
+ });
294
+ loadQuotaCyclesData();
295
+ }
296
+ });
297
+
276
298
  // Auto-detect subscription tier
277
299
  fetchSubscription().then(info => {
278
300
  if (info.plan) {
@@ -0,0 +1,209 @@
1
+ const fmt = (n) => {
2
+ if (n == null) return '—';
3
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
4
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
5
+ return n.toString();
6
+ };
7
+
8
+ const fmtCost = (n) => n == null ? '—' : `$${n.toFixed(2)}`;
9
+
10
+ const fmtDate = (iso) => {
11
+ const d = new Date(iso);
12
+ return `${d.getMonth() + 1}/${d.getDate()}`;
13
+ };
14
+
15
+ function getModelData(cycle, modelKey) {
16
+ if (modelKey === 'overall') return cycle.overall;
17
+ return cycle.models?.[modelKey] || { utilization: 0, actualTokens: 0, projectedTokensAt100: null, actualCost: 0, projectedCostAt100: null, tokens: { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 } };
18
+ }
19
+
20
+ const MAX_DISPLAY_CYCLES = 10;
21
+
22
+ export function renderQuotaCycles(container, data, { modelKey = 'overall' } = {}) {
23
+ if (!container) return;
24
+
25
+ const hasModelData = data.currentCycle &&
26
+ ((data.currentCycle.models?.opus?.utilization > 0) ||
27
+ (data.currentCycle.models?.sonnet?.utilization > 0));
28
+
29
+ // --- Model toggle: grey out when no data ---
30
+ const toggleEl = document.getElementById('cycle-model-toggle');
31
+ if (toggleEl) {
32
+ toggleEl.querySelectorAll('button[data-cycle-model="opus"], button[data-cycle-model="sonnet"]').forEach(btn => {
33
+ btn.disabled = !hasModelData;
34
+ });
35
+ }
36
+
37
+ // --- Inline projection summary in header ---
38
+ const summaryEl = document.getElementById('cycle-projection-summary');
39
+ if (summaryEl) {
40
+ summaryEl.textContent = '';
41
+ }
42
+
43
+ // --- Bar Chart (compact) ---
44
+ container.innerHTML = '';
45
+
46
+ const allCycles = [];
47
+ if (data.history) allCycles.push(...[...data.history].reverse());
48
+ if (data.currentCycle) allCycles.push(data.currentCycle);
49
+
50
+ if (allCycles.length === 0) {
51
+ container.innerHTML = '<div style="color:#64748b;text-align:center;padding:20px;font-size:12px">No cycle data yet.</div>';
52
+ return;
53
+ }
54
+
55
+ const displayAll = allCycles.slice(-MAX_DISPLAY_CYCLES);
56
+
57
+ const chartData = displayAll.map(c => {
58
+ const d = getModelData(c, modelKey);
59
+ return {
60
+ label: `${fmtDate(c.start)}–${fmtDate(c.resets_at)}`,
61
+ actual: d.actualTokens,
62
+ projected: d.projectedTokensAt100,
63
+ isCurrent: c === data.currentCycle,
64
+ };
65
+ });
66
+
67
+ // Horizontal bar chart
68
+ const rowHeight = 28;
69
+ const margin = { top: 20, right: 50, bottom: 6, left: 70 };
70
+ const width = container.clientWidth - margin.left - margin.right;
71
+ const height = chartData.length * rowHeight;
72
+
73
+ const svg = d3.select(container).append('svg')
74
+ .attr('width', width + margin.left + margin.right)
75
+ .attr('height', height + margin.top + margin.bottom)
76
+ .append('g').attr('transform', `translate(${margin.left},${margin.top})`);
77
+
78
+ const y0 = d3.scaleBand().domain(chartData.map(d => d.label)).range([0, height]).padding(0.25);
79
+ const maxVal = d3.max(chartData, d => Math.max(d.actual, d.projected || 0)) || 1;
80
+ const x = d3.scaleLinear().domain([0, maxVal * 1.1]).range([0, width]);
81
+
82
+ // Axes
83
+ svg.append('g')
84
+ .call(d3.axisLeft(y0).tickSize(0))
85
+ .selectAll('text').attr('fill', '#94a3b8').style('font-size', '9px');
86
+ svg.append('g').attr('transform', `translate(0,${height})`)
87
+ .call(d3.axisBottom(x).ticks(4).tickFormat(d => fmt(d)))
88
+ .selectAll('text').attr('fill', '#94a3b8').style('font-size', '9px');
89
+ svg.selectAll('.domain, .tick line').attr('stroke', '#334155');
90
+
91
+ const barH = Math.min(y0.bandwidth() / 2.5, 12);
92
+
93
+ // Projected bars (behind, semi-transparent)
94
+ svg.selectAll('.bar-projected').data(chartData.filter(d => d.projected != null))
95
+ .join('rect').attr('class', 'bar-projected')
96
+ .attr('x', 0)
97
+ .attr('width', d => x(d.projected))
98
+ .attr('y', d => y0(d.label) + y0.bandwidth() / 2 - barH)
99
+ .attr('height', barH * 2)
100
+ .attr('fill', '#f59e0b').attr('opacity', 0.2)
101
+ .attr('rx', 2);
102
+
103
+ // Tooltip
104
+ const tooltip = d3.select(container).append('div')
105
+ .attr('class', 'd3-tooltip')
106
+ .style('opacity', 0);
107
+
108
+ function showTip(event, d) {
109
+ const proj = d.projected != null ? fmt(d.projected) : '—';
110
+ const rect = container.getBoundingClientRect();
111
+ tooltip.html(`<strong>${d.label}</strong><br>Actual: ${fmt(d.actual)}<br>Proj@100%: ${proj}`)
112
+ .style('opacity', 1)
113
+ .style('left', (event.clientX - rect.left + 12) + 'px')
114
+ .style('top', (event.clientY - rect.top - 10) + 'px');
115
+ }
116
+ function hideTip() { tooltip.style('opacity', 0); }
117
+
118
+ // Actual bars (front)
119
+ svg.selectAll('.bar-actual').data(chartData)
120
+ .join('rect').attr('class', 'bar-actual')
121
+ .attr('x', 0)
122
+ .attr('width', d => x(d.actual))
123
+ .attr('y', d => y0(d.label) + y0.bandwidth() / 2 - barH / 2)
124
+ .attr('height', barH)
125
+ .attr('fill', d => d.isCurrent ? '#3b82f6' : '#60a5fa')
126
+ .attr('rx', 2);
127
+
128
+ // Hover areas (full row for easy targeting)
129
+ svg.selectAll('.bar-hover').data(chartData)
130
+ .join('rect').attr('class', 'bar-hover')
131
+ .attr('x', 0)
132
+ .attr('width', width)
133
+ .attr('y', d => y0(d.label))
134
+ .attr('height', y0.bandwidth())
135
+ .attr('fill', 'transparent')
136
+ .style('cursor', 'pointer')
137
+ .on('mousemove', showTip)
138
+ .on('mouseleave', hideTip);
139
+
140
+ // Compact legend
141
+ const legend = svg.append('g').attr('transform', `translate(0, -8)`);
142
+ legend.append('rect').attr('width', 8).attr('height', 8).attr('fill', '#60a5fa').attr('rx', 1);
143
+ legend.append('text').attr('x', 10).attr('y', 7).text('Actual').attr('fill', '#64748b').style('font-size', '9px');
144
+ legend.append('rect').attr('x', 50).attr('width', 8).attr('height', 8).attr('fill', '#f59e0b').attr('opacity', 0.4).attr('rx', 1);
145
+ legend.append('text').attr('x', 60).attr('y', 7).text('Proj@100%').attr('fill', '#64748b').style('font-size', '9px');
146
+
147
+ // --- History Table ---
148
+ const tableEl = document.getElementById('quota-cycles-table');
149
+ if (!tableEl) return;
150
+ tableEl.innerHTML = '';
151
+
152
+ const table = document.createElement('table');
153
+ const thead = document.createElement('thead');
154
+ thead.innerHTML = `<tr>
155
+ <th>Cycle</th>
156
+ <th class="align-right">Util%</th>
157
+ <th class="align-right">In</th>
158
+ <th class="align-right">Out</th>
159
+ <th class="align-right">CR</th>
160
+ <th class="align-right">CW</th>
161
+ <th class="align-right">Total</th>
162
+ <th class="align-right">Excl CR</th>
163
+ <th class="align-right">Cost</th>
164
+ <th class="align-right col-highlight">Proj Tokens</th>
165
+ <th class="align-right col-highlight">Proj Cost</th>
166
+ <th class="align-right">\u0394 Prev</th>
167
+ </tr>`;
168
+ table.appendChild(thead);
169
+
170
+ const tbody = document.createElement('tbody');
171
+ const displayCycles = [...displayAll].reverse();
172
+ for (let i = 0; i < displayCycles.length; i++) {
173
+ const c = displayCycles[i];
174
+ const d = getModelData(c, modelKey);
175
+ const t = d.tokens || { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
176
+ const totalInclCR = t.input + t.output + t.cacheRead + t.cacheCreation;
177
+ const prev = displayCycles[i + 1] ? getModelData(displayCycles[i + 1], modelKey) : null;
178
+
179
+ let deltaStr = '—';
180
+ let deltaClass = '';
181
+ if (prev && prev.projectedTokensAt100 != null && d.projectedTokensAt100 != null && prev.projectedTokensAt100 > 0) {
182
+ const delta = ((d.projectedTokensAt100 - prev.projectedTokensAt100) / prev.projectedTokensAt100) * 100;
183
+ deltaStr = `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}%`;
184
+ deltaClass = delta >= 0 ? 'delta-positive' : 'delta-negative';
185
+ }
186
+
187
+ const isCurrent = c === data.currentCycle;
188
+ const label = `${fmtDate(c.start)}–${fmtDate(c.resets_at)}${isCurrent ? ' *' : ''}`;
189
+
190
+ const tr = document.createElement('tr');
191
+ tr.innerHTML = `
192
+ <td>${label}</td>
193
+ <td class="align-right">${d.utilization.toFixed(1)}%</td>
194
+ <td class="align-right">${fmt(t.input)}</td>
195
+ <td class="align-right">${fmt(t.output)}</td>
196
+ <td class="align-right">${fmt(t.cacheRead)}</td>
197
+ <td class="align-right">${fmt(t.cacheCreation)}</td>
198
+ <td class="align-right">${fmt(totalInclCR)}</td>
199
+ <td class="align-right">${fmt(d.actualTokens)}</td>
200
+ <td class="align-right">${fmtCost(d.actualCost)}</td>
201
+ <td class="align-right col-highlight">${fmt(d.projectedTokensAt100)}</td>
202
+ <td class="align-right col-highlight">${fmtCost(d.projectedCostAt100)}</td>
203
+ <td class="align-right ${deltaClass}">${deltaStr}</td>
204
+ `;
205
+ tbody.appendChild(tr);
206
+ }
207
+ table.appendChild(tbody);
208
+ tableEl.appendChild(table);
209
+ }
@@ -0,0 +1,274 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { parseLogDirectory } from './parser.js';
5
+ import { filterByDateRange } from './aggregator.js';
6
+ import { calculateRecordCost } from './pricing.js';
7
+
8
+ const MAX_HISTORY = 52;
9
+
10
+ /**
11
+ * Pure computation: given records (already filtered to a cycle) and quota data,
12
+ * compute actual tokens/cost and project at 100% utilization.
13
+ */
14
+ export function computeCycleData(records, quotaData) {
15
+ const overallUtil = quotaData.seven_day?.utilization || 0;
16
+ const opusUtil = quotaData.seven_day_opus?.utilization || 0;
17
+ const sonnetUtil = quotaData.seven_day_sonnet?.utilization || 0;
18
+
19
+ // Per-type accumulators
20
+ let inTok = 0, outTok = 0, crTok = 0, cwTok = 0, totalCost = 0;
21
+ let opusIn = 0, opusOut = 0, opusCR = 0, opusCW = 0, opusCost = 0;
22
+ let sonIn = 0, sonOut = 0, sonCR = 0, sonCW = 0, sonnetCost = 0;
23
+
24
+ for (const r of records) {
25
+ const cost = calculateRecordCost(r);
26
+ inTok += r.input_tokens; outTok += r.output_tokens;
27
+ crTok += r.cache_read_tokens; cwTok += r.cache_creation_tokens;
28
+ totalCost += cost;
29
+
30
+ if (r.model?.includes('opus')) {
31
+ opusIn += r.input_tokens; opusOut += r.output_tokens;
32
+ opusCR += r.cache_read_tokens; opusCW += r.cache_creation_tokens;
33
+ opusCost += cost;
34
+ } else if (r.model?.includes('sonnet')) {
35
+ sonIn += r.input_tokens; sonOut += r.output_tokens;
36
+ sonCR += r.cache_read_tokens; sonCW += r.cache_creation_tokens;
37
+ sonnetCost += cost;
38
+ }
39
+ }
40
+
41
+ totalCost = Math.round(totalCost * 100) / 100;
42
+ opusCost = Math.round(opusCost * 100) / 100;
43
+ sonnetCost = Math.round(sonnetCost * 100) / 100;
44
+
45
+ function buildTokens(inp, out, cr, cw) {
46
+ return { input: inp, output: out, cacheRead: cr, cacheCreation: cw };
47
+ }
48
+
49
+ function project(actual, utilization) {
50
+ if (utilization <= 0) return null;
51
+ return Math.round(actual / (utilization / 100));
52
+ }
53
+
54
+ function projectCost(actual, utilization) {
55
+ if (utilization <= 0) return null;
56
+ return Math.round((actual / (utilization / 100)) * 100) / 100;
57
+ }
58
+
59
+ // actualTokens = total excluding cache reads (in + out + cw) — used for projections
60
+ const totalExclCR = inTok + outTok + cwTok;
61
+ const opusExclCR = opusIn + opusOut + opusCW;
62
+ const sonExclCR = sonIn + sonOut + sonCW;
63
+
64
+ return {
65
+ overall: {
66
+ utilization: overallUtil,
67
+ tokens: buildTokens(inTok, outTok, crTok, cwTok),
68
+ actualTokens: totalExclCR,
69
+ projectedTokensAt100: project(totalExclCR, overallUtil),
70
+ actualCost: totalCost,
71
+ projectedCostAt100: projectCost(totalCost, overallUtil),
72
+ },
73
+ models: {
74
+ opus: {
75
+ utilization: opusUtil,
76
+ tokens: buildTokens(opusIn, opusOut, opusCR, opusCW),
77
+ actualTokens: opusExclCR,
78
+ projectedTokensAt100: project(opusExclCR, opusUtil),
79
+ actualCost: opusCost,
80
+ projectedCostAt100: projectCost(opusCost, opusUtil),
81
+ },
82
+ sonnet: {
83
+ utilization: sonnetUtil,
84
+ tokens: buildTokens(sonIn, sonOut, sonCR, sonCW),
85
+ actualTokens: sonExclCR,
86
+ projectedTokensAt100: project(sonExclCR, sonnetUtil),
87
+ actualCost: sonnetCost,
88
+ projectedCostAt100: projectCost(sonnetCost, sonnetUtil),
89
+ },
90
+ },
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Update the quota cycle snapshot file for this machine.
96
+ * Called after each successful quota API fetch.
97
+ *
98
+ * @param {object} quotaData - Quota API response (must have available === true)
99
+ * @param {string} logBaseDir - This machine's log directory (~/.claude/projects/)
100
+ * @param {string} machineName - Identifier for this machine
101
+ * @param {string} [snapshotDir] - Directory for snapshot files (defaults to syncDir or ~/.claude/)
102
+ * @param {string} [syncDir] - Shared sync directory; used as fallback when snapshotDir is not set
103
+ */
104
+ export function updateQuotaCycleSnapshot(quotaData, logBaseDir, machineName, snapshotDir, syncDir) {
105
+ if (!quotaData?.available || !quotaData.seven_day?.resets_at) return;
106
+
107
+ const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
108
+ const filePath = path.join(dir, `quota-cycles-${machineName}.json`);
109
+
110
+ // Normalize resets_at to second precision — the API returns varying microseconds
111
+ // on each call (e.g. .905316 vs .581788) which would cause false cycle switches
112
+ const rawResetsAt = new Date(quotaData.seven_day.resets_at);
113
+ rawResetsAt.setMilliseconds(0);
114
+ const resetsAt = rawResetsAt.toISOString();
115
+ const start = new Date(rawResetsAt.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
116
+
117
+ let snapshot;
118
+ try {
119
+ snapshot = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
120
+ } catch {
121
+ snapshot = { schemaVersion: 1, machineName, currentCycle: null, history: [] };
122
+ }
123
+
124
+ // Compare normalized timestamps to detect actual cycle boundary changes
125
+ const storedResetNorm = snapshot.currentCycle
126
+ ? new Date(snapshot.currentCycle.resets_at).setMilliseconds(0)
127
+ : null;
128
+ if (snapshot.currentCycle && storedResetNorm !== rawResetsAt.getTime()) {
129
+ snapshot.history.unshift(snapshot.currentCycle);
130
+ if (snapshot.history.length > MAX_HISTORY) {
131
+ snapshot.history = snapshot.history.slice(0, MAX_HISTORY);
132
+ }
133
+ snapshot.currentCycle = null;
134
+ }
135
+
136
+ const allRecords = parseLogDirectory(logBaseDir);
137
+ const cycleRecords = filterByDateRange(allRecords, start, resetsAt);
138
+ const cycleData = computeCycleData(cycleRecords, quotaData);
139
+
140
+ snapshot.currentCycle = {
141
+ resets_at: resetsAt,
142
+ start,
143
+ lastUpdated: new Date().toISOString(),
144
+ ...cycleData,
145
+ };
146
+
147
+ fs.writeFileSync(filePath, JSON.stringify(snapshot, null, 2));
148
+ }
149
+
150
+ /**
151
+ * Load and merge quota cycle data from all machines.
152
+ *
153
+ * @param {string} machineName - This machine's name
154
+ * @param {string|null} syncDir - Shared sync directory (null if single-machine)
155
+ * @param {string} [snapshotDir] - Directory for snapshot files (defaults to ~/.claude/)
156
+ * @returns {{ currentCycle: object|null, history: object[], machines: string[] }}
157
+ */
158
+ export function loadQuotaCycles(machineName, syncDir, snapshotDir) {
159
+ const dir = snapshotDir || syncDir || path.join(os.homedir(), '.claude');
160
+ const empty = { currentCycle: null, history: [], machines: [] };
161
+
162
+ let files;
163
+ try {
164
+ files = fs.readdirSync(dir).filter(f => f.startsWith('quota-cycles-') && f.endsWith('.json'));
165
+ } catch {
166
+ return empty;
167
+ }
168
+
169
+ if (files.length === 0) return empty;
170
+
171
+ const snapshots = [];
172
+ for (const file of files) {
173
+ try {
174
+ const data = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf-8'));
175
+ if (data.schemaVersion === 1) snapshots.push(data);
176
+ } catch { /* skip corrupt files */ }
177
+ }
178
+
179
+ if (snapshots.length === 0) return empty;
180
+
181
+ const machines = snapshots.map(s => s.machineName);
182
+
183
+ if (snapshots.length === 1) {
184
+ const s = snapshots[0];
185
+ return { currentCycle: s.currentCycle, history: s.history, machines };
186
+ }
187
+
188
+ return {
189
+ currentCycle: mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean)),
190
+ history: mergeHistories(snapshots.map(s => s.history)),
191
+ machines,
192
+ };
193
+ }
194
+
195
+ function mergeCycles(cycles) {
196
+ if (cycles.length === 0) return null;
197
+ if (cycles.length === 1) return cycles[0];
198
+
199
+ const byReset = new Map();
200
+ for (const c of cycles) {
201
+ const key = c.resets_at;
202
+ if (!byReset.has(key)) byReset.set(key, []);
203
+ byReset.get(key).push(c);
204
+ }
205
+
206
+ let bestKey = null, bestCount = 0;
207
+ for (const [key, arr] of byReset) {
208
+ if (arr.length > bestCount || (arr.length === bestCount && key > bestKey)) { bestKey = key; bestCount = arr.length; }
209
+ }
210
+
211
+ const sameCycle = byReset.get(bestKey);
212
+ return mergeSamePeriodCycles(sameCycle);
213
+ }
214
+
215
+ function mergeSamePeriodCycles(cycles) {
216
+ const mostRecent = cycles.reduce((a, b) =>
217
+ new Date(a.lastUpdated) > new Date(b.lastUpdated) ? a : b
218
+ );
219
+
220
+ return {
221
+ resets_at: mostRecent.resets_at,
222
+ start: mostRecent.start,
223
+ lastUpdated: mostRecent.lastUpdated,
224
+ overall: mergeMetrics(cycles.map(c => c.overall), mostRecent.overall.utilization),
225
+ models: {
226
+ opus: mergeMetrics(cycles.map(c => c.models.opus), mostRecent.models?.opus?.utilization || 0),
227
+ sonnet: mergeMetrics(cycles.map(c => c.models.sonnet), mostRecent.models?.sonnet?.utilization || 0),
228
+ },
229
+ };
230
+ }
231
+
232
+ function mergeMetrics(metricsArray, utilization) {
233
+ const totalTokens = metricsArray.reduce((sum, m) => sum + (m?.actualTokens || 0), 0);
234
+ const totalCost = Math.round(metricsArray.reduce((sum, m) => sum + (m?.actualCost || 0), 0) * 100) / 100;
235
+
236
+ // Merge per-type token breakdown
237
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
238
+ for (const m of metricsArray) {
239
+ if (m?.tokens) {
240
+ tokens.input += m.tokens.input || 0;
241
+ tokens.output += m.tokens.output || 0;
242
+ tokens.cacheRead += m.tokens.cacheRead || 0;
243
+ tokens.cacheCreation += m.tokens.cacheCreation || 0;
244
+ }
245
+ }
246
+
247
+ return {
248
+ utilization,
249
+ tokens,
250
+ actualTokens: totalTokens,
251
+ projectedTokensAt100: utilization > 0 ? Math.round(totalTokens / (utilization / 100)) : null,
252
+ actualCost: totalCost,
253
+ projectedCostAt100: utilization > 0 ? Math.round((totalCost / (utilization / 100)) * 100) / 100 : null,
254
+ };
255
+ }
256
+
257
+ function mergeHistories(historyArrays) {
258
+ const byReset = new Map();
259
+ for (const history of historyArrays) {
260
+ for (const entry of history) {
261
+ const key = entry.resets_at;
262
+ if (!byReset.has(key)) byReset.set(key, []);
263
+ byReset.get(key).push(entry);
264
+ }
265
+ }
266
+
267
+ const merged = [];
268
+ for (const [, entries] of byReset) {
269
+ merged.push(mergeSamePeriodCycles(entries));
270
+ }
271
+
272
+ merged.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
273
+ return merged.slice(0, MAX_HISTORY);
274
+ }
@@ -1,10 +1,12 @@
1
1
  import { Router } from 'express';
2
+ import os from 'os';
2
3
  import { parseLogDirectory, parseMultiMachineDirectory } from '../parser.js';
3
4
  import { syncLocalToShared } from '../sync.js';
4
5
  import { filterByDateRange, autoGranularity, aggregateByTime, aggregateBySession, aggregateByProject, aggregateByModel, aggregateCache } from '../aggregator.js';
5
6
  import { calculateRecordCost, PLAN_DEFAULTS } from '../pricing.js';
6
7
  import { createQuotaFetcher } from '../quota.js';
7
8
  import { getSubscriptionInfo } from '../credentials.js';
9
+ import { updateQuotaCycleSnapshot, loadQuotaCycles } from '../quota-cycles.js';
8
10
 
9
11
  export function createApiRouter(logBaseDir, options = {}) {
10
12
  const router = Router();
@@ -117,12 +119,44 @@ export function createApiRouter(logBaseDir, options = {}) {
117
119
  router.get('/quota', async (req, res) => {
118
120
  try {
119
121
  const data = await quotaFetcher.fetchQuota();
122
+ if (data.available) {
123
+ try {
124
+ updateQuotaCycleSnapshot(
125
+ data,
126
+ logBaseDir,
127
+ options.machineName || os.hostname(),
128
+ options.snapshotDir,
129
+ options.syncDir
130
+ );
131
+ } catch (err) {
132
+ console.warn('Failed to update quota cycle snapshot:', err.message);
133
+ }
134
+ }
120
135
  res.json(data);
121
136
  } catch (err) {
122
137
  res.json({ available: false, error: err.message });
123
138
  }
124
139
  });
125
140
 
141
+ router.get('/quota-cycles', (req, res) => {
142
+ try {
143
+ const data = loadQuotaCycles(
144
+ options.machineName || os.hostname(),
145
+ options.syncDir || null,
146
+ options.snapshotDir
147
+ );
148
+ if (data.currentCycle) {
149
+ const start = new Date(data.currentCycle.start);
150
+ const now = new Date();
151
+ data.currentCycle.daysElapsed = Math.round(((now - start) / (1000 * 60 * 60 * 24)) * 10) / 10;
152
+ data.currentCycle.daysTotal = 7;
153
+ }
154
+ res.json(data);
155
+ } catch (err) {
156
+ res.status(500).json({ error: err.message });
157
+ }
158
+ });
159
+
126
160
  router.get('/subscription', (req, res) => {
127
161
  const info = options.getSubscriptionInfo ? options.getSubscriptionInfo() : getSubscriptionInfo();
128
162
  res.json(info || { plan: null, subscriptionType: null, rateLimitTier: null });