claude-usage-dashboard 1.5.3 → 1.5.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-dashboard",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
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": {
package/public/js/app.js CHANGED
@@ -28,6 +28,7 @@ const state = {
28
28
  };
29
29
 
30
30
  let datePicker, planSelector;
31
+ let _cachedCycleData = null;
31
32
 
32
33
  function formatNumber(n) {
33
34
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
@@ -61,7 +62,8 @@ function getQuotaWindow(sevenDay) {
61
62
 
62
63
  async function loadQuota() {
63
64
  try {
64
- const data = await fetchQuota();
65
+ const [data, cycleData] = await Promise.all([fetchQuota(), fetchQuotaCycles()]);
66
+ _cachedCycleData = cycleData;
65
67
 
66
68
  // Use the actual quota window (resets_at - 7 days → resets_at)
67
69
  let cost7dValue = 0;
@@ -72,12 +74,16 @@ async function loadQuota() {
72
74
  if (window && sevenDay.utilization > 0) {
73
75
  quotaWindowFrom = window.from;
74
76
  quotaWindowTo = window.to;
75
- const cost7d = await fetchCost({
76
- from: window.from.toISOString(),
77
- to: window.to.toISOString(),
78
- plan: state.plan.plan,
79
- });
80
- cost7dValue = cost7d.api_equivalent_cost_usd;
77
+ // Prefer cycle data (multi-machine merged) over cost API (local only)
78
+ cost7dValue = cycleData?.currentCycle?.overall?.actualCost || 0;
79
+ if (cost7dValue === 0) {
80
+ const cost7d = await fetchCost({
81
+ from: window.from.toISOString(),
82
+ to: window.to.toISOString(),
83
+ plan: state.plan.plan,
84
+ });
85
+ cost7dValue = cost7d.api_equivalent_cost_usd;
86
+ }
81
87
  }
82
88
 
83
89
  renderQuotaGauges(document.getElementById('chart-quota'), data, {
@@ -85,17 +91,18 @@ async function loadQuota() {
85
91
  });
86
92
  const el = document.getElementById('quota-last-updated');
87
93
  if (el && data.lastFetched) el.textContent = `Updated ${new Date(data.lastFetched).toLocaleTimeString()} ${getTimezoneAbbr()}`;
88
- loadQuotaCyclesData();
94
+ renderQuotaCycles(document.getElementById('chart-quota-cycles'), cycleData, {
95
+ modelKey: state.cycleModel,
96
+ });
89
97
  } catch { /* silently degrade */ }
90
98
  }
91
99
 
92
- async function loadQuotaCyclesData() {
93
- try {
94
- const data = await fetchQuotaCycles();
95
- renderQuotaCycles(document.getElementById('chart-quota-cycles'), data, {
100
+ function loadQuotaCyclesData() {
101
+ if (_cachedCycleData) {
102
+ renderQuotaCycles(document.getElementById('chart-quota-cycles'), _cachedCycleData, {
96
103
  modelKey: state.cycleModel,
97
104
  });
98
- } catch { /* silently degrade */ }
105
+ }
99
106
  }
100
107
 
101
108
  function startAutoRefresh() {
@@ -137,18 +144,35 @@ async function loadAll() {
137
144
  fetchCache(params),
138
145
  ]);
139
146
 
140
- // Summary cards
147
+ // Summary cards — use multi-machine cycle data when viewing current cycle
141
148
  const t = usage.total;
142
- const totalAll = t.input_tokens + t.output_tokens + t.cache_read_tokens + t.cache_creation_tokens;
149
+ let tokIn = t.input_tokens, tokOut = t.output_tokens;
150
+ let tokCR = t.cache_read_tokens, tokCW = t.cache_creation_tokens;
151
+ let apiCost = cost.api_equivalent_cost_usd;
152
+ const cc = _cachedCycleData?.currentCycle?.overall;
153
+ if (cc?.tokens) {
154
+ const cct = cc.tokens;
155
+ const mergedAll = cct.input + cct.output + cct.cacheRead + cct.cacheCreation;
156
+ const localAll = tokIn + tokOut + tokCR + tokCW;
157
+ if (mergedAll > localAll) {
158
+ tokIn = cct.input; tokOut = cct.output;
159
+ tokCR = cct.cacheRead; tokCW = cct.cacheCreation;
160
+ apiCost = cc.actualCost;
161
+ }
162
+ }
163
+ const totalAll = tokIn + tokOut + tokCR + tokCW;
143
164
  document.getElementById('val-total-tokens').textContent = formatNumber(totalAll);
144
165
  document.getElementById('sub-total-tokens').innerHTML =
145
- `<span style="color:#4ade80">cache read:${formatNumber(t.cache_read_tokens)}</span> · ` +
146
- `<span style="color:#f59e0b">cache write:${formatNumber(t.cache_creation_tokens)}</span> · ` +
147
- `<span style="color:#60a5fa">in:${formatNumber(t.input_tokens)}</span> · ` +
148
- `<span style="color:#f97316">out:${formatNumber(t.output_tokens)}</span>`;
149
- document.getElementById('val-api-cost').textContent = `$${cost.api_equivalent_cost_usd.toFixed(2)}`;
150
-
151
- document.getElementById('val-cache-rate').textContent = `${(cache.cache_read_rate * 100).toFixed(1)}%`;
166
+ `<span style="color:#4ade80">cache read:${formatNumber(tokCR)}</span> · ` +
167
+ `<span style="color:#f59e0b">cache write:${formatNumber(tokCW)}</span> · ` +
168
+ `<span style="color:#60a5fa">in:${formatNumber(tokIn)}</span> · ` +
169
+ `<span style="color:#f97316">out:${formatNumber(tokOut)}</span>`;
170
+ document.getElementById('val-api-cost').textContent = `$${apiCost.toFixed(2)}`;
171
+
172
+ const totalInput = tokIn + tokCR + tokCW;
173
+ document.getElementById('val-cache-rate').textContent = totalInput > 0
174
+ ? `${((tokCR / totalInput) * 100).toFixed(1)}%`
175
+ : `${(cache.cache_read_rate * 100).toFixed(1)}%`;
152
176
 
153
177
  // Set active granularity button
154
178
  const activeGran = usage.granularity;
@@ -161,9 +161,9 @@ export function renderQuotaCycles(container, data, { modelKey = 'overall' } = {}
161
161
  <th class="align-right">Total</th>
162
162
  <th class="align-right">Excl CR</th>
163
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>
164
+ <th class="align-right col-highlight">Proj Token Limit</th>
165
+ <th class="align-right col-highlight">Proj Cost Limit</th>
166
+ <th class="align-right">\u0394 Limit</th>
167
167
  </tr>`;
168
168
  table.appendChild(thead);
169
169
 
@@ -178,8 +178,8 @@ export function renderQuotaCycles(container, data, { modelKey = 'overall' } = {}
178
178
 
179
179
  let deltaStr = '—';
180
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;
181
+ if (prev && prev.projectedCostAt100 != null && d.projectedCostAt100 != null && prev.projectedCostAt100 > 0) {
182
+ const delta = ((d.projectedCostAt100 - prev.projectedCostAt100) / prev.projectedCostAt100) * 100;
183
183
  deltaStr = `${delta >= 0 ? '+' : ''}${delta.toFixed(1)}%`;
184
184
  deltaClass = delta >= 0 ? 'delta-positive' : 'delta-negative';
185
185
  }
@@ -7,6 +7,35 @@ import { calculateRecordCost } from './pricing.js';
7
7
 
8
8
  const MAX_HISTORY = 52;
9
9
 
10
+ /**
11
+ * Normalize a cycle's resets_at to hour precision for use as a dedup key.
12
+ * The API returns varying sub-second precision across calls and machines,
13
+ * so raw strings cannot be used for grouping.
14
+ */
15
+ function cyclePeriodKey(cycle) {
16
+ const d = new Date(cycle.resets_at);
17
+ d.setMinutes(0, 0, 0);
18
+ return d.toISOString();
19
+ }
20
+
21
+ /**
22
+ * Deduplicate history entries that represent the same quota cycle period.
23
+ * Keeps the entry with the latest lastUpdated for each period.
24
+ */
25
+ function deduplicateHistory(history) {
26
+ const byKey = new Map();
27
+ for (const entry of history) {
28
+ const key = cyclePeriodKey(entry);
29
+ const existing = byKey.get(key);
30
+ if (!existing || new Date(entry.lastUpdated) > new Date(existing.lastUpdated)) {
31
+ byKey.set(key, entry);
32
+ }
33
+ }
34
+ const result = Array.from(byKey.values());
35
+ result.sort((a, b) => new Date(b.resets_at) - new Date(a.resets_at));
36
+ return result;
37
+ }
38
+
10
39
  /**
11
40
  * Pure computation: given records (already filtered to a cycle) and quota data,
12
41
  * compute actual tokens/cost and project at 100% utilization.
@@ -121,12 +150,13 @@ export function updateQuotaCycleSnapshot(quotaData, logBaseDir, machineName, sna
121
150
  snapshot = { schemaVersion: 1, machineName, currentCycle: null, history: [] };
122
151
  }
123
152
 
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()) {
153
+ // Compare normalized period keys to detect actual cycle boundary changes.
154
+ // Uses hour-precision keys to tolerate varying sub-second timestamps from the API.
155
+ const storedKey = snapshot.currentCycle ? cyclePeriodKey(snapshot.currentCycle) : null;
156
+ const newKey = cyclePeriodKey({ resets_at: resetsAt });
157
+ if (snapshot.currentCycle && storedKey !== newKey) {
129
158
  snapshot.history.unshift(snapshot.currentCycle);
159
+ snapshot.history = deduplicateHistory(snapshot.history);
130
160
  if (snapshot.history.length > MAX_HISTORY) {
131
161
  snapshot.history = snapshot.history.slice(0, MAX_HISTORY);
132
162
  }
@@ -181,34 +211,55 @@ export function loadQuotaCycles(machineName, syncDir, snapshotDir) {
181
211
  const machines = snapshots.map(s => s.machineName);
182
212
 
183
213
  if (snapshots.length === 1) {
214
+ // Single machine: duplicates are time-series snapshots of the same data,
215
+ // so dedup (keep most recent) and filter out current-cycle overlap.
184
216
  const s = snapshots[0];
185
- return { currentCycle: s.currentCycle, history: s.history, machines };
217
+ let history = deduplicateHistory(s.history);
218
+ if (s.currentCycle) {
219
+ const currentKey = cyclePeriodKey(s.currentCycle);
220
+ history = history.filter(h => cyclePeriodKey(h) !== currentKey);
221
+ }
222
+ return { currentCycle: s.currentCycle, history, machines };
186
223
  }
187
224
 
188
- return {
189
- currentCycle: mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean)),
190
- history: mergeHistories(snapshots.map(s => s.history)),
191
- machines,
192
- };
225
+ // Multi-machine: dedup within each machine first (removes false-switch
226
+ // duplicates), then merge across machines (sums different machines' data).
227
+ const dedupedHistories = snapshots.map(s => deduplicateHistory(s.history));
228
+ let currentCycle = mergeCycles(snapshots.map(s => s.currentCycle).filter(Boolean));
229
+ let history = mergeHistories(dedupedHistories);
230
+
231
+ // If history contains entries for the current cycle's period (e.g. from an
232
+ // offline machine whose cycle was archived), merge them INTO the current
233
+ // cycle instead of dropping them.
234
+ if (currentCycle) {
235
+ const currentKey = cyclePeriodKey(currentCycle);
236
+ const overlapping = history.filter(h => cyclePeriodKey(h) === currentKey);
237
+ history = history.filter(h => cyclePeriodKey(h) !== currentKey);
238
+ if (overlapping.length > 0) {
239
+ currentCycle = mergeSamePeriodCycles([currentCycle, ...overlapping]);
240
+ }
241
+ }
242
+
243
+ return { currentCycle, history, machines };
193
244
  }
194
245
 
195
246
  function mergeCycles(cycles) {
196
247
  if (cycles.length === 0) return null;
197
248
  if (cycles.length === 1) return cycles[0];
198
249
 
199
- const byReset = new Map();
250
+ const byPeriod = new Map();
200
251
  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);
252
+ const key = cyclePeriodKey(c);
253
+ if (!byPeriod.has(key)) byPeriod.set(key, []);
254
+ byPeriod.get(key).push(c);
204
255
  }
205
256
 
206
257
  let bestKey = null, bestCount = 0;
207
- for (const [key, arr] of byReset) {
258
+ for (const [key, arr] of byPeriod) {
208
259
  if (arr.length > bestCount || (arr.length === bestCount && key > bestKey)) { bestKey = key; bestCount = arr.length; }
209
260
  }
210
261
 
211
- const sameCycle = byReset.get(bestKey);
262
+ const sameCycle = byPeriod.get(bestKey);
212
263
  return mergeSamePeriodCycles(sameCycle);
213
264
  }
214
265
 
@@ -255,17 +306,17 @@ function mergeMetrics(metricsArray, utilization) {
255
306
  }
256
307
 
257
308
  function mergeHistories(historyArrays) {
258
- const byReset = new Map();
309
+ const byPeriod = new Map();
259
310
  for (const history of historyArrays) {
260
311
  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);
312
+ const key = cyclePeriodKey(entry);
313
+ if (!byPeriod.has(key)) byPeriod.set(key, []);
314
+ byPeriod.get(key).push(entry);
264
315
  }
265
316
  }
266
317
 
267
318
  const merged = [];
268
- for (const [, entries] of byReset) {
319
+ for (const [, entries] of byPeriod) {
269
320
  merged.push(mergeSamePeriodCycles(entries));
270
321
  }
271
322