cctally 1.6.1 → 1.6.3

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.
@@ -0,0 +1,1481 @@
1
+ """Share template registry.
2
+
3
+ Templates are pure-Python builders that produce ShareSnapshot instances
4
+ from panel-data + ShareOptions. The kernel (_lib_share.py) renders the
5
+ snapshots; templates own the data-to-snapshot composition.
6
+
7
+ Each template is identified by `<panel>-<archetype>` (e.g., `weekly-recap`).
8
+ Three archetypes per panel: recap, visual, detail.
9
+
10
+ This module ships in the public npm/brew distribution alongside
11
+ `bin/cctally` (promoted to public in .mirror-allowlist as of v1.6.2;
12
+ required by both the CLI `--format` surface and the dashboard share
13
+ GUI at runtime).
14
+
15
+ Spec: docs/superpowers/specs/2026-05-11-shareable-reports-v2-design.md §9
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from collections.abc import Callable, Mapping
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+
25
+ # --- Panel set ---
26
+ #
27
+ # Share-capable panels are the 8 data-view panels in the dashboard.
28
+ # RecentAlertsPanel ('alerts') is intentionally excluded: it's a
29
+ # notification stream, not a data view — shipping share templates over
30
+ # alerts would conflate the two concepts (spec §6.1, §9.5).
31
+ SHARE_CAPABLE_PANELS: frozenset[str] = frozenset({
32
+ "current-week",
33
+ "trend",
34
+ "weekly",
35
+ "daily",
36
+ "monthly",
37
+ "blocks",
38
+ "forecast",
39
+ "sessions",
40
+ })
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ShareTemplate:
45
+ id: str # globally unique: "<panel>-<archetype>"
46
+ panel: str # routing key
47
+ label: str # gallery tile heading ("Recap" / "Visual" / "Detail")
48
+ description: str # tile subhead
49
+ default_options: Mapping[str, Any]
50
+ builder: Callable[..., Any] # (panel_data, share_options) -> ShareSnapshot
51
+
52
+
53
+ # Filled in subsequent tasks (M1.4 adds the 8 Recap templates;
54
+ # M2.1 adds the 16 Visual + Detail templates).
55
+ SHARE_TEMPLATES: tuple[ShareTemplate, ...] = ()
56
+
57
+
58
+ # --- Import-time invariants ---
59
+ #
60
+ # These run at module import and fail loudly on registry inconsistencies,
61
+ # mirroring the migration-ordering guards in bin/cctally.
62
+
63
+ def _validate_registry() -> None:
64
+ ids = [t.id for t in SHARE_TEMPLATES]
65
+ if len(ids) != len(set(ids)):
66
+ dups = sorted({i for i in ids if ids.count(i) > 1})
67
+ raise RuntimeError(f"duplicate share template ids: {dups}")
68
+ panels_in = {t.panel for t in SHARE_TEMPLATES}
69
+ unknown = panels_in - SHARE_CAPABLE_PANELS
70
+ if unknown:
71
+ raise RuntimeError(f"share templates reference unknown panels: {sorted(unknown)}")
72
+ # NOTE: do NOT require panels_in == SHARE_CAPABLE_PANELS at import time
73
+ # for the M1 in-progress state (registry being populated task-by-task).
74
+ # The full-coverage assertion fires only once the registry is "complete",
75
+ # gated by ENV var so partial dev builds don't break.
76
+ if os.environ.get("CCTALLY_SHARE_TEMPLATES_REQUIRE_COMPLETE") == "1":
77
+ missing = SHARE_CAPABLE_PANELS - panels_in
78
+ if missing:
79
+ raise RuntimeError(f"share registry missing panels: {sorted(missing)}")
80
+
81
+
82
+ # NOTE: the authoritative `_validate_registry()` import-time call lives at
83
+ # the END of this module, after `SHARE_TEMPLATES` has been extended with all
84
+ # registered templates. Calling it here (with an empty registry) was harmless
85
+ # under M1.3's scaffold-only state but blows up under
86
+ # `CCTALLY_SHARE_TEMPLATES_REQUIRE_COMPLETE=1` because the partial registry
87
+ # is "missing every panel." Defer the single gate to the bottom of the file.
88
+
89
+
90
+ # --- Lookup helpers (consumed by /api/share/render and /templates) ---
91
+
92
+ def templates_for_panel(panel: str) -> tuple[ShareTemplate, ...]:
93
+ return tuple(t for t in SHARE_TEMPLATES if t.panel == panel)
94
+
95
+
96
+ def get_template(template_id: str) -> ShareTemplate:
97
+ for t in SHARE_TEMPLATES:
98
+ if t.id == template_id:
99
+ return t
100
+ raise KeyError(template_id)
101
+
102
+
103
+ # --- Shared builder helpers ---
104
+
105
+ import datetime as _dt
106
+
107
+
108
+ def _import_share_lib():
109
+ """Module-load import, scoped here for testability — no cycle exists today.
110
+
111
+ `_lib_share` is a pure stdlib sibling with no imports from this module, so
112
+ a top-level `from _lib_share import ...` would work. Loading via a path
113
+ spec instead keeps `_lib_share_templates` importable from both the in-tree
114
+ test harness (which loads `bin/_lib_share_templates.py` by file path) and
115
+ from `bin/cctally` (which also loads `_lib_share.py` by file path via
116
+ `_share_load_lib`). One module instance is exposed as `_LS`.
117
+
118
+ The loaded module MUST be registered in `sys.modules` before
119
+ `exec_module` runs: Python 3.14's `@dataclass` decorator resolves
120
+ `cls.__module__` via `sys.modules.get(...)` while building the field
121
+ type-check, and would AttributeError on `None.__dict__` otherwise.
122
+ """
123
+ from pathlib import Path
124
+ import importlib.util
125
+ import sys
126
+ if "_lib_share" in sys.modules:
127
+ return sys.modules["_lib_share"]
128
+ p = Path(__file__).resolve().parent / "_lib_share.py"
129
+ spec = importlib.util.spec_from_file_location("_lib_share", p)
130
+ m = importlib.util.module_from_spec(spec)
131
+ sys.modules["_lib_share"] = m
132
+ try:
133
+ spec.loader.exec_module(m)
134
+ except Exception:
135
+ sys.modules.pop("_lib_share", None)
136
+ raise
137
+ return m
138
+
139
+
140
+ _LS = _import_share_lib()
141
+
142
+
143
+ def _kpi_strip(*items: tuple[str, str]) -> tuple:
144
+ """Generic KPI strip → tuple of `Totalled`."""
145
+ return tuple(_LS.Totalled(label=lbl, value=val) for lbl, val in items)
146
+
147
+
148
+ def _top_projects_rows(top_projects, cap: int) -> tuple:
149
+ """Build `Row` tuple with ProjectCell + MoneyCell from a list of
150
+ `(project_path, cost_usd)` pairs.
151
+
152
+ Anonymization happens later in `_scrub()` — builders always emit real
153
+ names. Accepts both 2-tuples and `(path, cost, ...)` longer tuples;
154
+ only the first two positional elements are used so callers can pass
155
+ enriched rows without copy-coercion.
156
+ """
157
+ rows = []
158
+ for entry in (top_projects or [])[:cap]:
159
+ path = entry[0]
160
+ cost = float(entry[1] or 0.0)
161
+ rows.append(_LS.Row(cells={
162
+ "project": _LS.ProjectCell(label=path),
163
+ "cost": _LS.MoneyCell(usd=cost),
164
+ }))
165
+ return tuple(rows)
166
+
167
+
168
+ _PROJECT_COLUMNS = (
169
+ _LS.ColumnSpec(key="project", label="Project", align="left"),
170
+ _LS.ColumnSpec(key="cost", label="$", align="right", emphasis=True),
171
+ )
172
+
173
+
174
+ def _utc_now() -> _dt.datetime:
175
+ """Override-aware UTC now (per `CCTALLY_AS_OF` env hook for fixture tests)."""
176
+ s = os.environ.get("CCTALLY_AS_OF")
177
+ if s:
178
+ parsed = _dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
179
+ if parsed.tzinfo is None:
180
+ parsed = parsed.replace(tzinfo=_dt.timezone.utc)
181
+ return parsed
182
+ return _dt.datetime.now(_dt.timezone.utc)
183
+
184
+
185
+ def _release_version() -> str:
186
+ """Read CHANGELOG-stamped latest version.
187
+
188
+ Honors `CCTALLY_TEST_CHANGELOG_PATH` override (the documented test pattern
189
+ at `bin/cctally:86`). Falls back to `"dev"` when CHANGELOG is unreadable
190
+ or has no stamped release entry yet (pre-release dev builds).
191
+
192
+ Parallel to `_release_read_latest_release_version` in `bin/cctally` —
193
+ intentionally duplicated so the template module stays free of any
194
+ `bin/cctally` import. If CHANGELOG header format changes, update both.
195
+ """
196
+ from pathlib import Path
197
+ p = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
198
+ if p:
199
+ path = Path(p)
200
+ else:
201
+ path = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
202
+ try:
203
+ for line in path.read_text(encoding="utf-8").splitlines():
204
+ if line.startswith("## [") and "Unreleased" not in line:
205
+ # "## [1.5.0] - 2026-05-11" → "1.5.0"
206
+ return line.split("[", 1)[1].split("]", 1)[0]
207
+ except OSError:
208
+ pass
209
+ return "dev"
210
+
211
+
212
+ def _parse_iso_utc(s: str) -> _dt.datetime:
213
+ """Parse an ISO-8601 string into a UTC-aware datetime.
214
+
215
+ Accepts both `Z` and `+HH:MM` suffixes. Naive inputs are interpreted as
216
+ UTC (matches the rest of the share kernel — JSON output always emits `Z`).
217
+ """
218
+ parsed = _dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
219
+ if parsed.tzinfo is None:
220
+ parsed = parsed.replace(tzinfo=_dt.timezone.utc)
221
+ return parsed.astimezone(_dt.timezone.utc)
222
+
223
+
224
+ def _period(start, end, *, label: str, display_tz: str):
225
+ return _LS.PeriodSpec(start=start, end=end,
226
+ display_tz=display_tz, label=label)
227
+
228
+
229
+ def _display_tz(options) -> str:
230
+ return options.get("display_tz", "Etc/UTC")
231
+
232
+
233
+ # --- 8 Recap builders ---
234
+
235
+
236
+ def _build_weekly_recap(*, panel_data, options):
237
+ """Weekly recap — balanced KPI + 8-week cost line + top-N projects.
238
+
239
+ Expected panel_data shape (produced by M1.6's `_build_weekly_share_panel_data`):
240
+ {
241
+ "weeks": [
242
+ {"start_date": "YYYY-MM-DD", # ISO date string
243
+ "cost_usd": float,
244
+ "pct_used": float, # fraction 0..1
245
+ "dollar_per_pct": float,
246
+ "top_projects": [(path, cost), ...]},
247
+ ... up to 8 weeks, chronological ...
248
+ ],
249
+ "current_week_index": int, # index into weeks[]
250
+ }
251
+ """
252
+ weeks = panel_data["weeks"]
253
+ idx = panel_data.get("current_week_index", 0)
254
+ w = weeks[idx]
255
+ start = _parse_iso_utc(w["start_date"])
256
+ end = start + _dt.timedelta(days=6)
257
+ return _LS.ShareSnapshot(
258
+ cmd="weekly",
259
+ title=f"Weekly recap — week of {w['start_date']}",
260
+ subtitle=None,
261
+ period=_period(start, end, label="This week", display_tz=_display_tz(options)),
262
+ columns=_PROJECT_COLUMNS,
263
+ rows=_top_projects_rows(w.get("top_projects") or [], options.get("top_n", 5)),
264
+ chart=_LS.LineChart(
265
+ points=tuple(
266
+ _LS.ChartPoint(x_label=w2["start_date"], x_value=float(i),
267
+ y_value=float(w2["cost_usd"]))
268
+ for i, w2 in enumerate(weeks)
269
+ ),
270
+ y_label="$ / week",
271
+ reference_lines=(),
272
+ ),
273
+ totals=_kpi_strip(
274
+ ("$ spent", f"${w['cost_usd']:.2f}"),
275
+ ("% used", f"{w['pct_used']*100:.1f}%"),
276
+ ("$/% rate", f"${w['dollar_per_pct']:.3f}"),
277
+ ),
278
+ notes=(),
279
+ generated_at=_utc_now(),
280
+ version=_release_version(),
281
+ )
282
+
283
+
284
+ def _build_current_week_recap(*, panel_data, options):
285
+ """Current-week recap — week-to-date KPI strip + daily line + top-3 projects.
286
+
287
+ CurrentWeekPanel has no 1:1 CLI counterpart (spec §9.5); panel_data is
288
+ synthesized in M1.6 from the dashboard envelope.
289
+
290
+ Expected panel_data shape:
291
+ {
292
+ "kpi_cost_usd": float,
293
+ "kpi_pct_used": float, # fraction 0..1
294
+ "kpi_dollar_per_pct": float,
295
+ "kpi_days_remaining": float,
296
+ "daily_progression": [{"date": "YYYY-MM-DD",
297
+ "cost_usd": float}, ...], # ≤7
298
+ "top_projects": [(path, cost), ...],
299
+ "week_start_date": "YYYY-MM-DD",
300
+ "display_tz": "Etc/UTC" | "...",
301
+ }
302
+ """
303
+ progression = panel_data.get("daily_progression") or []
304
+ start = _parse_iso_utc(panel_data["week_start_date"])
305
+ end = start + _dt.timedelta(days=6)
306
+ today_label = progression[-1]["date"] if progression else panel_data["week_start_date"]
307
+ return _LS.ShareSnapshot(
308
+ cmd="current-week",
309
+ title=f"Current week — through {today_label}",
310
+ subtitle=None,
311
+ period=_period(start, end, label="This week", display_tz=_display_tz(options)),
312
+ columns=_PROJECT_COLUMNS,
313
+ rows=_top_projects_rows(panel_data.get("top_projects") or [],
314
+ options.get("top_n", 3)),
315
+ chart=_LS.LineChart(
316
+ points=tuple(
317
+ _LS.ChartPoint(x_label=d["date"], x_value=float(i),
318
+ y_value=float(d["cost_usd"]))
319
+ for i, d in enumerate(progression)
320
+ ),
321
+ y_label="$ / day",
322
+ reference_lines=(),
323
+ ) if progression else None,
324
+ totals=_kpi_strip(
325
+ ("$ spent", f"${panel_data['kpi_cost_usd']:.2f}"),
326
+ ("% used", f"{panel_data['kpi_pct_used']*100:.1f}%"),
327
+ ("$/% rate", f"${panel_data['kpi_dollar_per_pct']:.3f}"),
328
+ ("Days remaining", f"{panel_data['kpi_days_remaining']:.1f}"),
329
+ ),
330
+ notes=(),
331
+ generated_at=_utc_now(),
332
+ version=_release_version(),
333
+ )
334
+
335
+
336
+ def _build_trend_recap(*, panel_data, options):
337
+ """Trend recap — $/% line over 8 weeks + 3-week delta KPI.
338
+
339
+ Maps to CLI `report` subcommand (dashboard panel: `trend`).
340
+
341
+ Expected panel_data shape:
342
+ {
343
+ "weeks": [
344
+ {"start_date": "YYYY-MM-DD",
345
+ "cost_usd": float,
346
+ "pct_used": float,
347
+ "dollar_per_pct": float}, ... 8 entries, chronological ...
348
+ ],
349
+ "delta_3_weeks": {
350
+ "dpp_change_pct": float, # +ve = $/% trending up
351
+ "cost_change_usd": float,
352
+ },
353
+ }
354
+ """
355
+ weeks = panel_data["weeks"]
356
+ start = _parse_iso_utc(weeks[0]["start_date"]) if weeks else _utc_now()
357
+ end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else _utc_now()
358
+ end = end_anchor + _dt.timedelta(days=6)
359
+ delta = panel_data.get("delta_3_weeks") or {}
360
+ return _LS.ShareSnapshot(
361
+ cmd="report",
362
+ title="$/% trend — last 8 weeks",
363
+ subtitle=None,
364
+ period=_period(start, end, label="Last 8 weeks", display_tz=_display_tz(options)),
365
+ columns=(
366
+ _LS.ColumnSpec(key="week", label="Week", align="left"),
367
+ _LS.ColumnSpec(key="cost", label="$", align="right", emphasis=True),
368
+ _LS.ColumnSpec(key="pct", label="% used", align="right"),
369
+ _LS.ColumnSpec(key="dpp", label="$/%", align="right"),
370
+ ),
371
+ rows=tuple(
372
+ _LS.Row(cells={
373
+ "week": _LS.TextCell(w["start_date"]),
374
+ "cost": _LS.MoneyCell(float(w["cost_usd"])),
375
+ "pct": _LS.PercentCell(float(w["pct_used"]) * 100.0),
376
+ "dpp": _LS.MoneyCell(float(w["dollar_per_pct"])),
377
+ })
378
+ for w in weeks
379
+ ),
380
+ chart=_LS.LineChart(
381
+ points=tuple(
382
+ _LS.ChartPoint(x_label=w["start_date"], x_value=float(i),
383
+ y_value=float(w["dollar_per_pct"]))
384
+ for i, w in enumerate(weeks)
385
+ ),
386
+ y_label="$ / 1%",
387
+ reference_lines=(),
388
+ ) if weeks else None,
389
+ totals=_kpi_strip(
390
+ ("Δ $/% (3wk)", f"{float(delta.get('dpp_change_pct') or 0.0)*100:+.1f}%"),
391
+ ("Δ $ (3wk)", f"${float(delta.get('cost_change_usd') or 0.0):+,.2f}"),
392
+ ),
393
+ notes=(),
394
+ generated_at=_utc_now(),
395
+ version=_release_version(),
396
+ )
397
+
398
+
399
+ def _build_daily_recap(*, panel_data, options):
400
+ """Daily recap — 7-day cost bar + top-5 projects.
401
+
402
+ Maps to CLI `daily` (dashboard panel: `daily`).
403
+
404
+ Expected panel_data shape:
405
+ {
406
+ "days": [{"date": "YYYY-MM-DD",
407
+ "cost_usd": float,
408
+ "pct_of_period": float,
409
+ "top_model": str}, ...], # 7 entries, chronological
410
+ "top_projects": [(path, cost), ...],
411
+ }
412
+ """
413
+ days = panel_data.get("days") or []
414
+ start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
415
+ end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
416
+ end = end_anchor + _dt.timedelta(days=1)
417
+ sum_cost = sum(float(d["cost_usd"]) for d in days)
418
+ return _LS.ShareSnapshot(
419
+ cmd="daily",
420
+ title=f"Daily — last {len(days)} day{'s' if len(days) != 1 else ''}",
421
+ subtitle=None,
422
+ period=_period(start, end, label="Last 7 days", display_tz=_display_tz(options)),
423
+ columns=_PROJECT_COLUMNS,
424
+ rows=_top_projects_rows(panel_data.get("top_projects") or [],
425
+ options.get("top_n", 5)),
426
+ chart=_LS.BarChart(
427
+ points=tuple(
428
+ _LS.ChartPoint(x_label=d["date"], x_value=float(i),
429
+ y_value=float(d["cost_usd"]))
430
+ for i, d in enumerate(days)
431
+ ),
432
+ y_label="$ / day",
433
+ ) if days else None,
434
+ totals=_kpi_strip(
435
+ ("Sum", f"${sum_cost:,.2f}"),
436
+ ("Daily avg", f"${(sum_cost / len(days) if days else 0.0):,.2f}"),
437
+ ),
438
+ notes=(),
439
+ generated_at=_utc_now(),
440
+ version=_release_version(),
441
+ )
442
+
443
+
444
+ def _build_monthly_recap(*, panel_data, options):
445
+ """Monthly recap — per-month bar + KPI strip + top-N projects.
446
+
447
+ Maps to CLI `monthly` (dashboard panel: `monthly`).
448
+
449
+ Expected panel_data shape:
450
+ {
451
+ "months": [{"month": "YYYY-MM",
452
+ "cost_usd": float,
453
+ "pct_used": float, # fraction 0..1; may be 0
454
+ "top_model": str}, ...], # chronological
455
+ "top_projects": [(path, cost), ...],
456
+ }
457
+ """
458
+ months = panel_data.get("months") or []
459
+
460
+ def _month_start(s):
461
+ return _parse_iso_utc(f"{s}-01")
462
+
463
+ start = _month_start(months[0]["month"]) if months else _utc_now()
464
+ if months:
465
+ last = _month_start(months[-1]["month"])
466
+ # End of last month: simple 31-day forward, then truncate to month end.
467
+ end_anchor = last.replace(day=28) + _dt.timedelta(days=4)
468
+ end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
469
+ else:
470
+ end = start
471
+ sum_cost = sum(float(m["cost_usd"]) for m in months)
472
+ return _LS.ShareSnapshot(
473
+ cmd="monthly",
474
+ title=f"Monthly — last {len(months)} month{'s' if len(months) != 1 else ''}",
475
+ subtitle=None,
476
+ period=_period(start, end, label="Recent months",
477
+ display_tz=_display_tz(options)),
478
+ columns=_PROJECT_COLUMNS,
479
+ rows=_top_projects_rows(panel_data.get("top_projects") or [],
480
+ options.get("top_n", 5)),
481
+ chart=_LS.BarChart(
482
+ points=tuple(
483
+ _LS.ChartPoint(x_label=m["month"], x_value=float(i),
484
+ y_value=float(m["cost_usd"]))
485
+ for i, m in enumerate(months)
486
+ ),
487
+ y_label="$ / month",
488
+ ) if months else None,
489
+ totals=_kpi_strip(
490
+ ("Sum", f"${sum_cost:,.2f}"),
491
+ ("Monthly avg", f"${(sum_cost / len(months) if months else 0.0):,.2f}"),
492
+ ),
493
+ notes=(),
494
+ generated_at=_utc_now(),
495
+ version=_release_version(),
496
+ )
497
+
498
+
499
+ def _build_blocks_recap(*, panel_data, options):
500
+ """Blocks recap — current 5h block KPI + recent-blocks line + top-3 projects.
501
+
502
+ Maps to CLI `five-hour-blocks` (dashboard panel: `blocks`).
503
+
504
+ Expected panel_data shape:
505
+ {
506
+ "current_block": {"start_at": "ISO datetime",
507
+ "end_at": "ISO datetime",
508
+ "cost_usd": float,
509
+ "pct_used": float, # fraction 0..1
510
+ "tokens_total": int},
511
+ "recent_blocks": [{"start_at": "ISO", "cost_usd": float}, ...], # ≤8
512
+ "top_projects": [(path, cost), ...],
513
+ }
514
+ """
515
+ cb = panel_data.get("current_block") or {}
516
+ recent = panel_data.get("recent_blocks") or []
517
+ start = _parse_iso_utc(cb["start_at"]) if cb.get("start_at") else _utc_now()
518
+ end = _parse_iso_utc(cb["end_at"]) if cb.get("end_at") else start + _dt.timedelta(hours=5)
519
+ return _LS.ShareSnapshot(
520
+ cmd="five-hour-blocks",
521
+ title="Current 5-hour block",
522
+ subtitle=None,
523
+ period=_period(start, end, label="Current block",
524
+ display_tz=_display_tz(options)),
525
+ columns=_PROJECT_COLUMNS,
526
+ rows=_top_projects_rows(panel_data.get("top_projects") or [],
527
+ options.get("top_n", 3)),
528
+ chart=_LS.LineChart(
529
+ points=tuple(
530
+ _LS.ChartPoint(x_label=b["start_at"], x_value=float(i),
531
+ y_value=float(b["cost_usd"]))
532
+ for i, b in enumerate(recent)
533
+ ),
534
+ y_label="$ / block",
535
+ reference_lines=(),
536
+ ) if recent else None,
537
+ totals=_kpi_strip(
538
+ ("$ this block", f"${float(cb.get('cost_usd') or 0.0):.2f}"),
539
+ ("% used", f"{float(cb.get('pct_used') or 0.0)*100:.1f}%"),
540
+ ("Tokens", f"{int(cb.get('tokens_total') or 0):,}"),
541
+ ),
542
+ notes=(),
543
+ generated_at=_utc_now(),
544
+ version=_release_version(),
545
+ )
546
+
547
+
548
+ def _build_forecast_recap(*, panel_data, options):
549
+ """Forecast recap — projection chart + budget table + days-to-ceiling KPIs.
550
+
551
+ Maps to CLI `forecast` (dashboard panel: `forecast`).
552
+
553
+ Expected panel_data shape:
554
+ {
555
+ "projected_end_pct": float, # fraction 0..1+
556
+ "days_to_100pct": float,
557
+ "days_to_90pct": float,
558
+ "daily_budgets": {
559
+ "avg": float, # $/day to-date
560
+ "recent_24h": float,
561
+ "until_90pct": float,
562
+ "until_100pct": float,
563
+ },
564
+ "projection_curve": [{"date": "YYYY-MM-DD",
565
+ "projected_pct_used": float}, # fraction
566
+ ...], # ≤7 entries
567
+ "confidence": "ok" | "LOW CONF",
568
+ }
569
+ """
570
+ curve = panel_data.get("projection_curve") or []
571
+ budgets = panel_data.get("daily_budgets") or {}
572
+ start = _parse_iso_utc(curve[0]["date"]) if curve else _utc_now()
573
+ end_anchor = _parse_iso_utc(curve[-1]["date"]) if curve else start
574
+ end = end_anchor + _dt.timedelta(days=1)
575
+ confidence = panel_data.get("confidence") or "ok"
576
+ notes = ("LOW CONF: insufficient samples",) if confidence == "LOW CONF" else ()
577
+ return _LS.ShareSnapshot(
578
+ cmd="forecast",
579
+ title="Forecast — projection to ceiling",
580
+ subtitle=None,
581
+ period=_period(start, end, label="Next 7 days",
582
+ display_tz=_display_tz(options)),
583
+ columns=(
584
+ _LS.ColumnSpec(key="metric", label="Metric", align="left"),
585
+ _LS.ColumnSpec(key="value", label="$/day", align="right", emphasis=True),
586
+ ),
587
+ rows=(
588
+ _LS.Row(cells={"metric": _LS.TextCell("Avg to-date"),
589
+ "value": _LS.MoneyCell(float(budgets.get("avg") or 0.0))}),
590
+ _LS.Row(cells={"metric": _LS.TextCell("Recent 24h"),
591
+ "value": _LS.MoneyCell(float(budgets.get("recent_24h") or 0.0))}),
592
+ _LS.Row(cells={"metric": _LS.TextCell("Budget to 90%"),
593
+ "value": _LS.MoneyCell(float(budgets.get("until_90pct") or 0.0))}),
594
+ _LS.Row(cells={"metric": _LS.TextCell("Budget to 100%"),
595
+ "value": _LS.MoneyCell(float(budgets.get("until_100pct") or 0.0))}),
596
+ ),
597
+ chart=_LS.LineChart(
598
+ points=tuple(
599
+ _LS.ChartPoint(x_label=p["date"], x_value=float(i),
600
+ y_value=float(p["projected_pct_used"]) * 100.0)
601
+ for i, p in enumerate(curve)
602
+ ),
603
+ y_label="projected %",
604
+ reference_lines=(
605
+ (90.0, "90%", "warn"),
606
+ (100.0, "100%", "alarm"),
607
+ ),
608
+ ) if curve else None,
609
+ totals=_kpi_strip(
610
+ ("Days→90%", f"{float(panel_data.get('days_to_90pct') or 0.0):.1f}"),
611
+ ("Days→100%", f"{float(panel_data.get('days_to_100pct') or 0.0):.1f}"),
612
+ ("End %", f"{float(panel_data.get('projected_end_pct') or 0.0)*100:.1f}%"),
613
+ ),
614
+ notes=notes,
615
+ generated_at=_utc_now(),
616
+ version=_release_version(),
617
+ )
618
+
619
+
620
+ def _build_sessions_recap(*, panel_data, options):
621
+ """Sessions recap — top-N sessions table + total (no chart per spec §9.5).
622
+
623
+ Maps to CLI `session` (dashboard panel: `sessions`). Default `top_n` = 15
624
+ per spec §9.6.
625
+
626
+ Expected panel_data shape:
627
+ {
628
+ "sessions": [
629
+ {"session_id": str,
630
+ "project_path": str,
631
+ "cost_usd": float,
632
+ "started_at": "ISO datetime",
633
+ "model": str},
634
+ ... already sorted desc by cost, length ≤ top_n cap upstream ...
635
+ ],
636
+ }
637
+ """
638
+ sessions = panel_data.get("sessions") or []
639
+ cap = options.get("top_n", 15)
640
+ rows_iter = sessions[:cap]
641
+ sum_cost = sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
642
+ starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
643
+ start = min(starts) if starts else _utc_now()
644
+ end = max(starts) if starts else start
645
+ return _LS.ShareSnapshot(
646
+ cmd="session",
647
+ title=f"Sessions — top {len(rows_iter)}",
648
+ subtitle=None,
649
+ period=_period(start, end, label="Recent sessions",
650
+ display_tz=_display_tz(options)),
651
+ columns=(
652
+ _LS.ColumnSpec(key="started", label="Started", align="left"),
653
+ _LS.ColumnSpec(key="project", label="Project", align="left"),
654
+ _LS.ColumnSpec(key="model", label="Model", align="left"),
655
+ _LS.ColumnSpec(key="cost", label="$", align="right",
656
+ emphasis=True),
657
+ ),
658
+ rows=tuple(
659
+ _LS.Row(cells={
660
+ "started": (_LS.DateCell(when=_parse_iso_utc(s["started_at"]))
661
+ if s.get("started_at")
662
+ else _LS.TextCell("")),
663
+ "project": _LS.ProjectCell(label=str(s.get("project_path") or "")),
664
+ "model": _LS.TextCell(str(s.get("model") or "")),
665
+ "cost": _LS.MoneyCell(float(s.get("cost_usd") or 0.0)),
666
+ })
667
+ for s in rows_iter
668
+ ),
669
+ chart=None,
670
+ totals=_kpi_strip(
671
+ ("Sum", f"${sum_cost:,.2f}"),
672
+ ("Shown", f"{len(rows_iter)}"),
673
+ ),
674
+ notes=(),
675
+ generated_at=_utc_now(),
676
+ version=_release_version(),
677
+ )
678
+
679
+
680
+ # --- 16 Visual + Detail builders ---
681
+ #
682
+ # Archetype contract (spec §9.4):
683
+ # - Visual: chart populated (same density as Recap), `rows=()`, `columns=()`,
684
+ # `top_n=8` (default_options). Visuals drop the table entirely.
685
+ # - Detail: chart populated, full table (`top_n=50`), columns same as Recap.
686
+ #
687
+ # Each Visual/Detail mirrors its Recap sibling's panel_data indexing,
688
+ # period assembly, and chart construction. Only `title`, `columns`/`rows`,
689
+ # and (where chart space permits trimming) `totals` differ.
690
+
691
+
692
+ def _build_weekly_visual(*, panel_data, options):
693
+ """Weekly visual — chart-only, table dropped (spec §9.5).
694
+
695
+ Same panel_data shape as `_build_weekly_recap`; Visual differs by
696
+ emitting `rows=()` and `columns=()`. Chart density unchanged.
697
+ """
698
+ weeks = panel_data["weeks"]
699
+ idx = panel_data.get("current_week_index", 0)
700
+ w = weeks[idx]
701
+ start = _parse_iso_utc(w["start_date"])
702
+ end = start + _dt.timedelta(days=6)
703
+ return _LS.ShareSnapshot(
704
+ cmd="weekly",
705
+ title=f"Weekly visual — week of {w['start_date']}",
706
+ subtitle=None,
707
+ period=_period(start, end, label="This week", display_tz=_display_tz(options)),
708
+ columns=(),
709
+ rows=(),
710
+ chart=_LS.LineChart(
711
+ points=tuple(
712
+ _LS.ChartPoint(x_label=w2["start_date"], x_value=float(i),
713
+ y_value=float(w2["cost_usd"]))
714
+ for i, w2 in enumerate(weeks)
715
+ ),
716
+ y_label="$ / week",
717
+ reference_lines=(),
718
+ ),
719
+ totals=_kpi_strip(
720
+ ("$ spent", f"${w['cost_usd']:.2f}"),
721
+ ("% used", f"{w['pct_used']*100:.1f}%"),
722
+ ),
723
+ notes=(),
724
+ generated_at=_utc_now(),
725
+ version=_release_version(),
726
+ )
727
+
728
+
729
+ def _build_weekly_detail(*, panel_data, options):
730
+ """Weekly detail — full per-week × per-project table (spec §9.5).
731
+
732
+ Same panel_data shape as `_build_weekly_recap`; Detail uses
733
+ `top_n=50` (or higher) and includes all projects as table rows alongside
734
+ the chart.
735
+
736
+ NOTE: ships as per-project table; spec §9.5 calls for per-week × per-model
737
+ cross-tab — deferred until `_build_weekly_share_panel_data` carries the
738
+ cross-tab series (see issue #33).
739
+ """
740
+ weeks = panel_data["weeks"]
741
+ idx = panel_data.get("current_week_index", 0)
742
+ w = weeks[idx]
743
+ start = _parse_iso_utc(w["start_date"])
744
+ end = start + _dt.timedelta(days=6)
745
+ top_n = max(int(options.get("top_n", 50)), 1)
746
+ return _LS.ShareSnapshot(
747
+ cmd="weekly",
748
+ title=f"Weekly detail — week of {w['start_date']}",
749
+ subtitle=None,
750
+ period=_period(start, end, label="This week", display_tz=_display_tz(options)),
751
+ columns=_PROJECT_COLUMNS,
752
+ rows=_top_projects_rows(w.get("top_projects") or [], top_n),
753
+ chart=_LS.LineChart(
754
+ points=tuple(
755
+ _LS.ChartPoint(x_label=w2["start_date"], x_value=float(i),
756
+ y_value=float(w2["cost_usd"]))
757
+ for i, w2 in enumerate(weeks)
758
+ ),
759
+ y_label="$ / week",
760
+ reference_lines=(),
761
+ ),
762
+ totals=_kpi_strip(
763
+ ("$ spent", f"${w['cost_usd']:.2f}"),
764
+ ("% used", f"{w['pct_used']*100:.1f}%"),
765
+ ("$/% rate", f"${w['dollar_per_pct']:.3f}"),
766
+ ),
767
+ notes=(),
768
+ generated_at=_utc_now(),
769
+ version=_release_version(),
770
+ )
771
+
772
+
773
+ def _build_current_week_visual(*, panel_data, options):
774
+ """Current-week visual — week-to-date line, rows=() (spec §9.5)."""
775
+ progression = panel_data.get("daily_progression") or []
776
+ start = _parse_iso_utc(panel_data["week_start_date"])
777
+ end = start + _dt.timedelta(days=6)
778
+ today_label = progression[-1]["date"] if progression else panel_data["week_start_date"]
779
+ return _LS.ShareSnapshot(
780
+ cmd="current-week",
781
+ title=f"Current week visual — through {today_label}",
782
+ subtitle=None,
783
+ period=_period(start, end, label="This week", display_tz=_display_tz(options)),
784
+ columns=(),
785
+ rows=(),
786
+ chart=_LS.LineChart(
787
+ points=tuple(
788
+ _LS.ChartPoint(x_label=d["date"], x_value=float(i),
789
+ y_value=float(d["cost_usd"]))
790
+ for i, d in enumerate(progression)
791
+ ),
792
+ y_label="$ / day",
793
+ reference_lines=(),
794
+ ) if progression else None,
795
+ totals=_kpi_strip(
796
+ ("$ spent", f"${panel_data['kpi_cost_usd']:.2f}"),
797
+ ("% used", f"{panel_data['kpi_pct_used']*100:.1f}%"),
798
+ ),
799
+ notes=(),
800
+ generated_at=_utc_now(),
801
+ version=_release_version(),
802
+ )
803
+
804
+
805
+ def _build_current_week_detail(*, panel_data, options):
806
+ """Current-week detail — per-project full table + chart (spec §9.5)."""
807
+ progression = panel_data.get("daily_progression") or []
808
+ start = _parse_iso_utc(panel_data["week_start_date"])
809
+ end = start + _dt.timedelta(days=6)
810
+ today_label = progression[-1]["date"] if progression else panel_data["week_start_date"]
811
+ top_n = max(int(options.get("top_n", 50)), 1)
812
+ return _LS.ShareSnapshot(
813
+ cmd="current-week",
814
+ title=f"Current week detail — through {today_label}",
815
+ subtitle=None,
816
+ period=_period(start, end, label="This week", display_tz=_display_tz(options)),
817
+ columns=_PROJECT_COLUMNS,
818
+ rows=_top_projects_rows(panel_data.get("top_projects") or [], top_n),
819
+ chart=_LS.LineChart(
820
+ points=tuple(
821
+ _LS.ChartPoint(x_label=d["date"], x_value=float(i),
822
+ y_value=float(d["cost_usd"]))
823
+ for i, d in enumerate(progression)
824
+ ),
825
+ y_label="$ / day",
826
+ reference_lines=(),
827
+ ) if progression else None,
828
+ totals=_kpi_strip(
829
+ ("$ spent", f"${panel_data['kpi_cost_usd']:.2f}"),
830
+ ("% used", f"{panel_data['kpi_pct_used']*100:.1f}%"),
831
+ ("$/% rate", f"${panel_data['kpi_dollar_per_pct']:.3f}"),
832
+ ("Days remaining", f"{panel_data['kpi_days_remaining']:.1f}"),
833
+ ),
834
+ notes=(),
835
+ generated_at=_utc_now(),
836
+ version=_release_version(),
837
+ )
838
+
839
+
840
+ def _build_trend_visual(*, panel_data, options):
841
+ """Trend visual — $/% trend line over 8 weeks; rows=() (spec §9.5)."""
842
+ weeks = panel_data["weeks"]
843
+ start = _parse_iso_utc(weeks[0]["start_date"]) if weeks else _utc_now()
844
+ end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else _utc_now()
845
+ end = end_anchor + _dt.timedelta(days=6)
846
+ delta = panel_data.get("delta_3_weeks") or {}
847
+ return _LS.ShareSnapshot(
848
+ cmd="report",
849
+ title="$/% trend visual — last 8 weeks",
850
+ subtitle=None,
851
+ period=_period(start, end, label="Last 8 weeks", display_tz=_display_tz(options)),
852
+ columns=(),
853
+ rows=(),
854
+ chart=_LS.LineChart(
855
+ points=tuple(
856
+ _LS.ChartPoint(x_label=w["start_date"], x_value=float(i),
857
+ y_value=float(w["dollar_per_pct"]))
858
+ for i, w in enumerate(weeks)
859
+ ),
860
+ y_label="$ / 1%",
861
+ reference_lines=(),
862
+ ) if weeks else None,
863
+ totals=_kpi_strip(
864
+ ("Δ $/% (3wk)", f"{float(delta.get('dpp_change_pct') or 0.0)*100:+.1f}%"),
865
+ ("Δ $ (3wk)", f"${float(delta.get('cost_change_usd') or 0.0):+,.2f}"),
866
+ ),
867
+ notes=(),
868
+ generated_at=_utc_now(),
869
+ version=_release_version(),
870
+ )
871
+
872
+
873
+ def _build_trend_detail(*, panel_data, options):
874
+ """Trend detail — full 8-week × $/%/rate table + sparkline (spec §9.5)."""
875
+ weeks = panel_data["weeks"]
876
+ start = _parse_iso_utc(weeks[0]["start_date"]) if weeks else _utc_now()
877
+ end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else _utc_now()
878
+ end = end_anchor + _dt.timedelta(days=6)
879
+ delta = panel_data.get("delta_3_weeks") or {}
880
+ return _LS.ShareSnapshot(
881
+ cmd="report",
882
+ title="$/% trend detail — last 8 weeks",
883
+ subtitle=None,
884
+ period=_period(start, end, label="Last 8 weeks", display_tz=_display_tz(options)),
885
+ columns=(
886
+ _LS.ColumnSpec(key="week", label="Week", align="left"),
887
+ _LS.ColumnSpec(key="cost", label="$", align="right", emphasis=True),
888
+ _LS.ColumnSpec(key="pct", label="% used", align="right"),
889
+ _LS.ColumnSpec(key="dpp", label="$/%", align="right"),
890
+ ),
891
+ rows=tuple(
892
+ _LS.Row(cells={
893
+ "week": _LS.TextCell(w["start_date"]),
894
+ "cost": _LS.MoneyCell(float(w["cost_usd"])),
895
+ "pct": _LS.PercentCell(float(w["pct_used"]) * 100.0),
896
+ "dpp": _LS.MoneyCell(float(w["dollar_per_pct"])),
897
+ })
898
+ for w in weeks
899
+ ),
900
+ chart=_LS.LineChart(
901
+ points=tuple(
902
+ _LS.ChartPoint(x_label=w["start_date"], x_value=float(i),
903
+ y_value=float(w["dollar_per_pct"]))
904
+ for i, w in enumerate(weeks)
905
+ ),
906
+ y_label="$ / 1%",
907
+ reference_lines=(),
908
+ ) if weeks else None,
909
+ totals=_kpi_strip(
910
+ ("Δ $/% (3wk)", f"{float(delta.get('dpp_change_pct') or 0.0)*100:+.1f}%"),
911
+ ("Δ $ (3wk)", f"${float(delta.get('cost_change_usd') or 0.0):+,.2f}"),
912
+ ),
913
+ notes=(),
914
+ generated_at=_utc_now(),
915
+ version=_release_version(),
916
+ )
917
+
918
+
919
+ def _build_daily_visual(*, panel_data, options):
920
+ """Daily visual — 7-day cost bar, rows=() (spec §9.5)."""
921
+ days = panel_data.get("days") or []
922
+ start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
923
+ end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
924
+ end = end_anchor + _dt.timedelta(days=1)
925
+ sum_cost = sum(float(d["cost_usd"]) for d in days)
926
+ return _LS.ShareSnapshot(
927
+ cmd="daily",
928
+ title=f"Daily visual — last {len(days)} day{'s' if len(days) != 1 else ''}",
929
+ subtitle=None,
930
+ period=_period(start, end, label="Last 7 days", display_tz=_display_tz(options)),
931
+ columns=(),
932
+ rows=(),
933
+ chart=_LS.BarChart(
934
+ points=tuple(
935
+ _LS.ChartPoint(x_label=d["date"], x_value=float(i),
936
+ y_value=float(d["cost_usd"]))
937
+ for i, d in enumerate(days)
938
+ ),
939
+ y_label="$ / day",
940
+ ) if days else None,
941
+ totals=_kpi_strip(
942
+ ("Sum", f"${sum_cost:,.2f}"),
943
+ ("Daily avg", f"${(sum_cost / len(days) if days else 0.0):,.2f}"),
944
+ ),
945
+ notes=(),
946
+ generated_at=_utc_now(),
947
+ version=_release_version(),
948
+ )
949
+
950
+
951
+ def _build_daily_detail(*, panel_data, options):
952
+ """Daily detail — per-day × per-project full table (spec §9.5).
953
+
954
+ NOTE: ships as per-project table; spec §9.5 calls for per-day × per-project
955
+ cross-tab — deferred until `_build_daily_share_panel_data` carries
956
+ per-day per-project cells (see issue #33).
957
+ """
958
+ days = panel_data.get("days") or []
959
+ start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
960
+ end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
961
+ end = end_anchor + _dt.timedelta(days=1)
962
+ sum_cost = sum(float(d["cost_usd"]) for d in days)
963
+ top_n = max(int(options.get("top_n", 50)), 1)
964
+ return _LS.ShareSnapshot(
965
+ cmd="daily",
966
+ title=f"Daily detail — last {len(days)} day{'s' if len(days) != 1 else ''}",
967
+ subtitle=None,
968
+ period=_period(start, end, label="Last 7 days", display_tz=_display_tz(options)),
969
+ columns=_PROJECT_COLUMNS,
970
+ rows=_top_projects_rows(panel_data.get("top_projects") or [], top_n),
971
+ chart=_LS.BarChart(
972
+ points=tuple(
973
+ _LS.ChartPoint(x_label=d["date"], x_value=float(i),
974
+ y_value=float(d["cost_usd"]))
975
+ for i, d in enumerate(days)
976
+ ),
977
+ y_label="$ / day",
978
+ ) if days else None,
979
+ totals=_kpi_strip(
980
+ ("Sum", f"${sum_cost:,.2f}"),
981
+ ("Daily avg", f"${(sum_cost / len(days) if days else 0.0):,.2f}"),
982
+ ),
983
+ notes=(),
984
+ generated_at=_utc_now(),
985
+ version=_release_version(),
986
+ )
987
+
988
+
989
+ def _build_monthly_visual(*, panel_data, options):
990
+ """Monthly visual — month-over-month bar, rows=() (spec §9.5)."""
991
+ months = panel_data.get("months") or []
992
+
993
+ def _month_start(s):
994
+ return _parse_iso_utc(f"{s}-01")
995
+
996
+ start = _month_start(months[0]["month"]) if months else _utc_now()
997
+ if months:
998
+ last = _month_start(months[-1]["month"])
999
+ end_anchor = last.replace(day=28) + _dt.timedelta(days=4)
1000
+ end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
1001
+ else:
1002
+ end = start
1003
+ sum_cost = sum(float(m["cost_usd"]) for m in months)
1004
+ return _LS.ShareSnapshot(
1005
+ cmd="monthly",
1006
+ title=f"Monthly visual — last {len(months)} month{'s' if len(months) != 1 else ''}",
1007
+ subtitle=None,
1008
+ period=_period(start, end, label="Recent months",
1009
+ display_tz=_display_tz(options)),
1010
+ columns=(),
1011
+ rows=(),
1012
+ chart=_LS.BarChart(
1013
+ points=tuple(
1014
+ _LS.ChartPoint(x_label=m["month"], x_value=float(i),
1015
+ y_value=float(m["cost_usd"]))
1016
+ for i, m in enumerate(months)
1017
+ ),
1018
+ y_label="$ / month",
1019
+ ) if months else None,
1020
+ totals=_kpi_strip(
1021
+ ("Sum", f"${sum_cost:,.2f}"),
1022
+ ("Monthly avg", f"${(sum_cost / len(months) if months else 0.0):,.2f}"),
1023
+ ),
1024
+ notes=(),
1025
+ generated_at=_utc_now(),
1026
+ version=_release_version(),
1027
+ )
1028
+
1029
+
1030
+ def _build_monthly_detail(*, panel_data, options):
1031
+ """Monthly detail — per-month × per-project full table (spec §9.5).
1032
+
1033
+ NOTE: ships as per-project table; spec §9.5 calls for per-month × per-project
1034
+ cross-tab — deferred until `_build_monthly_share_panel_data` carries
1035
+ per-month per-project cells (see issue #33).
1036
+ """
1037
+ months = panel_data.get("months") or []
1038
+
1039
+ def _month_start(s):
1040
+ return _parse_iso_utc(f"{s}-01")
1041
+
1042
+ start = _month_start(months[0]["month"]) if months else _utc_now()
1043
+ if months:
1044
+ last = _month_start(months[-1]["month"])
1045
+ end_anchor = last.replace(day=28) + _dt.timedelta(days=4)
1046
+ end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
1047
+ else:
1048
+ end = start
1049
+ sum_cost = sum(float(m["cost_usd"]) for m in months)
1050
+ top_n = max(int(options.get("top_n", 50)), 1)
1051
+ return _LS.ShareSnapshot(
1052
+ cmd="monthly",
1053
+ title=f"Monthly detail — last {len(months)} month{'s' if len(months) != 1 else ''}",
1054
+ subtitle=None,
1055
+ period=_period(start, end, label="Recent months",
1056
+ display_tz=_display_tz(options)),
1057
+ columns=_PROJECT_COLUMNS,
1058
+ rows=_top_projects_rows(panel_data.get("top_projects") or [], top_n),
1059
+ chart=_LS.BarChart(
1060
+ points=tuple(
1061
+ _LS.ChartPoint(x_label=m["month"], x_value=float(i),
1062
+ y_value=float(m["cost_usd"]))
1063
+ for i, m in enumerate(months)
1064
+ ),
1065
+ y_label="$ / month",
1066
+ ) if months else None,
1067
+ totals=_kpi_strip(
1068
+ ("Sum", f"${sum_cost:,.2f}"),
1069
+ ("Monthly avg", f"${(sum_cost / len(months) if months else 0.0):,.2f}"),
1070
+ ),
1071
+ notes=(),
1072
+ generated_at=_utc_now(),
1073
+ version=_release_version(),
1074
+ )
1075
+
1076
+
1077
+ def _build_blocks_visual(*, panel_data, options):
1078
+ """Blocks visual — recent-blocks line, rows=() (spec §9.5)."""
1079
+ cb = panel_data.get("current_block") or {}
1080
+ recent = panel_data.get("recent_blocks") or []
1081
+ start = _parse_iso_utc(cb["start_at"]) if cb.get("start_at") else _utc_now()
1082
+ end = _parse_iso_utc(cb["end_at"]) if cb.get("end_at") else start + _dt.timedelta(hours=5)
1083
+ return _LS.ShareSnapshot(
1084
+ cmd="five-hour-blocks",
1085
+ title="Current 5-hour block — visual",
1086
+ subtitle=None,
1087
+ period=_period(start, end, label="Current block",
1088
+ display_tz=_display_tz(options)),
1089
+ columns=(),
1090
+ rows=(),
1091
+ chart=_LS.LineChart(
1092
+ points=tuple(
1093
+ _LS.ChartPoint(x_label=b["start_at"], x_value=float(i),
1094
+ y_value=float(b["cost_usd"]))
1095
+ for i, b in enumerate(recent)
1096
+ ),
1097
+ y_label="$ / block",
1098
+ reference_lines=(),
1099
+ ) if recent else None,
1100
+ totals=_kpi_strip(
1101
+ ("$ this block", f"${float(cb.get('cost_usd') or 0.0):.2f}"),
1102
+ ("% used", f"{float(cb.get('pct_used') or 0.0)*100:.1f}%"),
1103
+ ),
1104
+ notes=(),
1105
+ generated_at=_utc_now(),
1106
+ version=_release_version(),
1107
+ )
1108
+
1109
+
1110
+ def _build_blocks_detail(*, panel_data, options):
1111
+ """Blocks detail — full per-project rows + recent-blocks chart (spec §9.5).
1112
+
1113
+ NOTE: ships as per-project table; spec §9.5 calls for per-block × per-project
1114
+ cross-tab — deferred until `_build_blocks_share_panel_data` carries
1115
+ per-block per-project cells (see issue #33).
1116
+ """
1117
+ cb = panel_data.get("current_block") or {}
1118
+ recent = panel_data.get("recent_blocks") or []
1119
+ start = _parse_iso_utc(cb["start_at"]) if cb.get("start_at") else _utc_now()
1120
+ end = _parse_iso_utc(cb["end_at"]) if cb.get("end_at") else start + _dt.timedelta(hours=5)
1121
+ top_n = max(int(options.get("top_n", 50)), 1)
1122
+ return _LS.ShareSnapshot(
1123
+ cmd="five-hour-blocks",
1124
+ title="Current 5-hour block — detail",
1125
+ subtitle=None,
1126
+ period=_period(start, end, label="Current block",
1127
+ display_tz=_display_tz(options)),
1128
+ columns=_PROJECT_COLUMNS,
1129
+ rows=_top_projects_rows(panel_data.get("top_projects") or [], top_n),
1130
+ chart=_LS.LineChart(
1131
+ points=tuple(
1132
+ _LS.ChartPoint(x_label=b["start_at"], x_value=float(i),
1133
+ y_value=float(b["cost_usd"]))
1134
+ for i, b in enumerate(recent)
1135
+ ),
1136
+ y_label="$ / block",
1137
+ reference_lines=(),
1138
+ ) if recent else None,
1139
+ totals=_kpi_strip(
1140
+ ("$ this block", f"${float(cb.get('cost_usd') or 0.0):.2f}"),
1141
+ ("% used", f"{float(cb.get('pct_used') or 0.0)*100:.1f}%"),
1142
+ ("Tokens", f"{int(cb.get('tokens_total') or 0):,}"),
1143
+ ),
1144
+ notes=(),
1145
+ generated_at=_utc_now(),
1146
+ version=_release_version(),
1147
+ )
1148
+
1149
+
1150
+ def _build_forecast_visual(*, panel_data, options):
1151
+ """Forecast visual — projection chart with 90/100% ceilings, rows=() (spec §9.5)."""
1152
+ curve = panel_data.get("projection_curve") or []
1153
+ start = _parse_iso_utc(curve[0]["date"]) if curve else _utc_now()
1154
+ end_anchor = _parse_iso_utc(curve[-1]["date"]) if curve else start
1155
+ end = end_anchor + _dt.timedelta(days=1)
1156
+ confidence = panel_data.get("confidence") or "ok"
1157
+ notes = ("LOW CONF: insufficient samples",) if confidence == "LOW CONF" else ()
1158
+ return _LS.ShareSnapshot(
1159
+ cmd="forecast",
1160
+ title="Forecast visual — projection to ceiling",
1161
+ subtitle=None,
1162
+ period=_period(start, end, label="Next 7 days",
1163
+ display_tz=_display_tz(options)),
1164
+ columns=(),
1165
+ rows=(),
1166
+ chart=_LS.LineChart(
1167
+ points=tuple(
1168
+ _LS.ChartPoint(x_label=p["date"], x_value=float(i),
1169
+ y_value=float(p["projected_pct_used"]) * 100.0)
1170
+ for i, p in enumerate(curve)
1171
+ ),
1172
+ y_label="projected %",
1173
+ reference_lines=(
1174
+ (90.0, "90%", "warn"),
1175
+ (100.0, "100%", "alarm"),
1176
+ ),
1177
+ ) if curve else None,
1178
+ totals=_kpi_strip(
1179
+ ("Days→90%", f"{float(panel_data.get('days_to_90pct') or 0.0):.1f}"),
1180
+ ("Days→100%", f"{float(panel_data.get('days_to_100pct') or 0.0):.1f}"),
1181
+ ("End %", f"{float(panel_data.get('projected_end_pct') or 0.0)*100:.1f}%"),
1182
+ ),
1183
+ notes=notes,
1184
+ generated_at=_utc_now(),
1185
+ version=_release_version(),
1186
+ )
1187
+
1188
+
1189
+ def _build_forecast_detail(*, panel_data, options):
1190
+ """Forecast detail — per-day projection table + chart (spec §9.5).
1191
+
1192
+ Detail's table emits one row per projection-curve day showing the
1193
+ cumulative projected % alongside the 4-line budget metric block from
1194
+ the Recap. Top_n caps the row count.
1195
+ """
1196
+ curve = panel_data.get("projection_curve") or []
1197
+ budgets = panel_data.get("daily_budgets") or {}
1198
+ start = _parse_iso_utc(curve[0]["date"]) if curve else _utc_now()
1199
+ end_anchor = _parse_iso_utc(curve[-1]["date"]) if curve else start
1200
+ end = end_anchor + _dt.timedelta(days=1)
1201
+ confidence = panel_data.get("confidence") or "ok"
1202
+ notes = ("LOW CONF: insufficient samples",) if confidence == "LOW CONF" else ()
1203
+ top_n = max(int(options.get("top_n", 50)), 1)
1204
+ # Budget metric rows + per-day projection rows, capped at top_n total.
1205
+ budget_rows = (
1206
+ _LS.Row(cells={"metric": _LS.TextCell("Avg to-date"),
1207
+ "value": _LS.MoneyCell(float(budgets.get("avg") or 0.0))}),
1208
+ _LS.Row(cells={"metric": _LS.TextCell("Recent 24h"),
1209
+ "value": _LS.MoneyCell(float(budgets.get("recent_24h") or 0.0))}),
1210
+ _LS.Row(cells={"metric": _LS.TextCell("Budget to 90%"),
1211
+ "value": _LS.MoneyCell(float(budgets.get("until_90pct") or 0.0))}),
1212
+ _LS.Row(cells={"metric": _LS.TextCell("Budget to 100%"),
1213
+ "value": _LS.MoneyCell(float(budgets.get("until_100pct") or 0.0))}),
1214
+ )
1215
+ day_rows = tuple(
1216
+ _LS.Row(cells={
1217
+ "metric": _LS.TextCell(p["date"]),
1218
+ "value": _LS.PercentCell(float(p["projected_pct_used"]) * 100.0),
1219
+ })
1220
+ for p in curve
1221
+ )
1222
+ rows = (budget_rows + day_rows)[:top_n]
1223
+ return _LS.ShareSnapshot(
1224
+ cmd="forecast",
1225
+ title="Forecast detail — projection to ceiling",
1226
+ subtitle=None,
1227
+ period=_period(start, end, label="Next 7 days",
1228
+ display_tz=_display_tz(options)),
1229
+ columns=(
1230
+ _LS.ColumnSpec(key="metric", label="Metric", align="left"),
1231
+ _LS.ColumnSpec(key="value", label="$/day", align="right", emphasis=True),
1232
+ ),
1233
+ rows=rows,
1234
+ chart=_LS.LineChart(
1235
+ points=tuple(
1236
+ _LS.ChartPoint(x_label=p["date"], x_value=float(i),
1237
+ y_value=float(p["projected_pct_used"]) * 100.0)
1238
+ for i, p in enumerate(curve)
1239
+ ),
1240
+ y_label="projected %",
1241
+ reference_lines=(
1242
+ (90.0, "90%", "warn"),
1243
+ (100.0, "100%", "alarm"),
1244
+ ),
1245
+ ) if curve else None,
1246
+ totals=_kpi_strip(
1247
+ ("Days→90%", f"{float(panel_data.get('days_to_90pct') or 0.0):.1f}"),
1248
+ ("Days→100%", f"{float(panel_data.get('days_to_100pct') or 0.0):.1f}"),
1249
+ ("End %", f"{float(panel_data.get('projected_end_pct') or 0.0)*100:.1f}%"),
1250
+ ),
1251
+ notes=notes,
1252
+ generated_at=_utc_now(),
1253
+ version=_release_version(),
1254
+ )
1255
+
1256
+
1257
+ def _build_sessions_visual(*, panel_data, options):
1258
+ """Sessions visual — horizontal bar of top-N sessions by cost; rows=().
1259
+
1260
+ Spec §9.5. Sessions Recap has no chart (it's a pure table); Visual
1261
+ flips that — chart only via `HorizontalBarChart` (top-N capped),
1262
+ rows=(). `cap=None` means show all `points` (the builder pre-truncates).
1263
+ """
1264
+ sessions = panel_data.get("sessions") or []
1265
+ cap = int(options.get("top_n", 8))
1266
+ rows_iter = sessions[:cap]
1267
+ sum_cost = sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
1268
+ starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
1269
+ start = min(starts) if starts else _utc_now()
1270
+ end = max(starts) if starts else start
1271
+ return _LS.ShareSnapshot(
1272
+ cmd="session",
1273
+ title=f"Sessions visual — top {len(rows_iter)}",
1274
+ subtitle=None,
1275
+ period=_period(start, end, label="Recent sessions",
1276
+ display_tz=_display_tz(options)),
1277
+ columns=(),
1278
+ rows=(),
1279
+ chart=_LS.HorizontalBarChart(
1280
+ points=tuple(
1281
+ _LS.ChartPoint(
1282
+ x_label=str(s.get("session_id") or ""),
1283
+ x_value=float(i),
1284
+ y_value=float(s.get("cost_usd") or 0.0),
1285
+ project_label=str(s.get("project_path") or "") or None,
1286
+ )
1287
+ for i, s in enumerate(rows_iter)
1288
+ ),
1289
+ x_label="$",
1290
+ cap=None,
1291
+ ) if rows_iter else None,
1292
+ totals=_kpi_strip(
1293
+ ("Sum", f"${sum_cost:,.2f}"),
1294
+ ("Shown", f"{len(rows_iter)}"),
1295
+ ),
1296
+ notes=(),
1297
+ generated_at=_utc_now(),
1298
+ version=_release_version(),
1299
+ )
1300
+
1301
+
1302
+ def _build_sessions_detail(*, panel_data, options):
1303
+ """Sessions detail — top-50 sessions with full columns + hbar chart (spec §9.5).
1304
+
1305
+ Default `top_n` is 50 (Recap's is 15). Sessions Recap explicitly omits the
1306
+ chart (table-first panel); Detail re-introduces a compact horizontal bar
1307
+ of the same top-N so the archetype contract (chart populated + rows
1308
+ populated) holds uniformly across all 8 panels' Detail siblings.
1309
+ """
1310
+ sessions = panel_data.get("sessions") or []
1311
+ cap = options.get("top_n", 50)
1312
+ rows_iter = sessions[:cap]
1313
+ sum_cost = sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
1314
+ starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
1315
+ start = min(starts) if starts else _utc_now()
1316
+ end = max(starts) if starts else start
1317
+ return _LS.ShareSnapshot(
1318
+ cmd="session",
1319
+ title=f"Sessions detail — top {len(rows_iter)}",
1320
+ subtitle=None,
1321
+ period=_period(start, end, label="Recent sessions",
1322
+ display_tz=_display_tz(options)),
1323
+ columns=(
1324
+ _LS.ColumnSpec(key="started", label="Started", align="left"),
1325
+ _LS.ColumnSpec(key="project", label="Project", align="left"),
1326
+ _LS.ColumnSpec(key="model", label="Model", align="left"),
1327
+ _LS.ColumnSpec(key="cost", label="$", align="right",
1328
+ emphasis=True),
1329
+ ),
1330
+ rows=tuple(
1331
+ _LS.Row(cells={
1332
+ "started": (_LS.DateCell(when=_parse_iso_utc(s["started_at"]))
1333
+ if s.get("started_at")
1334
+ else _LS.TextCell("")),
1335
+ "project": _LS.ProjectCell(label=str(s.get("project_path") or "")),
1336
+ "model": _LS.TextCell(str(s.get("model") or "")),
1337
+ "cost": _LS.MoneyCell(float(s.get("cost_usd") or 0.0)),
1338
+ })
1339
+ for s in rows_iter
1340
+ ),
1341
+ chart=_LS.HorizontalBarChart(
1342
+ points=tuple(
1343
+ _LS.ChartPoint(
1344
+ x_label=str(s.get("session_id") or ""),
1345
+ x_value=float(i),
1346
+ y_value=float(s.get("cost_usd") or 0.0),
1347
+ project_label=str(s.get("project_path") or "") or None,
1348
+ )
1349
+ for i, s in enumerate(rows_iter)
1350
+ ),
1351
+ x_label="$",
1352
+ cap=None,
1353
+ ) if rows_iter else None,
1354
+ totals=_kpi_strip(
1355
+ ("Sum", f"${sum_cost:,.2f}"),
1356
+ ("Shown", f"{len(rows_iter)}"),
1357
+ ),
1358
+ notes=(),
1359
+ generated_at=_utc_now(),
1360
+ version=_release_version(),
1361
+ )
1362
+
1363
+
1364
+ # --- Register Recap templates ---
1365
+
1366
+ _RECAP = (
1367
+ ShareTemplate(id="weekly-recap", panel="weekly", label="Recap",
1368
+ description="Balanced KPIs + chart + top projects",
1369
+ default_options={"top_n": 5, "show_chart": True, "show_table": True},
1370
+ builder=_build_weekly_recap),
1371
+ ShareTemplate(id="current-week-recap", panel="current-week", label="Recap",
1372
+ description="Week-to-date KPIs + line + top-3 projects",
1373
+ default_options={"top_n": 3, "show_chart": True, "show_table": True},
1374
+ builder=_build_current_week_recap),
1375
+ ShareTemplate(id="trend-recap", panel="trend", label="Recap",
1376
+ description="$/% trend over 8 weeks + 3-week delta",
1377
+ default_options={"top_n": 3, "show_chart": True, "show_table": True},
1378
+ builder=_build_trend_recap),
1379
+ ShareTemplate(id="daily-recap", panel="daily", label="Recap",
1380
+ description="7-day cost bar + top-5 projects",
1381
+ default_options={"top_n": 5, "show_chart": True, "show_table": True},
1382
+ builder=_build_daily_recap),
1383
+ ShareTemplate(id="monthly-recap", panel="monthly", label="Recap",
1384
+ description="Per-month bar + KPI + top projects",
1385
+ default_options={"top_n": 5, "show_chart": True, "show_table": True},
1386
+ builder=_build_monthly_recap),
1387
+ ShareTemplate(id="blocks-recap", panel="blocks", label="Recap",
1388
+ description="Current block KPI + recent-blocks line + top-3",
1389
+ default_options={"top_n": 3, "show_chart": True, "show_table": True},
1390
+ builder=_build_blocks_recap),
1391
+ ShareTemplate(id="forecast-recap", panel="forecast", label="Recap",
1392
+ description="Projection + budget table + days-to-ceiling",
1393
+ default_options={"top_n": 5, "show_chart": True, "show_table": True},
1394
+ builder=_build_forecast_recap),
1395
+ ShareTemplate(id="sessions-recap", panel="sessions", label="Recap",
1396
+ description="Top-N sessions table + total",
1397
+ default_options={"top_n": 15, "show_chart": False, "show_table": True},
1398
+ builder=_build_sessions_recap),
1399
+ )
1400
+
1401
+ SHARE_TEMPLATES = SHARE_TEMPLATES + _RECAP
1402
+
1403
+
1404
+ # --- Register Visual templates ---
1405
+
1406
+ _VISUAL = (
1407
+ ShareTemplate(id="weekly-visual", panel="weekly", label="Visual",
1408
+ description="Chart-first 8-week cost trend",
1409
+ default_options={"top_n": 8, "show_chart": True, "show_table": False},
1410
+ builder=_build_weekly_visual),
1411
+ ShareTemplate(id="current-week-visual", panel="current-week", label="Visual",
1412
+ description="Week-to-date line with KPI overlay",
1413
+ default_options={"top_n": 8, "show_chart": True, "show_table": False},
1414
+ builder=_build_current_week_visual),
1415
+ ShareTemplate(id="trend-visual", panel="trend", label="Visual",
1416
+ description="$/% trend line with budget reference",
1417
+ default_options={"top_n": 8, "show_chart": True, "show_table": False},
1418
+ builder=_build_trend_visual),
1419
+ ShareTemplate(id="daily-visual", panel="daily", label="Visual",
1420
+ description="Stacked bar by model across 7 days",
1421
+ default_options={"top_n": 8, "show_chart": True, "show_table": False},
1422
+ builder=_build_daily_visual),
1423
+ ShareTemplate(id="monthly-visual", panel="monthly", label="Visual",
1424
+ description="Month-over-month line",
1425
+ default_options={"top_n": 8, "show_chart": True, "show_table": False},
1426
+ builder=_build_monthly_visual),
1427
+ ShareTemplate(id="blocks-visual", panel="blocks", label="Visual",
1428
+ description="Burndown gauge + recent-blocks stacked bar",
1429
+ default_options={"top_n": 8, "show_chart": True, "show_table": False},
1430
+ builder=_build_blocks_visual),
1431
+ ShareTemplate(id="forecast-visual", panel="forecast", label="Visual",
1432
+ description="Projection with 90/100% ceilings",
1433
+ default_options={"top_n": 8, "show_chart": True, "show_table": False},
1434
+ builder=_build_forecast_visual),
1435
+ ShareTemplate(id="sessions-visual", panel="sessions", label="Visual",
1436
+ description="Horizontal bar of top-N sessions by cost",
1437
+ default_options={"top_n": 8, "show_chart": True, "show_table": False},
1438
+ builder=_build_sessions_visual),
1439
+ )
1440
+
1441
+
1442
+ # --- Register Detail templates ---
1443
+
1444
+ _DETAIL = (
1445
+ ShareTemplate(id="weekly-detail", panel="weekly", label="Detail",
1446
+ description="Per-week × per-project full table",
1447
+ default_options={"top_n": 50, "show_chart": True, "show_table": True},
1448
+ builder=_build_weekly_detail),
1449
+ ShareTemplate(id="current-week-detail", panel="current-week", label="Detail",
1450
+ description="Per-project table + sidebar chart",
1451
+ default_options={"top_n": 50, "show_chart": True, "show_table": True},
1452
+ builder=_build_current_week_detail),
1453
+ ShareTemplate(id="trend-detail", panel="trend", label="Detail",
1454
+ description="8-week table with $/%/rate columns + sparkline",
1455
+ default_options={"top_n": 50, "show_chart": True, "show_table": True},
1456
+ builder=_build_trend_detail),
1457
+ ShareTemplate(id="daily-detail", panel="daily", label="Detail",
1458
+ description="Per-day × per-project full table",
1459
+ default_options={"top_n": 50, "show_chart": True, "show_table": True},
1460
+ builder=_build_daily_detail),
1461
+ ShareTemplate(id="monthly-detail", panel="monthly", label="Detail",
1462
+ description="Per-month × per-project full table",
1463
+ default_options={"top_n": 50, "show_chart": True, "show_table": True},
1464
+ builder=_build_monthly_detail),
1465
+ ShareTemplate(id="blocks-detail", panel="blocks", label="Detail",
1466
+ description="Per-block × per-project rows",
1467
+ default_options={"top_n": 50, "show_chart": True, "show_table": True},
1468
+ builder=_build_blocks_detail),
1469
+ ShareTemplate(id="forecast-detail", panel="forecast", label="Detail",
1470
+ description="Per-day forecast table with $/% budget",
1471
+ default_options={"top_n": 50, "show_chart": True, "show_table": True},
1472
+ builder=_build_forecast_detail),
1473
+ ShareTemplate(id="sessions-detail", panel="sessions", label="Detail",
1474
+ description="Top-50 sessions with full columns",
1475
+ default_options={"top_n": 50, "show_chart": False, "show_table": True},
1476
+ builder=_build_sessions_detail),
1477
+ )
1478
+
1479
+ SHARE_TEMPLATES = SHARE_TEMPLATES + _VISUAL + _DETAIL
1480
+
1481
+ _validate_registry()