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.
- package/CHANGELOG.md +10 -0
- package/bin/_lib_share_templates.py +1481 -0
- package/package.json +1 -1
|
@@ -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()
|