claude-finops 0.8.0 → 0.9.0

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.
@@ -2,8 +2,8 @@
2
2
  "_comment": "Model pricing in USD per 1,000,000 tokens. Kept SEPARATE from usage data so it can be updated without touching the dashboard. Any cost derived from these numbers is ESTIMATED, never actual billed cost.",
3
3
  "currency": "USD",
4
4
  "unit": "per_million_tokens",
5
- "updated": "2026-09-23",
6
- "source": "Anthropic list prices (Claude API reference, cached 2026-06-24)",
5
+ "updated": "2026-09-28",
6
+ "source": "Anthropic list prices (platform.claude.com/docs/en/about-claude/pricing, fetched 2026-09-28)",
7
7
  "models": {
8
8
  "claude-opus-5": {
9
9
  "display_name": "Claude Opus 5",
@@ -84,6 +84,128 @@
84
84
  "context_window": 1000000,
85
85
  "provider": "anthropic"
86
86
  },
87
+ "claude-opus-4-8": {
88
+ "display_name": "Claude Opus 4.8",
89
+ "tier": "frontier",
90
+ "input": 5.0,
91
+ "output": 25.0,
92
+ "cache_write_5m": 6.25,
93
+ "cache_write_1h": 10.0,
94
+ "cache_read": 0.5,
95
+ "context_window": 1000000,
96
+ "provider": "anthropic"
97
+ },
98
+ "claude-opus-4-8[fast]": {
99
+ "display_name": "Claude Opus 4.8 (fast mode)",
100
+ "tier": "frontier",
101
+ "input": 10.0,
102
+ "output": 50.0,
103
+ "cache_write_5m": 12.5,
104
+ "cache_write_1h": 20.0,
105
+ "cache_read": 1.0,
106
+ "context_window": 1000000,
107
+ "provider": "anthropic",
108
+ "_note": "Research-preview fast mode, 2x standard. Selected when usage.speed == 'fast'."
109
+ },
110
+ "claude-opus-4-7": {
111
+ "display_name": "Claude Opus 4.7",
112
+ "tier": "frontier",
113
+ "input": 5.0,
114
+ "output": 25.0,
115
+ "cache_write_5m": 6.25,
116
+ "cache_write_1h": 10.0,
117
+ "cache_read": 0.5,
118
+ "context_window": 1000000,
119
+ "provider": "anthropic"
120
+ },
121
+ "claude-opus-4-6": {
122
+ "display_name": "Claude Opus 4.6",
123
+ "tier": "frontier",
124
+ "input": 5.0,
125
+ "output": 25.0,
126
+ "cache_write_5m": 6.25,
127
+ "cache_write_1h": 10.0,
128
+ "cache_read": 0.5,
129
+ "context_window": 1000000,
130
+ "provider": "anthropic"
131
+ },
132
+ "claude-opus-4-5": {
133
+ "display_name": "Claude Opus 4.5",
134
+ "tier": "frontier",
135
+ "input": 5.0,
136
+ "output": 25.0,
137
+ "cache_write_5m": 6.25,
138
+ "cache_write_1h": 10.0,
139
+ "cache_read": 0.5,
140
+ "context_window": 200000,
141
+ "provider": "anthropic"
142
+ },
143
+ "claude-opus-4-1": {
144
+ "display_name": "Claude Opus 4.1",
145
+ "tier": "frontier",
146
+ "input": 15.0,
147
+ "output": 75.0,
148
+ "cache_write_5m": 18.75,
149
+ "cache_write_1h": 30.0,
150
+ "cache_read": 1.5,
151
+ "context_window": 200000,
152
+ "provider": "anthropic"
153
+ },
154
+ "claude-opus-4": {
155
+ "display_name": "Claude Opus 4",
156
+ "tier": "frontier",
157
+ "input": 15.0,
158
+ "output": 75.0,
159
+ "cache_write_5m": 18.75,
160
+ "cache_write_1h": 30.0,
161
+ "cache_read": 1.5,
162
+ "context_window": 200000,
163
+ "provider": "anthropic"
164
+ },
165
+ "claude-sonnet-4-6": {
166
+ "display_name": "Claude Sonnet 4.6",
167
+ "tier": "balanced",
168
+ "input": 3.0,
169
+ "output": 15.0,
170
+ "cache_write_5m": 3.75,
171
+ "cache_write_1h": 6.0,
172
+ "cache_read": 0.3,
173
+ "context_window": 1000000,
174
+ "provider": "anthropic"
175
+ },
176
+ "claude-sonnet-4-5": {
177
+ "display_name": "Claude Sonnet 4.5",
178
+ "tier": "balanced",
179
+ "input": 3.0,
180
+ "output": 15.0,
181
+ "cache_write_5m": 3.75,
182
+ "cache_write_1h": 6.0,
183
+ "cache_read": 0.3,
184
+ "context_window": 200000,
185
+ "provider": "anthropic"
186
+ },
187
+ "claude-sonnet-4": {
188
+ "display_name": "Claude Sonnet 4",
189
+ "tier": "balanced",
190
+ "input": 3.0,
191
+ "output": 15.0,
192
+ "cache_write_5m": 3.75,
193
+ "cache_write_1h": 6.0,
194
+ "cache_read": 0.3,
195
+ "context_window": 200000,
196
+ "provider": "anthropic"
197
+ },
198
+ "claude-3-5-haiku": {
199
+ "display_name": "Claude Haiku 3.5",
200
+ "tier": "economy",
201
+ "input": 0.8,
202
+ "output": 4.0,
203
+ "cache_write_5m": 1.0,
204
+ "cache_write_1h": 1.6,
205
+ "cache_read": 0.08,
206
+ "context_window": 200000,
207
+ "provider": "anthropic"
208
+ },
87
209
  "claude-haiku-4-5-20251001": {
88
210
  "display_name": "Claude Haiku 4.5",
89
211
  "tier": "economy",
package/finops/agents.py CHANGED
@@ -33,7 +33,8 @@ def _cursor_state_db():
33
33
 
34
34
  AGENTS = {
35
35
  "claude": {"name": "Claude Code", "data": "full",
36
- "note": "Tokens, model, cost, prompts and tool calls per request."},
36
+ "note": "Tokens, model, cost, prompts and tool calls per request. "
37
+ "Includes Claude desktop app (Cowork) sessions when present."},
37
38
  "codex": {"name": "Codex", "data": "tokens",
38
39
  "paths": [os.path.join(HOME, ".codex", "sessions")],
39
40
  "note": "Tokens and model per turn. Cost estimated at OpenAI API list prices; "
@@ -12,6 +12,7 @@ import sqlite3
12
12
  import statistics
13
13
  import sys
14
14
  import threading
15
+ import time
15
16
  from collections import defaultdict
16
17
  from datetime import date, datetime, timedelta, timezone
17
18
 
@@ -334,6 +335,7 @@ class Analytics:
334
335
  f" FROM requests r WHERE {w} AND {extra}", p + ep)
335
336
 
336
337
  tot["cost_today"] = spend("r.day = ?", [today])
338
+ tot["cost_yesterday"] = spend("r.day = ?", [(_d(today) - timedelta(days=1)).isoformat()])
337
339
  tot["cost_week"] = spend("r.day >= ?", [wk])
338
340
  tot["cost_period"] = spend("r.day >= ? AND r.day <= ?", [bp["start"], bp["end"]])
339
341
  tot["billing_period"] = bp
@@ -443,6 +445,30 @@ class Analytics:
443
445
  AVG(r.context_tokens) avg_context
444
446
  FROM requests r WHERE {w} AND r.day <> '' GROUP BY 1 ORDER BY 1""", p)
445
447
 
448
+ def heatmap(self, f=None):
449
+ """Spend by weekday x hour, in this machine's local time (Monday first).
450
+
451
+ Transcripts stamp UTC; bucketing on that would put a 10am IST session at
452
+ 4am. SQLite's 'localtime' modifier applies the local offset, half-hour
453
+ zones included. Date filters still apply to UTC days like every view.
454
+ """
455
+ w, p = self.where(f)
456
+ rows = self.q(f"""
457
+ SELECT CAST(strftime('%w', r.ts, 'localtime') AS INTEGER) wd,
458
+ CAST(strftime('%H', r.ts, 'localtime') AS INTEGER) hr,
459
+ COALESCE(SUM(r.est_cost_usd),0) cost, COALESCE(SUM(r.billable_tokens),0) tokens,
460
+ COUNT(*) requests
461
+ FROM requests r WHERE {w} AND r.ts <> '' GROUP BY 1, 2""", p)
462
+ grid = {(d, h): {"dow": d, "hour": h, "cost": 0.0, "tokens": 0, "requests": 0}
463
+ for d in range(7) for h in range(24)}
464
+ for r in rows:
465
+ if r["wd"] is None or r["hr"] is None:
466
+ continue
467
+ c = grid[((r["wd"] + 6) % 7, r["hr"])]
468
+ c["cost"], c["tokens"], c["requests"] = r["cost"], r["tokens"], r["requests"]
469
+ return {"cells": [grid[(d, h)] for d in range(7) for h in range(24)],
470
+ "tz": time.strftime("%Z"), "cost_basis": "estimated"}
471
+
446
472
  # ---------------- models ----------------
447
473
  def models(self, f=None):
448
474
  w, p = self.where(f)
@@ -673,6 +699,11 @@ class Analytics:
673
699
  s["files"] = self.q(
674
700
  "SELECT path, GROUP_CONCAT(DISTINCT op) ops, COUNT(*) n FROM files_touched"
675
701
  " WHERE session_id=? GROUP BY path ORDER BY n DESC LIMIT 100", (sid,))
702
+ # Cowork transcripts live under the desktop app's own config dir, where
703
+ # `claude --resume` would not find them.
704
+ cowork = "local-agent-mode-sessions" in (s.get("source_file") or "")
705
+ s["resume"] = (f"claude --resume {sid}"
706
+ if (s.get("agent") or "claude") == "claude" and not cowork else None)
676
707
  return s
677
708
 
678
709
  # ---------------- categories ----------------
package/finops/api.py CHANGED
@@ -366,6 +366,9 @@ class Handler(BaseHTTPRequestHandler):
366
366
  if route == "usage":
367
367
  from .limits import usage
368
368
  return self.send_json(usage(force=qs.get("refresh", [""])[0] == "1"))
369
+ if route == "plan_history":
370
+ from .plan_history import history
371
+ return self.send_json(history())
369
372
  if route.startswith(("free_models", "suggestions", "job/", "sync", "compare")):
370
373
  return self.actions_get(route)
371
374
  f = filters_from(qs)
@@ -385,6 +388,8 @@ class Handler(BaseHTTPRequestHandler):
385
388
  return self.send_json(a.burn(f))
386
389
  if route == "timeline":
387
390
  return self.send_json(a.timeline(f, g("grain", "day")))
391
+ if route == "heatmap":
392
+ return self.send_json(a.heatmap(f))
388
393
  if route == "models":
389
394
  return self.send_json(a.models(f))
390
395
  if route == "projects":
package/finops/etl.py CHANGED
@@ -16,7 +16,7 @@ from datetime import datetime, timezone
16
16
  from .classify import classify
17
17
  from .pricing import Pricing
18
18
 
19
- from .paths import ROOT, DB_PATH
19
+ from .paths import ROOT, DB_PATH, DESKTOP_SESSIONS
20
20
  DEFAULT_SOURCE = os.path.expanduser("~/.claude/projects")
21
21
 
22
22
  SCHEMA_VERSION = 3 # 3: cross-session request_id dedup (resumed sessions copy history)
@@ -203,8 +203,14 @@ def _slug_to_name(slug):
203
203
 
204
204
 
205
205
  class Loader:
206
- def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True):
206
+ def __init__(self, db_path=DB_PATH, source=DEFAULT_SOURCE, pricing=None, other_agents=True,
207
+ desktop_roots=None):
207
208
  self.other_agents = other_agents
209
+ # An explicit source means "load exactly this"; only the default also picks up
210
+ # the desktop app's Cowork sessions (same JSONL format, different config dir).
211
+ self.desktop_roots = (desktop_roots if desktop_roots is not None
212
+ else [DESKTOP_SESSIONS] if source == DEFAULT_SOURCE else [])
213
+ self.cowork = False
208
214
  self.db_path = db_path
209
215
  self.source = source
210
216
  self.pricing = pricing or Pricing()
@@ -232,13 +238,19 @@ class Loader:
232
238
  if n.endswith(".jsonl"):
233
239
  files.append(os.path.join(dirpath, n))
234
240
  files.sort()
235
- for i, fp in enumerate(files, 1):
241
+ marker = os.sep + os.path.join(".claude", "projects") + os.sep
242
+ desktop = sorted(os.path.join(d, n) for root in self.desktop_roots
243
+ for d, _, names in os.walk(root) for n in names
244
+ if n.endswith(".jsonl") and marker in d + os.sep)
245
+ for i, fp in enumerate(files + desktop, 1):
236
246
  if verbose and i % 20 == 0:
237
- print(f" ...{i}/{len(files)} transcripts", file=sys.stderr)
247
+ print(f" ...{i}/{len(files) + len(desktop)} transcripts", file=sys.stderr)
248
+ self.cowork = i > len(files)
238
249
  try:
239
250
  self.load_file(fp)
240
251
  except Exception as exc: # a corrupt transcript must not kill the load
241
252
  print(f" ! skipped {os.path.basename(fp)}: {exc}", file=sys.stderr)
253
+ self.cowork = False
242
254
  if self.other_agents:
243
255
  from .agents import AgentLoader
244
256
  counts = AgentLoader(self, log=lambda m: print(m, file=sys.stderr)).run()
@@ -253,6 +265,7 @@ class Loader:
253
265
  for k, v in (
254
266
  ("source_dir", self.source),
255
267
  ("transcript_files", str(len(files))),
268
+ ("desktop_transcript_files", str(len(desktop))),
256
269
  ("pricing_updated", str(self.pricing.updated)),
257
270
  ("pricing_source", str(self.pricing.source)),
258
271
  ("cost_basis", "estimated"),
@@ -260,7 +273,7 @@ class Loader:
260
273
  ):
261
274
  self.db.execute("INSERT INTO meta VALUES (?,?)", (k, v))
262
275
  self.db.commit()
263
- return len(files)
276
+ return len(files) + len(desktop)
264
277
 
265
278
  def project_id(self, slug, cwd):
266
279
  if slug in self.projects:
@@ -271,6 +284,8 @@ class Loader:
271
284
  return pid
272
285
  is_sandbox = 1 if ("sandbox" in slug or slug.startswith("-private")) else 0
273
286
  name = os.path.basename(cwd) if cwd else _slug_to_name(slug)
287
+ if self.cowork:
288
+ name = f"Cowork · {name or 'session'}"
274
289
  cur = self.db.execute(
275
290
  "INSERT INTO projects (slug, path, name, is_sandbox) VALUES (?,?,?,?)",
276
291
  (slug, cwd, name or slug, is_sandbox))
package/finops/paths.py CHANGED
@@ -109,3 +109,15 @@ def migrate(log=print):
109
109
  with open(_MARKER, "w") as fh:
110
110
  fh.write("state dir in use; delete this file to re-run migration\n")
111
111
  return bool(moved)
112
+
113
+
114
+ # The Claude desktop app's data dir. Read-only: Cowork transcripts and the plan
115
+ # usage history live here.
116
+ if os.name == "nt":
117
+ DESKTOP_DIR = os.path.join(os.environ.get("APPDATA", ""), "Claude")
118
+ elif os.uname().sysname == "Darwin":
119
+ DESKTOP_DIR = os.path.join(os.path.expanduser("~"), "Library", "Application Support", "Claude")
120
+ else:
121
+ DESKTOP_DIR = os.path.join(os.path.expanduser("~"), ".config", "Claude")
122
+ DESKTOP_SESSIONS = os.path.join(DESKTOP_DIR, "local-agent-mode-sessions")
123
+ PLAN_HISTORY_PATH = os.path.join(DESKTOP_DIR, "plan-usage-history.json")
@@ -0,0 +1,80 @@
1
+ """Plan-limit history, as the Claude desktop app records it.
2
+
3
+ The desktop app samples plan usage every few minutes into plan-usage-history.json:
4
+ `fh` is the 5-hour window and `sd` the weekly (seven-day) window, both percent used.
5
+ The format is undocumented, so anything unexpected returns {"ok": False, "reason"}
6
+ instead of raising. Read on request, cached on the file's mtime.
7
+ """
8
+ import json
9
+ import os
10
+ from datetime import datetime, timezone
11
+
12
+ from .paths import PLAN_HISTORY_PATH
13
+
14
+ KNOWN_VERSIONS = {2}
15
+ _cache = {}
16
+
17
+
18
+ def _num(v):
19
+ return float(v) if isinstance(v, (int, float)) and not isinstance(v, bool) else None
20
+
21
+
22
+ def _runs(values, threshold):
23
+ """Separate stretches at or above the threshold, not raw sample counts."""
24
+ n, above = 0, False
25
+ for v in values:
26
+ hit = v is not None and v >= threshold
27
+ n += hit and not above
28
+ above = hit
29
+ return n
30
+
31
+
32
+ def _peak(values):
33
+ return max((v for v in values if v is not None), default=None)
34
+
35
+
36
+ def history(path=None):
37
+ path = path or PLAN_HISTORY_PATH
38
+ try:
39
+ mtime = os.path.getmtime(path)
40
+ except OSError:
41
+ return {"ok": False, "reason": "No plan history on this machine. The Claude desktop "
42
+ "app records it; it is not installed or has not saved any yet."}
43
+ hit = _cache.get(path)
44
+ if hit and hit[0] == mtime:
45
+ return hit[1]
46
+ res = _read(path)
47
+ _cache[path] = (mtime, res)
48
+ return res
49
+
50
+
51
+ def _read(path):
52
+ try:
53
+ with open(path) as fh:
54
+ raw = json.load(fh)
55
+ except (OSError, ValueError):
56
+ return {"ok": False, "reason": "The plan history file could not be read."}
57
+ version = raw.get("version") if isinstance(raw, dict) else None
58
+ if version not in KNOWN_VERSIONS:
59
+ return {"ok": False, "reason": f"Unrecognised plan history format (version {version})."}
60
+ samples = [x for x in raw.get("samples") or []
61
+ if isinstance(x, dict) and _num(x.get("t")) is not None]
62
+ if not samples:
63
+ return {"ok": False, "reason": "The plan history file has no samples yet."}
64
+ samples.sort(key=lambda x: x["t"])
65
+ orgs = {x.get("org") for x in samples}
66
+ org = samples[-1].get("org")
67
+ series = []
68
+ for x in samples:
69
+ if x.get("org") != org:
70
+ continue
71
+ u = x.get("u") if isinstance(x.get("u"), dict) else {}
72
+ t = datetime.fromtimestamp(x["t"] / 1000, timezone.utc).isoformat().replace("+00:00", "Z")
73
+ series.append({"t": t, "five_hour": _num(u.get("fh")), "weekly": _num(u.get("sd"))})
74
+ fh = [r["five_hour"] for r in series]
75
+ sd = [r["weekly"] for r in series]
76
+ return {"ok": True, "source": path, "org_count": len(orgs), "series": series,
77
+ "summary": {"five_hour_peak": _peak(fh), "weekly_peak": _peak(sd),
78
+ "five_hour_ge90": _runs(fh, 90), "five_hour_hit100": _runs(fh, 100),
79
+ "weekly_ge90": _runs(sd, 90), "weekly_hit100": _runs(sd, 100),
80
+ "first": series[0]["t"], "last": series[-1]["t"]}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-finops",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Local FinOps dashboard for Claude Code: what you used, what it cost, why it cost that much, and what to change. Reads your own transcripts, no API key, no data leaves the machine.",
5
5
  "bin": {
6
6
  "claude-finops": "bin/claude-finops.js"
package/web/app.js CHANGED
@@ -73,6 +73,13 @@ const dur = s => s == null ? '—' : s < 60 ? `${Math.round(s)}s`
73
73
  const shortDay = d => (d || '').slice(5);
74
74
  const shortId = s => (s || '').slice(0, 8);
75
75
 
76
+ // "(▲12% vs yesterday)"; nothing when yesterday had no spend to compare against.
77
+ const vsYesterday = o => {
78
+ const y = o.cost_yesterday?.c, t = o.cost_today?.c;
79
+ if (!y || t == null) return '';
80
+ const d = (t - y) / y * 100;
81
+ return ` (${d >= 0 ? '▲' : '▼'}${Math.abs(d).toFixed(0)}% vs yesterday)`;
82
+ };
76
83
  function kpi(label, value, detail, opts = {}) {
77
84
  const na = value == null;
78
85
  return `<div class="kpi${na ? ' na' : ''}">
@@ -452,6 +459,8 @@ async function openSession(id) {
452
459
  : '<div class="na">None</div>')}
453
460
  </div>
454
461
  ${card('Session metadata', `<dl class="kv">
462
+ ${s.resume ? `<dt>Resume</dt><dd><span class="mono">${esc(s.resume)}</span>
463
+ <button class="btn pb-copy" id="sess-resume" data-copy="${esc(s.resume)}">Copy</button></dd>` : ''}
455
464
  <dt>Session ID</dt><dd class="mono">${esc(s.id)}</dd>
456
465
  <dt>Title</dt><dd>${s.title ? esc(s.title) : NA()}</dd>
457
466
  <dt>Project</dt><dd>${esc(s.project)}</dd>
@@ -463,6 +472,10 @@ async function openSession(id) {
463
472
  <dt>Commits / PRs</dt><dd>${NA()}</dd>
464
473
  </dl>`)}`;
465
474
  wireTable(cont, s.prompts, r => openPrompt(r.prompt_id));
475
+ const rb = cont.querySelector('#sess-resume');
476
+ if (rb) rb.onclick = async () => {
477
+ try { await navigator.clipboard.writeText(rb.dataset.copy); rb.textContent = 'Copied'; }
478
+ catch { rb.textContent = 'Copy failed'; } };
466
479
  const tl = s.timeline.map((r, i) => ({i, ...r}));
467
480
  C.timeSeries($('#sesschart', cont), {rows: tl, x: 'i', type: 'area',
468
481
  series: [{key: 'context_tokens', label: 'Context tokens', color: seriesVar(0)}],
@@ -500,7 +513,7 @@ VIEWS.overview = async (page) => {
500
513
  page.innerHTML = `
501
514
  <div class="grid g5">
502
515
  ${kpi('Estimated spend', fmtUSD(o.est_cost_usd),
503
- `${fmtUSD(o.cost_today.c)} today · ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
516
+ `${fmtUSD(o.cost_today.c)} today${vsYesterday(o)} · ${fmtUSD(o.cost_week.c)} last 7d`, {badge: BADGE.estimated})}
504
517
  ${kpi('Billable tokens', fmtNum(o.billable_tokens),
505
518
  `${fmtNum(o.output_tokens)} output · ${fmtNum(o.cache_read_tokens)} cache read`,
506
519
  {badge: BADGE.actual})}
@@ -763,9 +776,10 @@ const METRICS = [
763
776
  ['avg_context', 'Avg context', fmtNum],
764
777
  ];
765
778
  VIEWS.usage = async (page) => {
766
- const [tl, models, ov] = await Promise.all([api('timeline', `&grain=${S.grain}`),
767
- api('models'), api('overview')]);
779
+ const [tl, models, ov, hm] = await Promise.all([api('timeline', `&grain=${S.grain}`),
780
+ api('models'), api('overview'), api('heatmap')]);
768
781
  const [, mlabel, mfmt] = METRICS.find(m => m[0] === S.metric) || METRICS[0];
782
+ const hmMetric = ['cost', 'tokens', 'requests'].includes(S.metric) ? S.metric : 'cost';
769
783
  page.innerHTML = `
770
784
  <div class="grid g5">
771
785
  ${kpi('Total tokens', fmtNum(ov.billable_tokens), null, {badge: BADGE.actual})}
@@ -793,6 +807,9 @@ VIEWS.usage = async (page) => {
793
807
  footer: 'Days are bucketed in UTC, as Claude Code timestamps its transcripts. '
794
808
  + 'If you work late in a timezone ahead of UTC, that work lands on the previous day here.',
795
809
  flush: 0})}
810
+ ${card('Peak hours', '<div class="chart" id="heat"></div>',
811
+ {badge: hmMetric === 'cost' ? BADGE.estimated : BADGE.actual,
812
+ hint: `your local time (${hm.tz || 'local'}) · ${hmMetric === 'cost' ? 'estimated cost' : hmMetric}`})}
796
813
  ${card('Model mix over time', '<div class="legend" id="mixleg"></div>' +
797
814
  '<div class="chart" id="mix"></div>', {badge: BADGE.estimated,
798
815
  hint: 'stacked estimated cost per model'})}
@@ -810,6 +827,7 @@ VIEWS.usage = async (page) => {
810
827
  xLabel: v => S.grain === 'day' ? shortDay(v) : v.slice(8).replace('T', ' ') + ':00',
811
828
  onClick: r => { if (S.grain === 'day') { S.filter.start = r.bucket; S.filter.end = r.bucket;
812
829
  S.range = 'custom'; bust(); render(); } }});
830
+ C.heatmap($('#heat', page), {cells: hm.cells, metric: hmMetric});
813
831
 
814
832
  // per-model stacked mix
815
833
  const byDay = new Map();
@@ -849,7 +867,8 @@ VIEWS.usage = async (page) => {
849
867
 
850
868
  /* ---------- burn & limits ---------- */
851
869
  VIEWS.burn = async (page) => {
852
- const burn = await api('burn');
870
+ const [burn, plan] = await Promise.all([api('burn'), fetch('/api/plan_history')
871
+ .then(r => r.json()).catch(e => ({ok: false, reason: e.message}))]);
853
872
  const bp = burn.period;
854
873
  const rows = Object.entries(burn.allowances);
855
874
  page.innerHTML = `
@@ -901,10 +920,31 @@ VIEWS.burn = async (page) => {
901
920
  <span class="mono">config/settings.json → limits</span> to enable usage-vs-limit,
902
921
  days-until-limit and limit-date projections.</p></div>`,
903
922
  {badge: a.configured ? BADGE.estimated : ''})).join('')}</div>
923
+ ${card('Plan limits over time', plan.ok ? `
924
+ <div class="grid g3">
925
+ ${kpi('5-hour peak', fmtPct(plan.summary.five_hour_peak ?? 0, 0), null, {badge: BADGE.actual})}
926
+ ${kpi('Times at 90%+ (5-hour)', fmtInt(plan.summary.five_hour_ge90),
927
+ `${fmtInt(plan.summary.five_hour_hit100)} reached 100%`)}
928
+ ${kpi('Weekly peak', fmtPct(plan.summary.weekly_peak ?? 0, 0),
929
+ `${fmtInt(plan.summary.weekly_ge90)} times at 90%+`)}
930
+ </div>
931
+ <div class="legend" id="planleg"></div><div class="chart" id="plan"></div>`
932
+ : `<div class="empty">${esc(plan.reason || 'Unavailable')}</div>`,
933
+ {badge: BADGE.actual, hint: '5-hour and weekly plan usage, % used',
934
+ footer: 'Read from the Claude desktop app\'s local plan-usage-history.json. '
935
+ + 'Its format is undocumented, so this card may go blank after an app update.'})}
904
936
  ${card('Daily consumption within the billing period', '<div class="chart" id="bs"></div>',
905
937
  {badge: BADGE.estimated})}`;
906
938
  for (const [k, a] of rows) if (a.configured)
907
939
  C.gauge($(`#g-${k}`, page), {pct: a.used_pct, status: a.status, label: 'of allowance'});
940
+ if (plan.ok) {
941
+ const ps = [{key: 'five_hour', label: '5-hour window', color: seriesVar(0), fmt: v => fmtPct(v, 0)},
942
+ {key: 'weekly', label: 'Weekly window', color: seriesVar(1), fmt: v => fmtPct(v, 0)}];
943
+ C.legend($('#planleg', page), ps.map(s => ({label: s.label, color: s.color})));
944
+ C.timeSeries($('#plan', page), {rows: plan.series, x: 't', type: 'line', series: ps,
945
+ fmt: v => fmtPct(v, 0), max: 100, height: 220,
946
+ xLabel: t => new Date(t).toLocaleString([], {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'})});
947
+ }
908
948
  C.timeSeries($('#bs', page), {rows: burn.series, x: 'day', type: 'bar',
909
949
  series: [{key: 'cost', label: 'Estimated cost', color: seriesVar(0), fmt: fmtUSD}],
910
950
  fmt: fmtUSD, height: 220, xLabel: shortDay});
package/web/charts.js CHANGED
@@ -70,7 +70,7 @@ export function timeSeries(host, opts) {
70
70
  const totals = rows.map(r => stacked
71
71
  ? active.reduce((a, s) => a + (+r[s.key] || 0), 0)
72
72
  : Math.max(...active.map(s => +r[s.key] || 0), 0));
73
- const max = nice(Math.max(...totals, 0) || 1);
73
+ const max = Math.max(opts.max || 0, nice(Math.max(...totals, 0) || 1));
74
74
  const y = v => m.t + ih - (v / max) * ih;
75
75
  const n = rows.length;
76
76
  const bw = Math.max(2, Math.min(34, iw / n * 0.72));
@@ -349,3 +349,36 @@ export function legend(host, items, onToggle) {
349
349
  host.appendChild(s);
350
350
  });
351
351
  }
352
+
353
+ /* ---------- heatmap: weekday x hour ---------- */
354
+ const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
355
+ export function heatmap(host, {cells, metric = 'cost'}) {
356
+ host.innerHTML = '';
357
+ const max = Math.max(0, ...cells.map(c => +c[metric] || 0));
358
+ if (!max) { host.innerHTML = '<div class="empty">No activity in range</div>'; return; }
359
+ const W = Math.max(host.clientWidth || 640, 320);
360
+ const m = {t: 4, r: 4, b: 18, l: 34};
361
+ const cw = (W - m.l - m.r) / 24, ch = Math.min(22, Math.max(12, cw * 0.8));
362
+ const H = m.t + ch * 7 + m.b;
363
+ const svg = el('svg', {viewBox: `0 0 ${W} ${H}`, height: H, role: 'img'});
364
+ DOW.forEach((d, i) => svg.appendChild(el('text', {x: m.l - 6, y: m.t + i * ch + ch * 0.68,
365
+ 'text-anchor': 'end'}, d)));
366
+ for (let h = 0; h < 24; h += 3) svg.appendChild(el('text', {x: m.l + h * cw + cw / 2,
367
+ y: H - 5, 'text-anchor': 'middle'}, String(h).padStart(2, '0')));
368
+ cells.forEach(c => {
369
+ const v = +c[metric] || 0;
370
+ const r = el('rect', {x: m.l + c.hour * cw + 1, y: m.t + c.dow * ch + 1,
371
+ width: Math.max(1, cw - 2), height: Math.max(1, ch - 2), rx: 2,
372
+ fill: v ? 'var(--s1)' : 'var(--grid)',
373
+ 'fill-opacity': v ? (0.15 + 0.85 * Math.sqrt(v / max)).toFixed(3) : 1});
374
+ r.addEventListener('mousemove', ev => showTip(
375
+ `<div class="t">${DOW[c.dow]} ${String(c.hour).padStart(2, '0')}:00</div>
376
+ <div class="row"><span class="k">Est. cost</span><span class="v">${fmtUSD(c.cost)}</span></div>
377
+ <div class="row"><span class="k">Tokens</span><span class="v">${fmtNum(c.tokens)}</span></div>
378
+ <div class="row"><span class="k">Requests</span><span class="v">${fmtInt(c.requests)}</span></div>`,
379
+ ev.clientX, ev.clientY));
380
+ r.addEventListener('mouseleave', hideTip);
381
+ svg.appendChild(r);
382
+ });
383
+ host.appendChild(svg);
384
+ }