cctally 1.7.4 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/bin/_cctally_dashboard.py +135 -123
- package/bin/_cctally_tui.py +124 -256
- package/bin/_lib_view_models.py +993 -0
- package/bin/cctally +289 -233
- package/dashboard/static/assets/{index-DhCnIFq9.js → index-CfXu9Fx_.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +9 -8
|
@@ -0,0 +1,993 @@
|
|
|
1
|
+
"""View-model kernel for CLI / dashboard / share consumers.
|
|
2
|
+
|
|
3
|
+
This module owns the per-domain row dataclasses and the ``*View``
|
|
4
|
+
wrappers that carry rows + data-plane aggregates (totals, averages).
|
|
5
|
+
|
|
6
|
+
Bundle 1 (landed):
|
|
7
|
+
|
|
8
|
+
Row dataclasses moved verbatim from ``bin/_cctally_tui.py`` — no field
|
|
9
|
+
changes:
|
|
10
|
+
|
|
11
|
+
- ``DailyPanelRow``, ``MonthlyPeriodRow``, ``WeeklyPeriodRow``,
|
|
12
|
+
``TuiSessionRow``.
|
|
13
|
+
|
|
14
|
+
``TuiTrendRow`` moved with the 10 nullable fields added per spec §4.1
|
|
15
|
+
(``week_start_date``, ``week_end_date``, ``week_end_at``,
|
|
16
|
+
``weekly_cost_usd``, ``usage_captured_at``, ``cost_captured_at``,
|
|
17
|
+
``as_of``, ``range_start_iso``, ``range_end_iso``, ``freshness``). All
|
|
18
|
+
defaults are ``None`` so existing TUI / dashboard fixtures that
|
|
19
|
+
construct ``TuiTrendRow`` positionally stay byte-stable.
|
|
20
|
+
|
|
21
|
+
Frozen ``*View`` dataclasses + builders:
|
|
22
|
+
|
|
23
|
+
- ``DailyView`` + ``build_daily_view(entries, *, now_utc, display_tz)``
|
|
24
|
+
- ``MonthlyView`` + ``build_monthly_view(entries, *, now_utc, n,
|
|
25
|
+
display_tz)``
|
|
26
|
+
- ``WeeklyView`` + ``build_weekly_view(conn, entries, *, weeks, now_utc,
|
|
27
|
+
display_tz, as_of_utc)``
|
|
28
|
+
- ``TrendView`` + ``build_trend_view(conn, *, now_utc, n, display_tz)``
|
|
29
|
+
- ``SessionsView`` + ``build_sessions_view(entries, *, now_utc, limit,
|
|
30
|
+
display_tz)``
|
|
31
|
+
|
|
32
|
+
Each ``*View`` carries ``rows`` (typed row tuple) plus a parallel
|
|
33
|
+
``aggregated`` ``BucketUsage`` tuple where CLI byte-stable JSON requires
|
|
34
|
+
it (the weekly view also carries an ``overlay`` tuple of ``(used_pct,
|
|
35
|
+
dpp)`` pairs aligned with ``aggregated``). All builders return totals
|
|
36
|
+
(``total_cost_usd`` / ``total_tokens``) so the dashboard envelope
|
|
37
|
+
adapter (in ``bin/_cctally_dashboard.py``) and the React panel layer
|
|
38
|
+
share a single source of truth.
|
|
39
|
+
|
|
40
|
+
Helpers:
|
|
41
|
+
|
|
42
|
+
- ``_load_lib(name)`` — late-import a sibling under ``bin/`` (matches
|
|
43
|
+
``_lib_aggregators._load_lib``; keeps the import-time graph acyclic).
|
|
44
|
+
- ``_cctally()`` — accessor for the ``cctally`` module at call-time
|
|
45
|
+
(spec §5.5; lets builders touch top-level helpers without binding at
|
|
46
|
+
module load).
|
|
47
|
+
- ``_display_tz_label`` / ``_model_breakdowns_to_models_late`` —
|
|
48
|
+
presentation-side helpers shared across builders.
|
|
49
|
+
|
|
50
|
+
``bin/_cctally_tui.py`` re-exports each name so historical imports
|
|
51
|
+
(``from _cctally_tui import DailyPanelRow``, ``ns["DailyPanelRow"]``
|
|
52
|
+
direct-dict reads in tests) keep resolving.
|
|
53
|
+
|
|
54
|
+
Spec: docs/superpowers/specs/2026-05-17-view-model-unification-design.md
|
|
55
|
+
"""
|
|
56
|
+
from __future__ import annotations
|
|
57
|
+
|
|
58
|
+
import datetime as dt
|
|
59
|
+
import pathlib
|
|
60
|
+
import sys
|
|
61
|
+
from dataclasses import dataclass
|
|
62
|
+
from typing import Any
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cctally():
|
|
66
|
+
"""Resolve the current ``cctally`` module at call-time (spec §5.5)."""
|
|
67
|
+
return sys.modules["cctally"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _load_lib(name: str):
|
|
71
|
+
"""Late-import a sibling under ``bin/`` (same recipe as
|
|
72
|
+
``_lib_aggregators._load_lib`` — keeps the import-time graph acyclic
|
|
73
|
+
even when builders need access to ``_cctally_dashboard`` /
|
|
74
|
+
``_lib_aggregators`` / ``_lib_share``).
|
|
75
|
+
"""
|
|
76
|
+
cached = sys.modules.get(name)
|
|
77
|
+
if cached is not None:
|
|
78
|
+
return cached
|
|
79
|
+
import importlib.util as _ilu
|
|
80
|
+
p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
|
|
81
|
+
spec = _ilu.spec_from_file_location(name, p)
|
|
82
|
+
mod = _ilu.module_from_spec(spec)
|
|
83
|
+
sys.modules[name] = mod
|
|
84
|
+
spec.loader.exec_module(mod)
|
|
85
|
+
return mod
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# === Row dataclasses (Task 2: moved verbatim from _cctally_tui.py) =========
|
|
89
|
+
# Field order, types, and defaults match the originals byte-stable.
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class TuiTrendRow:
|
|
94
|
+
"""Trend row used by CLI ``report``, TUI trend panel, dashboard
|
|
95
|
+
trend panel, and share ``_build_report_snapshot``.
|
|
96
|
+
|
|
97
|
+
The first 7 fields (``week_label`` through ``is_current``) are the
|
|
98
|
+
historical TUI/dashboard surface — preserved verbatim. The 10
|
|
99
|
+
nullable fields below were added by spec §4.1 so ``cmd_report`` can
|
|
100
|
+
consume a single typed shape (eliminates the camelCase dict
|
|
101
|
+
workaround documented at ``_build_report_snapshot:~12299``).
|
|
102
|
+
|
|
103
|
+
JSON serialization sites (``cmd_report --json``, dashboard envelope
|
|
104
|
+
``trend.weeks[]``) map field-by-field to today's keys; this typed
|
|
105
|
+
in-memory shape is internal.
|
|
106
|
+
"""
|
|
107
|
+
# ---- existing TUI/dashboard fields (verbatim) ----
|
|
108
|
+
week_label: str # e.g. "Apr 14"
|
|
109
|
+
week_start_at: dt.datetime
|
|
110
|
+
used_pct: float | None # None when the week has a cost snapshot
|
|
111
|
+
# but no usage snapshot (phantom weeks)
|
|
112
|
+
dollars_per_percent: float | None
|
|
113
|
+
delta_dpp: float | None # vs prior week
|
|
114
|
+
spark_height: int # 1..8 normalized
|
|
115
|
+
is_current: bool
|
|
116
|
+
|
|
117
|
+
# ---- NEW (spec §4.1): required by cmd_report JSON contract; nullable ----
|
|
118
|
+
week_start_date: dt.date | None = None
|
|
119
|
+
week_end_date: dt.date | None = None
|
|
120
|
+
week_end_at: dt.datetime | None = None
|
|
121
|
+
weekly_cost_usd: float | None = None
|
|
122
|
+
usage_captured_at: str | None = None # ISO-8601 or None
|
|
123
|
+
cost_captured_at: str | None = None # ISO-8601 or None
|
|
124
|
+
as_of: str | None = None # ISO-8601 or None
|
|
125
|
+
range_start_iso: str | None = None
|
|
126
|
+
range_end_iso: str | None = None
|
|
127
|
+
freshness: dict | None = None # {label, captured_at, age_seconds}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class WeeklyPeriodRow:
|
|
132
|
+
"""One subscription-week row for the dashboard's Weekly panel/modal.
|
|
133
|
+
|
|
134
|
+
`models` is a list of `{model, display, chip, cost_usd, cost_pct}`
|
|
135
|
+
dicts sorted by `cost_usd` descending. Pre-bucketed in Python so
|
|
136
|
+
the React layer never re-derives per-model coloring.
|
|
137
|
+
"""
|
|
138
|
+
label: str # "04-23" — MM-DD of the week start
|
|
139
|
+
cost_usd: float
|
|
140
|
+
total_tokens: int
|
|
141
|
+
input_tokens: int
|
|
142
|
+
output_tokens: int
|
|
143
|
+
cache_creation_tokens: int
|
|
144
|
+
cache_read_tokens: int
|
|
145
|
+
used_pct: float | None # from weekly_usage_snapshots overlay
|
|
146
|
+
dollar_per_pct: float | None # cost / used_pct when used_pct > 0
|
|
147
|
+
delta_cost_pct: float | None # (cost - prev_cost) / prev_cost
|
|
148
|
+
is_current: bool
|
|
149
|
+
models: list[dict[str, Any]]
|
|
150
|
+
week_start_at: str # ISO-8601 with tz, from SubWeek.start_ts
|
|
151
|
+
week_end_at: str # ISO-8601 with tz, from SubWeek.end_ts
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class MonthlyPeriodRow:
|
|
156
|
+
"""One calendar-month row for the dashboard's Monthly panel/modal."""
|
|
157
|
+
label: str # "YYYY-MM"
|
|
158
|
+
cost_usd: float
|
|
159
|
+
total_tokens: int
|
|
160
|
+
input_tokens: int
|
|
161
|
+
output_tokens: int
|
|
162
|
+
cache_creation_tokens: int
|
|
163
|
+
cache_read_tokens: int
|
|
164
|
+
delta_cost_pct: float | None
|
|
165
|
+
is_current: bool
|
|
166
|
+
models: list[dict[str, Any]]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass
|
|
170
|
+
class DailyPanelRow:
|
|
171
|
+
"""One row of the dashboard's Daily heatmap panel.
|
|
172
|
+
|
|
173
|
+
`intensity_bucket` is the server-computed quintile bucket (0..5) —
|
|
174
|
+
bucket 0 is reserved for zero-cost days; buckets 1..5 are quintiles
|
|
175
|
+
over non-zero days.
|
|
176
|
+
|
|
177
|
+
v2.3: Added per-day token rollup + `cache_hit_pct` so the Daily
|
|
178
|
+
detail modal can surface the same fields the CLI's `daily` command
|
|
179
|
+
shows. Defaults preserve compatibility with `_empty_dashboard_snapshot`
|
|
180
|
+
and any pre-v2.3 fixture that omits the new fields.
|
|
181
|
+
"""
|
|
182
|
+
date: str # local-tz YYYY-MM-DD
|
|
183
|
+
label: str # "MM-DD" — pre-formatted, mirrors Weekly/Monthly idiom
|
|
184
|
+
cost_usd: float
|
|
185
|
+
is_today: bool
|
|
186
|
+
intensity_bucket: int # 0..5
|
|
187
|
+
models: list[dict[str, Any]] # ModelCostRow shape, sorted desc by cost
|
|
188
|
+
# ---- v2.3 additions: Daily modal token + cache rollup ----
|
|
189
|
+
input_tokens: int = 0
|
|
190
|
+
output_tokens: int = 0
|
|
191
|
+
cache_creation_tokens: int = 0
|
|
192
|
+
cache_read_tokens: int = 0
|
|
193
|
+
total_tokens: int = 0
|
|
194
|
+
cache_hit_pct: float | None = None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass
|
|
198
|
+
class TuiSessionRow:
|
|
199
|
+
started_at: dt.datetime
|
|
200
|
+
duration_minutes: float
|
|
201
|
+
model_primary: str # first model used in the session
|
|
202
|
+
cost_usd: float
|
|
203
|
+
cache_hit_pct: float | None
|
|
204
|
+
project_label: str # basename of project_path
|
|
205
|
+
session_id: str # full session UUID (v2: needed for session-detail modal)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# === Internal helpers ======================================================
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _display_tz_label(display_tz) -> str:
|
|
212
|
+
"""Mirror of cctally._share_display_tz_label.
|
|
213
|
+
|
|
214
|
+
Kept here so builders don't depend on the cctally namespace at
|
|
215
|
+
build time. ``ZoneInfo`` -> ``zone.key``; ``None`` -> ``"local"``
|
|
216
|
+
(per resolve_display_tz's convention).
|
|
217
|
+
"""
|
|
218
|
+
return display_tz.key if display_tz is not None else "local"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _model_breakdowns_to_models_late(model_breakdowns, cost_usd):
|
|
222
|
+
"""Late-bound shim for ``_model_breakdowns_to_models`` in
|
|
223
|
+
``_cctally_dashboard``.
|
|
224
|
+
|
|
225
|
+
Cannot eagerly import at module load (``_cctally_dashboard`` is a
|
|
226
|
+
heavier sibling and creating an import-time edge would force its
|
|
227
|
+
side-effects on every builder load). Resolved at first call.
|
|
228
|
+
"""
|
|
229
|
+
mod = _load_lib("_cctally_dashboard")
|
|
230
|
+
return mod._model_breakdowns_to_models(model_breakdowns, cost_usd)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# === DailyView + build_daily_view (Task 3) =================================
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True)
|
|
237
|
+
class DailyView:
|
|
238
|
+
"""Daily domain view — entries-driven, newest-first.
|
|
239
|
+
|
|
240
|
+
``rows`` carries one ``DailyPanelRow`` per *non-empty* day (NO
|
|
241
|
+
gap-fill — the dashboard envelope adapter materializes the
|
|
242
|
+
contiguous heatmap window post-builder; CLI / share consume rows
|
|
243
|
+
as-is to preserve byte-stable ``cctally daily --json``).
|
|
244
|
+
|
|
245
|
+
``aggregated`` is the parallel ``BucketUsage`` tuple from
|
|
246
|
+
``_aggregate_daily`` (same order as ``rows``). CLI's
|
|
247
|
+
``_bucket_to_json`` and ``_render_bucket_table`` plus the share
|
|
248
|
+
``_build_daily_snapshot`` consume this shape; the dashboard
|
|
249
|
+
envelope adapter consumes ``rows``.
|
|
250
|
+
|
|
251
|
+
Carrying both shapes (BucketUsage + DailyPanelRow) mirrors the
|
|
252
|
+
SessionsView pattern in Bundle 2 (spec §6.5) — CLI/share renderers
|
|
253
|
+
today depend on BucketUsage fields (``bucket``, ``model_breakdowns``,
|
|
254
|
+
``models: list[str]``) that aren't present on ``DailyPanelRow``;
|
|
255
|
+
forcing the rename onto the renderer would break byte-stable
|
|
256
|
+
``cctally daily --json``.
|
|
257
|
+
"""
|
|
258
|
+
rows: "tuple[DailyPanelRow, ...]" = ()
|
|
259
|
+
aggregated: tuple = () # tuple[BucketUsage, ...] — forward-ref kept untyped to avoid an import-time edge into the aggregator's BucketUsage shape.
|
|
260
|
+
total_cost_usd: float = 0.0
|
|
261
|
+
total_tokens: int = 0
|
|
262
|
+
period_start: "dt.datetime | None" = None
|
|
263
|
+
period_end: "dt.datetime | None" = None
|
|
264
|
+
display_tz_label: str = ""
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def build_daily_view(entries, *, now_utc, display_tz=None):
|
|
268
|
+
"""Build a ``DailyView`` from raw ``UsageEntry`` list (spec §5.1).
|
|
269
|
+
|
|
270
|
+
Gap-free: only days with entries appear in ``view.rows`` /
|
|
271
|
+
``view.aggregated`` (newest-first). The contiguous-window
|
|
272
|
+
materialization the dashboard heatmap needs is presentation logic
|
|
273
|
+
and stays at the dashboard envelope adapter.
|
|
274
|
+
|
|
275
|
+
Per-row derivations: ``cache_hit_pct`` (cache_read / (input +
|
|
276
|
+
cache_creation + cache_read) * 100), ``is_today`` (date == today
|
|
277
|
+
in display_tz), ``models[]`` via
|
|
278
|
+
``_model_breakdowns_to_models``.
|
|
279
|
+
|
|
280
|
+
Leaves ``DailyPanelRow.label`` and ``intensity_bucket`` at dataclass
|
|
281
|
+
defaults — the dashboard envelope adapter populates them. CLI /
|
|
282
|
+
share consumers ignore them and read ``view.aggregated`` instead.
|
|
283
|
+
"""
|
|
284
|
+
_agg = _load_lib("_lib_aggregators")
|
|
285
|
+
buckets = _agg._aggregate_daily(entries, mode="auto", tz=display_tz)
|
|
286
|
+
if not buckets:
|
|
287
|
+
return DailyView(
|
|
288
|
+
rows=(),
|
|
289
|
+
aggregated=(),
|
|
290
|
+
total_cost_usd=0.0,
|
|
291
|
+
total_tokens=0,
|
|
292
|
+
period_start=None,
|
|
293
|
+
period_end=now_utc,
|
|
294
|
+
display_tz_label=_display_tz_label(display_tz),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
today_local = (
|
|
298
|
+
now_utc.astimezone(display_tz) if display_tz is not None
|
|
299
|
+
# internal fallback: host-local intentional
|
|
300
|
+
else now_utc.astimezone()
|
|
301
|
+
).date()
|
|
302
|
+
|
|
303
|
+
rows = []
|
|
304
|
+
# buckets come oldest-first from _aggregate_daily; reverse for newest-first.
|
|
305
|
+
reversed_buckets = list(reversed(buckets))
|
|
306
|
+
total_cost = 0.0
|
|
307
|
+
total_tok = 0
|
|
308
|
+
for b in reversed_buckets:
|
|
309
|
+
denom = b.input_tokens + b.cache_creation_tokens + b.cache_read_tokens
|
|
310
|
+
cache_hit = (b.cache_read_tokens / denom * 100.0) if denom > 0 else None
|
|
311
|
+
d = dt.date.fromisoformat(b.bucket)
|
|
312
|
+
row = DailyPanelRow(
|
|
313
|
+
date=b.bucket,
|
|
314
|
+
label="", # adapter fills
|
|
315
|
+
cost_usd=b.cost_usd,
|
|
316
|
+
is_today=(d == today_local),
|
|
317
|
+
intensity_bucket=0, # adapter fills
|
|
318
|
+
models=_model_breakdowns_to_models_late(
|
|
319
|
+
b.model_breakdowns, b.cost_usd,
|
|
320
|
+
),
|
|
321
|
+
input_tokens=b.input_tokens,
|
|
322
|
+
output_tokens=b.output_tokens,
|
|
323
|
+
cache_creation_tokens=b.cache_creation_tokens,
|
|
324
|
+
cache_read_tokens=b.cache_read_tokens,
|
|
325
|
+
total_tokens=b.total_tokens,
|
|
326
|
+
cache_hit_pct=cache_hit,
|
|
327
|
+
)
|
|
328
|
+
rows.append(row)
|
|
329
|
+
total_cost += b.cost_usd
|
|
330
|
+
total_tok += b.total_tokens
|
|
331
|
+
|
|
332
|
+
earliest = dt.date.fromisoformat(buckets[0].bucket)
|
|
333
|
+
period_start = dt.datetime.combine(
|
|
334
|
+
earliest, dt.time.min, tzinfo=dt.timezone.utc,
|
|
335
|
+
)
|
|
336
|
+
return DailyView(
|
|
337
|
+
rows=tuple(rows),
|
|
338
|
+
aggregated=tuple(reversed_buckets),
|
|
339
|
+
total_cost_usd=total_cost,
|
|
340
|
+
total_tokens=total_tok,
|
|
341
|
+
period_start=period_start,
|
|
342
|
+
period_end=now_utc,
|
|
343
|
+
display_tz_label=_display_tz_label(display_tz),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# === MonthlyView + build_monthly_view (Task 8) =============================
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@dataclass(frozen=True)
|
|
351
|
+
class MonthlyView:
|
|
352
|
+
"""Monthly domain view — entries-driven, newest-first.
|
|
353
|
+
|
|
354
|
+
Like ``DailyView``: ``rows`` carries the typed ``MonthlyPeriodRow``
|
|
355
|
+
tuple for dashboard/share, ``aggregated`` carries the parallel
|
|
356
|
+
``BucketUsage`` tuple for CLI byte-stability.
|
|
357
|
+
|
|
358
|
+
Boundary-spillover bucket is dropped (mirrors the existing dashboard
|
|
359
|
+
builder at ``_dashboard_build_monthly_periods:1752``):
|
|
360
|
+
in tzs west of UTC, the bucket builder emits an extra ``YYYY-MM``
|
|
361
|
+
row for entries that straddle the UTC range start into the prior
|
|
362
|
+
local month. Slicing to ``n`` after reversal drops it.
|
|
363
|
+
|
|
364
|
+
``delta_cost_pct`` is computed per row vs the next-older row; the
|
|
365
|
+
oldest row's value is ``None`` (no prior to compare against).
|
|
366
|
+
"""
|
|
367
|
+
rows: "tuple[MonthlyPeriodRow, ...]" = ()
|
|
368
|
+
aggregated: tuple = () # tuple[BucketUsage, ...] — forward-ref kept untyped to avoid an import-time edge into the aggregator's BucketUsage shape.
|
|
369
|
+
total_cost_usd: float = 0.0
|
|
370
|
+
total_tokens: int = 0
|
|
371
|
+
period_start: "dt.datetime | None" = None
|
|
372
|
+
period_end: "dt.datetime | None" = None
|
|
373
|
+
display_tz_label: str = ""
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def build_monthly_view(entries, *, now_utc, n=12, display_tz=None):
|
|
377
|
+
"""Build a ``MonthlyView`` for the trailing ``n`` calendar months
|
|
378
|
+
(spec §5.2).
|
|
379
|
+
|
|
380
|
+
Calls ``_aggregate_monthly``. Drops the boundary-spillover bucket
|
|
381
|
+
(mirrors ``_dashboard_build_monthly_periods``). Computes
|
|
382
|
+
``delta_cost_pct`` per row vs the next-older row. Newest-first.
|
|
383
|
+
|
|
384
|
+
Totals (``total_cost_usd`` / ``total_tokens``) sum over the
|
|
385
|
+
truncated row set so the React panel sees the same number as the
|
|
386
|
+
CLI table footer would.
|
|
387
|
+
"""
|
|
388
|
+
_agg = _load_lib("_lib_aggregators")
|
|
389
|
+
buckets = _agg._aggregate_monthly(entries, mode="auto", tz=display_tz)
|
|
390
|
+
if not buckets:
|
|
391
|
+
return MonthlyView(
|
|
392
|
+
rows=(), aggregated=(),
|
|
393
|
+
total_cost_usd=0.0, total_tokens=0,
|
|
394
|
+
period_start=None, period_end=now_utc,
|
|
395
|
+
display_tz_label=_display_tz_label(display_tz),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Reverse for newest-first AND cap to n BEFORE the delta loop —
|
|
399
|
+
# boundary-spillover drop (see MonthlyView docstring).
|
|
400
|
+
buckets = list(reversed(buckets))[:n]
|
|
401
|
+
cur_label = (
|
|
402
|
+
now_utc.astimezone(display_tz) if display_tz is not None
|
|
403
|
+
# internal fallback: host-local intentional
|
|
404
|
+
else now_utc.astimezone()
|
|
405
|
+
).strftime("%Y-%m")
|
|
406
|
+
|
|
407
|
+
rows = []
|
|
408
|
+
total_cost = 0.0
|
|
409
|
+
total_tok = 0
|
|
410
|
+
for i, b in enumerate(buckets):
|
|
411
|
+
prev = buckets[i + 1] if i + 1 < len(buckets) else None
|
|
412
|
+
delta = None
|
|
413
|
+
if prev is not None and prev.cost_usd > 0:
|
|
414
|
+
delta = (b.cost_usd - prev.cost_usd) / prev.cost_usd
|
|
415
|
+
rows.append(MonthlyPeriodRow(
|
|
416
|
+
label=b.bucket, # "YYYY-MM"
|
|
417
|
+
cost_usd=b.cost_usd,
|
|
418
|
+
total_tokens=b.total_tokens,
|
|
419
|
+
input_tokens=b.input_tokens,
|
|
420
|
+
output_tokens=b.output_tokens,
|
|
421
|
+
cache_creation_tokens=b.cache_creation_tokens,
|
|
422
|
+
cache_read_tokens=b.cache_read_tokens,
|
|
423
|
+
delta_cost_pct=delta,
|
|
424
|
+
is_current=(b.bucket == cur_label),
|
|
425
|
+
models=_model_breakdowns_to_models_late(
|
|
426
|
+
b.model_breakdowns, b.cost_usd,
|
|
427
|
+
),
|
|
428
|
+
))
|
|
429
|
+
total_cost += b.cost_usd
|
|
430
|
+
total_tok += b.total_tokens
|
|
431
|
+
|
|
432
|
+
# period_start = first day of the oldest visible month, UTC.
|
|
433
|
+
earliest_label = buckets[-1].bucket
|
|
434
|
+
yr, mo = earliest_label.split("-")
|
|
435
|
+
period_start = dt.datetime(
|
|
436
|
+
int(yr), int(mo), 1, tzinfo=dt.timezone.utc,
|
|
437
|
+
)
|
|
438
|
+
return MonthlyView(
|
|
439
|
+
rows=tuple(rows),
|
|
440
|
+
aggregated=tuple(buckets),
|
|
441
|
+
total_cost_usd=total_cost,
|
|
442
|
+
total_tokens=total_tok,
|
|
443
|
+
period_start=period_start,
|
|
444
|
+
period_end=now_utc,
|
|
445
|
+
display_tz_label=_display_tz_label(display_tz),
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# === WeeklyView + build_weekly_view (Task 9) ===============================
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@dataclass(frozen=True)
|
|
453
|
+
class WeeklyView:
|
|
454
|
+
"""Weekly domain view — subscription-week aligned, newest-first.
|
|
455
|
+
|
|
456
|
+
``rows`` carries the typed ``WeeklyPeriodRow`` tuple (already
|
|
457
|
+
overlaid with ``weekly_usage_snapshots``); ``aggregated`` carries
|
|
458
|
+
the parallel ``BucketUsage`` tuple for CLI byte-stability;
|
|
459
|
+
``overlay`` carries ``(used_pct, dollar_per_pct)`` tuples in the
|
|
460
|
+
same order as ``aggregated`` (drives the CLI's
|
|
461
|
+
``_render_weekly_table`` / ``_weekly_to_json``).
|
|
462
|
+
|
|
463
|
+
``rows`` and ``aggregated`` are both newest-first. ``overlay``
|
|
464
|
+
aligns with ``aggregated``.
|
|
465
|
+
"""
|
|
466
|
+
rows: "tuple[WeeklyPeriodRow, ...]" = ()
|
|
467
|
+
aggregated: tuple = () # tuple[BucketUsage, ...] — forward-ref kept untyped to avoid an import-time edge into the aggregator's BucketUsage shape.
|
|
468
|
+
overlay: "tuple[tuple[float | None, float | None], ...]" = ()
|
|
469
|
+
total_cost_usd: float = 0.0
|
|
470
|
+
total_tokens: int = 0
|
|
471
|
+
period_start: "dt.datetime | None" = None
|
|
472
|
+
period_end: "dt.datetime | None" = None
|
|
473
|
+
display_tz_label: str = ""
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
|
|
477
|
+
as_of_utc=None):
|
|
478
|
+
"""Build a ``WeeklyView`` from subscription-week boundaries
|
|
479
|
+
(spec §5.3).
|
|
480
|
+
|
|
481
|
+
``weeks`` is the ``list[SubWeek]`` computed by the caller via
|
|
482
|
+
``_compute_subscription_weeks(conn, range_start, range_end)``.
|
|
483
|
+
|
|
484
|
+
Calls ``_aggregate_weekly(entries, weeks)`` and overlays
|
|
485
|
+
``weekly_usage_snapshots`` per ``WeekRef`` via
|
|
486
|
+
``get_latest_usage_for_week`` (clamped to ``as_of_utc`` when
|
|
487
|
+
provided — the CLI passes ``range_end``-Z so historical
|
|
488
|
+
``--until <past>`` queries pick the period-relevant snapshot, not
|
|
489
|
+
today's). Derives ``dollar_per_pct``, ``delta_cost_pct``,
|
|
490
|
+
``is_current``.
|
|
491
|
+
|
|
492
|
+
Output is newest-first across ``rows`` / ``aggregated`` /
|
|
493
|
+
``overlay`` — the CLI re-reverses for asc rendering.
|
|
494
|
+
"""
|
|
495
|
+
_agg = _load_lib("_lib_aggregators")
|
|
496
|
+
_cct_core = _load_lib("_cctally_core")
|
|
497
|
+
buckets_asc = _agg._aggregate_weekly(entries, weeks)
|
|
498
|
+
if not buckets_asc:
|
|
499
|
+
return WeeklyView(
|
|
500
|
+
rows=(), aggregated=(), overlay=(),
|
|
501
|
+
total_cost_usd=0.0, total_tokens=0,
|
|
502
|
+
period_start=None, period_end=now_utc,
|
|
503
|
+
display_tz_label=_display_tz_label(display_tz),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# Index SubWeek by `start_date.isoformat()` — the invariant
|
|
507
|
+
# _aggregate_weekly enforces is one-to-one between bucket key and
|
|
508
|
+
# SubWeek.
|
|
509
|
+
week_by_key = {w.start_date.isoformat(): w for w in weeks}
|
|
510
|
+
parse_iso = _cct_core.parse_iso_datetime
|
|
511
|
+
make_ref = _cct_core.make_week_ref
|
|
512
|
+
get_usage = _cct_core.get_latest_usage_for_week
|
|
513
|
+
|
|
514
|
+
# Build asc overlay + asc WeeklyPeriodRow list first; reverse later.
|
|
515
|
+
asc_overlay: list = []
|
|
516
|
+
asc_rows: list = []
|
|
517
|
+
total_cost = 0.0
|
|
518
|
+
total_tok = 0
|
|
519
|
+
for i, b in enumerate(buckets_asc):
|
|
520
|
+
sw = week_by_key.get(b.bucket)
|
|
521
|
+
if sw is None:
|
|
522
|
+
# _aggregate_weekly invariant: every emitted bucket key maps
|
|
523
|
+
# 1:1 to a SubWeek in ``weeks``. Surface the invariant
|
|
524
|
+
# violation loudly rather than silently desynchronizing the
|
|
525
|
+
# three parallel lists (``asc_rows`` vs ``asc_overlay`` vs
|
|
526
|
+
# ``buckets_asc``) — under the prior defensive ``continue``
|
|
527
|
+
# branch, ``asc_overlay`` would advance one slot while
|
|
528
|
+
# ``asc_rows`` stayed put, creating a latent index-misalign
|
|
529
|
+
# for any consumer reading ``view.rows`` parallel to
|
|
530
|
+
# ``view.aggregated`` / ``view.overlay``.
|
|
531
|
+
raise AssertionError(
|
|
532
|
+
f"_aggregate_weekly emitted bucket without matching "
|
|
533
|
+
f"SubWeek (invariant violation): bucket={b.bucket!r}"
|
|
534
|
+
)
|
|
535
|
+
ref = make_ref(
|
|
536
|
+
week_start_date=sw.start_date.isoformat(),
|
|
537
|
+
week_end_date=sw.end_date.isoformat(),
|
|
538
|
+
week_start_at=sw.start_ts,
|
|
539
|
+
week_end_at=sw.end_ts,
|
|
540
|
+
)
|
|
541
|
+
usage_row = get_usage(conn, ref, as_of_utc=as_of_utc)
|
|
542
|
+
used_pct = None
|
|
543
|
+
if usage_row is not None and usage_row["weekly_percent"] is not None:
|
|
544
|
+
used_pct = float(usage_row["weekly_percent"])
|
|
545
|
+
dpp = (b.cost_usd / used_pct) if (used_pct and used_pct > 0) else None
|
|
546
|
+
asc_overlay.append((used_pct, dpp))
|
|
547
|
+
|
|
548
|
+
# delta_cost_pct vs the prior (older) bucket. asc order: prior
|
|
549
|
+
# is at index i - 1.
|
|
550
|
+
prev = buckets_asc[i - 1] if i > 0 else None
|
|
551
|
+
delta = None
|
|
552
|
+
if prev is not None and prev.cost_usd > 0:
|
|
553
|
+
delta = (b.cost_usd - prev.cost_usd) / prev.cost_usd
|
|
554
|
+
|
|
555
|
+
# is_current: now_utc falls inside [start_ts, end_ts).
|
|
556
|
+
try:
|
|
557
|
+
sw_start = parse_iso(sw.start_ts, "week.start_ts")
|
|
558
|
+
sw_end = parse_iso(sw.end_ts, "week.end_ts")
|
|
559
|
+
is_current = sw_start <= now_utc < sw_end
|
|
560
|
+
except ValueError:
|
|
561
|
+
# parse_iso_datetime raises ValueError on malformed ISO; any
|
|
562
|
+
# other exception is a genuine bug — let it propagate.
|
|
563
|
+
is_current = False
|
|
564
|
+
|
|
565
|
+
asc_rows.append(WeeklyPeriodRow(
|
|
566
|
+
label=sw.start_date.strftime("%m-%d"),
|
|
567
|
+
cost_usd=b.cost_usd,
|
|
568
|
+
total_tokens=b.total_tokens,
|
|
569
|
+
input_tokens=b.input_tokens,
|
|
570
|
+
output_tokens=b.output_tokens,
|
|
571
|
+
cache_creation_tokens=b.cache_creation_tokens,
|
|
572
|
+
cache_read_tokens=b.cache_read_tokens,
|
|
573
|
+
used_pct=used_pct,
|
|
574
|
+
dollar_per_pct=dpp,
|
|
575
|
+
delta_cost_pct=delta,
|
|
576
|
+
is_current=is_current,
|
|
577
|
+
models=_model_breakdowns_to_models_late(
|
|
578
|
+
b.model_breakdowns, b.cost_usd,
|
|
579
|
+
),
|
|
580
|
+
week_start_at=sw.start_ts,
|
|
581
|
+
week_end_at=sw.end_ts,
|
|
582
|
+
))
|
|
583
|
+
total_cost += b.cost_usd
|
|
584
|
+
total_tok += b.total_tokens
|
|
585
|
+
|
|
586
|
+
# Reverse to newest-first across all three parallel lists.
|
|
587
|
+
rows = list(reversed(asc_rows))
|
|
588
|
+
aggregated = list(reversed(buckets_asc))
|
|
589
|
+
overlay = list(reversed(asc_overlay))
|
|
590
|
+
|
|
591
|
+
period_start_dt = None
|
|
592
|
+
if weeks:
|
|
593
|
+
try:
|
|
594
|
+
period_start_dt = parse_iso(weeks[0].start_ts, "weeks[0].start_ts")
|
|
595
|
+
except ValueError:
|
|
596
|
+
# parse_iso_datetime raises ValueError on malformed ISO; any
|
|
597
|
+
# other exception is a genuine bug — let it propagate.
|
|
598
|
+
period_start_dt = None
|
|
599
|
+
return WeeklyView(
|
|
600
|
+
rows=tuple(rows),
|
|
601
|
+
aggregated=tuple(aggregated),
|
|
602
|
+
overlay=tuple(overlay),
|
|
603
|
+
total_cost_usd=total_cost,
|
|
604
|
+
total_tokens=total_tok,
|
|
605
|
+
period_start=period_start_dt,
|
|
606
|
+
period_end=now_utc,
|
|
607
|
+
display_tz_label=_display_tz_label(display_tz),
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# === TrendView + build_trend_view (Task 10) ================================
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@dataclass(frozen=True)
|
|
615
|
+
class TrendView:
|
|
616
|
+
"""Trend view — last n subscription weeks for cmd_report / TUI / dashboard
|
|
617
|
+
trend panel / share `_build_report_snapshot` (spec §4.2, §5.4).
|
|
618
|
+
|
|
619
|
+
Rows are typed ``TuiTrendRow`` (extended per spec §4.1 with 10
|
|
620
|
+
nullable fields so the same shape serves both the TUI's 7-field
|
|
621
|
+
surface and ``cmd_report``'s 11-field JSON contract).
|
|
622
|
+
|
|
623
|
+
``avg_dollars_per_pct`` is the 3-sample-rule mean (None when fewer
|
|
624
|
+
than 3 rows carry a non-None ``dollars_per_percent``). The
|
|
625
|
+
dashboard envelope adapter emits it as ``trend.avg_dollars_per_pct``
|
|
626
|
+
so the React layer doesn't re-derive.
|
|
627
|
+
|
|
628
|
+
Row ordering matches ``cmd_report`` / ``_tui_build_trend``:
|
|
629
|
+
chronological (oldest first), suitable for the TUI sparkline left-
|
|
630
|
+
to-right walk + cmd_report's `--json` trend list (which is then
|
|
631
|
+
sorted by recency at the render site).
|
|
632
|
+
"""
|
|
633
|
+
rows: "tuple[TuiTrendRow, ...]" = () # oldest-first
|
|
634
|
+
avg_dollars_per_pct: "float | None" = None
|
|
635
|
+
period_start: "dt.datetime | None" = None
|
|
636
|
+
period_end: "dt.datetime | None" = None
|
|
637
|
+
display_tz_label: str = ""
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
|
|
641
|
+
"""Build a ``TrendView`` of the last ``n`` subscription weeks
|
|
642
|
+
(spec §5.4).
|
|
643
|
+
|
|
644
|
+
Reads ``weekly_usage_snapshots`` for usage% per ``WeekRef``. Reads
|
|
645
|
+
``weekly_cost_snapshots`` for cost — EXCEPT for weeks touched by a
|
|
646
|
+
reset event, where the builder bypasses the cache and live-
|
|
647
|
+
computes cost from ``session_entries`` via
|
|
648
|
+
``_compute_cost_for_weekref(week_ref)``. This matches the existing
|
|
649
|
+
cmd_report path (``bin/cctally:~7969-7979``) and ``_tui_build_trend``
|
|
650
|
+
(``bin/_cctally_tui.py:~1515-1524``).
|
|
651
|
+
|
|
652
|
+
Rows are emitted oldest-first (chronological); each row populates
|
|
653
|
+
all 17 ``TuiTrendRow`` fields (7 historical + 10 extended).
|
|
654
|
+
``delta_dpp`` is computed against the prior chronological row's
|
|
655
|
+
non-None dpp. ``spark_height`` is normalized 1..8 across the
|
|
656
|
+
window's valid dpp samples.
|
|
657
|
+
|
|
658
|
+
``avg_dollars_per_pct`` follows the 3-sample rule (spec §4.3):
|
|
659
|
+
mean over non-None ``dollars_per_percent`` values iff at least 3
|
|
660
|
+
rows qualify; else None. Mirrors
|
|
661
|
+
``_build_report_snapshot``'s historical behavior.
|
|
662
|
+
"""
|
|
663
|
+
_cct_core = _load_lib("_cctally_core")
|
|
664
|
+
parse_iso = _cct_core.parse_iso_datetime
|
|
665
|
+
make_ref = _cct_core.make_week_ref
|
|
666
|
+
get_usage = _cct_core.get_latest_usage_for_week
|
|
667
|
+
c = _cctally()
|
|
668
|
+
|
|
669
|
+
week_refs = c.get_recent_weeks(conn, max(1, n))
|
|
670
|
+
if not week_refs:
|
|
671
|
+
return TrendView(
|
|
672
|
+
rows=(), avg_dollars_per_pct=None,
|
|
673
|
+
period_start=None, period_end=now_utc,
|
|
674
|
+
display_tz_label=_display_tz_label(display_tz),
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# Determine current_key + current_week_start_at — same pattern as
|
|
678
|
+
# _tui_build_trend / cmd_report's Bug D handling.
|
|
679
|
+
latest_usage = conn.execute(
|
|
680
|
+
"SELECT week_start_date, week_end_date "
|
|
681
|
+
"FROM weekly_usage_snapshots "
|
|
682
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
683
|
+
).fetchone()
|
|
684
|
+
current_key = None
|
|
685
|
+
current_week_start_at = None
|
|
686
|
+
if latest_usage is not None and latest_usage["week_start_date"] is not None:
|
|
687
|
+
current_key = latest_usage["week_start_date"]
|
|
688
|
+
try:
|
|
689
|
+
canon_start, canon_end = c._get_canonical_boundary_for_date(
|
|
690
|
+
conn, latest_usage["week_start_date"]
|
|
691
|
+
)
|
|
692
|
+
current_ref = make_ref(
|
|
693
|
+
week_start_date=latest_usage["week_start_date"],
|
|
694
|
+
week_end_date=latest_usage["week_end_date"],
|
|
695
|
+
week_start_at=canon_start,
|
|
696
|
+
week_end_at=canon_end,
|
|
697
|
+
)
|
|
698
|
+
_adjusted = c._apply_reset_events_to_weekrefs(conn, [current_ref])
|
|
699
|
+
if _adjusted:
|
|
700
|
+
current_week_start_at = _adjusted[0].week_start_at
|
|
701
|
+
except Exception:
|
|
702
|
+
current_week_start_at = None
|
|
703
|
+
|
|
704
|
+
# Build a chronological (oldest-first) intermediate over week_refs.
|
|
705
|
+
# week_refs come newest-first from get_recent_weeks; reverse.
|
|
706
|
+
chrono = list(reversed(week_refs))
|
|
707
|
+
|
|
708
|
+
# Split-key set (Bug D): credited weeks appear twice in week_refs
|
|
709
|
+
# with identical WeekRef.key. Pin as_of_utc=week_end_at for those
|
|
710
|
+
# so each segment finds its own latest snapshot.
|
|
711
|
+
split_keys = {
|
|
712
|
+
r.key for r in week_refs
|
|
713
|
+
if sum(1 for x in week_refs if x.key == r.key) > 1
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
try:
|
|
717
|
+
_fresh_cfg = c._get_oauth_usage_config(c.load_config())
|
|
718
|
+
except Exception:
|
|
719
|
+
_fresh_cfg = c._get_oauth_usage_config({})
|
|
720
|
+
|
|
721
|
+
intermediate: list = []
|
|
722
|
+
for week_ref in chrono:
|
|
723
|
+
usage = get_usage(
|
|
724
|
+
conn, week_ref,
|
|
725
|
+
as_of_utc=(
|
|
726
|
+
week_ref.week_end_at if week_ref.key in split_keys else None
|
|
727
|
+
),
|
|
728
|
+
)
|
|
729
|
+
usage_captured_at = usage["captured_at_utc"] if usage else None
|
|
730
|
+
if c._week_ref_has_reset_event(conn, week_ref):
|
|
731
|
+
cost_usd = c._compute_cost_for_weekref(week_ref)
|
|
732
|
+
cost_captured_at = (
|
|
733
|
+
usage_captured_at if cost_usd is not None else None
|
|
734
|
+
)
|
|
735
|
+
range_start_iso = week_ref.week_start_at
|
|
736
|
+
range_end_iso = week_ref.week_end_at
|
|
737
|
+
else:
|
|
738
|
+
cost = c.get_latest_cost_for_week(conn, week_ref)
|
|
739
|
+
cost_usd = float(cost["cost_usd"]) if cost else None
|
|
740
|
+
cost_captured_at = cost["captured_at_utc"] if cost else None
|
|
741
|
+
range_start_iso = (
|
|
742
|
+
cost["range_start_iso"] if cost and cost["range_start_iso"]
|
|
743
|
+
else None
|
|
744
|
+
)
|
|
745
|
+
range_end_iso = (
|
|
746
|
+
cost["range_end_iso"] if cost and cost["range_end_iso"]
|
|
747
|
+
else None
|
|
748
|
+
)
|
|
749
|
+
percent = float(usage["weekly_percent"]) if usage else None
|
|
750
|
+
ratio = (
|
|
751
|
+
cost_usd / percent
|
|
752
|
+
if (cost_usd is not None and percent and percent > 0)
|
|
753
|
+
else None
|
|
754
|
+
)
|
|
755
|
+
intermediate.append({
|
|
756
|
+
"week_ref": week_ref,
|
|
757
|
+
"used_pct": percent,
|
|
758
|
+
"cost_usd": cost_usd,
|
|
759
|
+
"dpp": ratio,
|
|
760
|
+
"usage_captured_at": usage_captured_at,
|
|
761
|
+
"cost_captured_at": cost_captured_at,
|
|
762
|
+
"range_start_iso": range_start_iso,
|
|
763
|
+
"range_end_iso": range_end_iso,
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
# Normalize dpp into spark heights 1..8 across the chrono window.
|
|
767
|
+
dpps = [d["dpp"] for d in intermediate if d["dpp"] is not None]
|
|
768
|
+
if dpps:
|
|
769
|
+
lo, hi = min(dpps), max(dpps)
|
|
770
|
+
span = (hi - lo) or 1e-9
|
|
771
|
+
else:
|
|
772
|
+
lo, hi, span = 0.0, 1.0, 1e-9
|
|
773
|
+
|
|
774
|
+
rows: list = []
|
|
775
|
+
prev_dpp: float | None = None
|
|
776
|
+
for d in intermediate:
|
|
777
|
+
week_ref = d["week_ref"]
|
|
778
|
+
percent = d["used_pct"]
|
|
779
|
+
cost_usd = d["cost_usd"]
|
|
780
|
+
dpp = d["dpp"]
|
|
781
|
+
usage_captured_at = d["usage_captured_at"]
|
|
782
|
+
cost_captured_at = d["cost_captured_at"]
|
|
783
|
+
range_start_iso = d["range_start_iso"]
|
|
784
|
+
range_end_iso = d["range_end_iso"]
|
|
785
|
+
|
|
786
|
+
delta = (
|
|
787
|
+
(dpp - prev_dpp)
|
|
788
|
+
if (dpp is not None and prev_dpp is not None) else None
|
|
789
|
+
)
|
|
790
|
+
spark = 1
|
|
791
|
+
if dpp is not None:
|
|
792
|
+
spark = int(round((dpp - lo) / span * 7)) + 1
|
|
793
|
+
spark = max(1, min(8, spark))
|
|
794
|
+
|
|
795
|
+
# WeekRef.week_start is a date; build a tz-aware datetime.
|
|
796
|
+
if week_ref.week_start_at:
|
|
797
|
+
week_start_dt = parse_iso(week_ref.week_start_at, "week_start_at")
|
|
798
|
+
_format_dt = c.format_display_dt
|
|
799
|
+
week_label = _format_dt(
|
|
800
|
+
week_start_dt, display_tz, fmt="%b %d", suffix=False,
|
|
801
|
+
)
|
|
802
|
+
else:
|
|
803
|
+
week_start_dt = dt.datetime.combine(
|
|
804
|
+
week_ref.week_start, dt.time(0, 0), dt.timezone.utc,
|
|
805
|
+
)
|
|
806
|
+
week_label = week_ref.week_start.strftime("%b %d")
|
|
807
|
+
|
|
808
|
+
is_cur = (
|
|
809
|
+
current_key is not None
|
|
810
|
+
and week_ref.key == current_key
|
|
811
|
+
and (
|
|
812
|
+
current_week_start_at is None
|
|
813
|
+
or week_ref.week_start_at == current_week_start_at
|
|
814
|
+
)
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
# as_of = max(usage_captured_at, cost_captured_at).
|
|
818
|
+
usage_dt = c._parse_iso_datetime_optional(usage_captured_at)
|
|
819
|
+
cost_dt = c._parse_iso_datetime_optional(cost_captured_at)
|
|
820
|
+
if usage_dt and cost_dt:
|
|
821
|
+
as_of_dt = usage_dt if usage_dt >= cost_dt else cost_dt
|
|
822
|
+
else:
|
|
823
|
+
as_of_dt = usage_dt or cost_dt
|
|
824
|
+
as_of = as_of_dt.isoformat(timespec="seconds") if as_of_dt else None
|
|
825
|
+
|
|
826
|
+
freshness = None
|
|
827
|
+
if usage_captured_at:
|
|
828
|
+
age_s = c._seconds_since_iso(usage_captured_at)
|
|
829
|
+
if age_s is not None and age_s <= 86400:
|
|
830
|
+
freshness = {
|
|
831
|
+
"label": c._freshness_label(age_s, _fresh_cfg),
|
|
832
|
+
"captured_at": usage_captured_at,
|
|
833
|
+
"age_seconds": int(age_s),
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
rows.append(TuiTrendRow(
|
|
837
|
+
week_label=week_label,
|
|
838
|
+
week_start_at=week_start_dt,
|
|
839
|
+
used_pct=percent,
|
|
840
|
+
dollars_per_percent=dpp,
|
|
841
|
+
delta_dpp=delta,
|
|
842
|
+
spark_height=spark,
|
|
843
|
+
is_current=is_cur,
|
|
844
|
+
# Extended fields (spec §4.1)
|
|
845
|
+
week_start_date=week_ref.week_start,
|
|
846
|
+
week_end_date=week_ref.week_end,
|
|
847
|
+
week_end_at=(
|
|
848
|
+
parse_iso(week_ref.week_end_at, "week_end_at")
|
|
849
|
+
if week_ref.week_end_at else None
|
|
850
|
+
),
|
|
851
|
+
weekly_cost_usd=cost_usd,
|
|
852
|
+
usage_captured_at=usage_captured_at,
|
|
853
|
+
cost_captured_at=cost_captured_at,
|
|
854
|
+
as_of=as_of,
|
|
855
|
+
range_start_iso=range_start_iso,
|
|
856
|
+
range_end_iso=range_end_iso,
|
|
857
|
+
freshness=freshness,
|
|
858
|
+
))
|
|
859
|
+
if dpp is not None:
|
|
860
|
+
prev_dpp = dpp
|
|
861
|
+
|
|
862
|
+
# 3-sample average rule (spec §4.3): mean of non-None dpps iff at
|
|
863
|
+
# least 3 samples qualify.
|
|
864
|
+
valid_dpps = [r.dollars_per_percent for r in rows
|
|
865
|
+
if r.dollars_per_percent is not None]
|
|
866
|
+
avg = (sum(valid_dpps) / len(valid_dpps)) if len(valid_dpps) >= 3 else None
|
|
867
|
+
|
|
868
|
+
return TrendView(
|
|
869
|
+
rows=tuple(rows),
|
|
870
|
+
avg_dollars_per_pct=avg,
|
|
871
|
+
period_start=None,
|
|
872
|
+
period_end=now_utc,
|
|
873
|
+
display_tz_label=_display_tz_label(display_tz),
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
# === SessionsView + build_sessions_view (Task 13) ==========================
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
@dataclass(frozen=True)
|
|
881
|
+
class SessionsView:
|
|
882
|
+
"""Sessions domain view — Claude sessions (merged across resumes),
|
|
883
|
+
last-activity descending.
|
|
884
|
+
|
|
885
|
+
Dual-shape per the spec §6.5 pattern that the daily/monthly/weekly
|
|
886
|
+
views established:
|
|
887
|
+
|
|
888
|
+
- ``rows`` carries the typed ``TuiSessionRow`` tuple consumed by the
|
|
889
|
+
TUI sessions panel and the dashboard session-detail surface.
|
|
890
|
+
- ``aggregated`` carries the parallel ``ClaudeSessionUsage`` tuple
|
|
891
|
+
consumed by ``cmd_session`` (CLI table + ``--json``) and the
|
|
892
|
+
share ``_build_session_snapshot`` (needs ``source_paths``,
|
|
893
|
+
``model_breakdowns``, ``last_activity`` — fields ``TuiSessionRow``
|
|
894
|
+
doesn't carry).
|
|
895
|
+
|
|
896
|
+
Both shapes derive from the SAME ``_aggregate_claude_sessions``
|
|
897
|
+
call so the resumed-session merge invariant (``CLAUDE.md`` "Cost /
|
|
898
|
+
weekly / session" gotcha block — a ``sessionId`` across multiple
|
|
899
|
+
JSONL files collapses into ONE row) is preserved end-to-end:
|
|
900
|
+
``rows[i]`` and ``aggregated[i]`` describe the same merged
|
|
901
|
+
sessionId.
|
|
902
|
+
|
|
903
|
+
``total_sessions == len(rows) == len(aggregated)`` always (spec
|
|
904
|
+
§4.3). Empty entries → ``rows=()``, ``aggregated=()``, totals
|
|
905
|
+
zero — no exceptions on empty input.
|
|
906
|
+
|
|
907
|
+
``limit=None`` keeps the full aggregator output (CLI use case —
|
|
908
|
+
``cctally session`` has no ``--limit`` flag and emits everything in
|
|
909
|
+
the date range). ``limit`` ≥ 1 truncates BOTH parallel tuples to
|
|
910
|
+
the leading ``limit`` rows (TUI / dashboard use case — the
|
|
911
|
+
sessions pane promises "last N sessions"). The aggregator's
|
|
912
|
+
descending-by-last-activity sort means the leading rows are the
|
|
913
|
+
most recent; the TUI's ``[:100]`` cap stays semantic-stable.
|
|
914
|
+
"""
|
|
915
|
+
rows: "tuple[TuiSessionRow, ...]" = ()
|
|
916
|
+
aggregated: tuple = () # tuple[ClaudeSessionUsage, ...] — forward-ref kept untyped to avoid an import-time edge into the aggregator's ClaudeSessionUsage shape.
|
|
917
|
+
total_sessions: int = 0
|
|
918
|
+
total_cost_usd: float = 0.0
|
|
919
|
+
period_start: "dt.datetime | None" = None
|
|
920
|
+
period_end: "dt.datetime | None" = None
|
|
921
|
+
display_tz_label: str = ""
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None):
|
|
925
|
+
"""Build a ``SessionsView`` from joined Claude session entries
|
|
926
|
+
(spec §5.5).
|
|
927
|
+
|
|
928
|
+
``entries`` is the ``list[_JoinedClaudeEntry]`` from
|
|
929
|
+
``get_claude_session_entries(range_start, range_end)``. The caller
|
|
930
|
+
controls the date window + ``skip_sync`` semantics; the builder
|
|
931
|
+
does no I/O of its own beyond what ``_aggregate_claude_sessions``
|
|
932
|
+
already does (cost recompute from ``CLAUDE_MODEL_PRICING``).
|
|
933
|
+
|
|
934
|
+
Per-row derivations (mirror today's ``_tui_build_sessions``
|
|
935
|
+
inline body):
|
|
936
|
+
- ``duration_minutes`` = ``(last_activity - first_activity)`` in
|
|
937
|
+
minutes (float).
|
|
938
|
+
- ``cache_hit_pct`` = ``cache_read / (input + cache_creation +
|
|
939
|
+
cache_read) * 100`` when the denominator is positive; ``None``
|
|
940
|
+
otherwise.
|
|
941
|
+
- ``model_primary`` = first model in the session's first-seen
|
|
942
|
+
order; ``"—"`` if the session somehow has no models (defensive
|
|
943
|
+
— the aggregator only emits sessions with at least one entry).
|
|
944
|
+
- ``project_label`` = ``os.path.basename(project_path)`` or
|
|
945
|
+
``project_path`` itself when the basename is empty (root paths).
|
|
946
|
+
|
|
947
|
+
``period_start`` is set to ``now_utc - 365d`` to match
|
|
948
|
+
``_tui_build_sessions``' bounded scan window — strictly cosmetic
|
|
949
|
+
metadata (the caller's actual date range owns the entries fetched).
|
|
950
|
+
Share / CLI consumers prefer the caller-supplied range; this field
|
|
951
|
+
is informational only.
|
|
952
|
+
"""
|
|
953
|
+
import os as _os # late: keep top-level imports lean.
|
|
954
|
+
_agg = _load_lib("_lib_aggregators")
|
|
955
|
+
aggregated = _agg._aggregate_claude_sessions(entries)
|
|
956
|
+
# Apply limit truncation up front so `rows` and `aggregated` stay
|
|
957
|
+
# in lockstep (spec §4.3 invariant: `total_sessions == len(rows)
|
|
958
|
+
# == len(aggregated)`). limit=None → keep everything.
|
|
959
|
+
if limit is not None:
|
|
960
|
+
aggregated = aggregated[:limit]
|
|
961
|
+
|
|
962
|
+
rows = []
|
|
963
|
+
total_cost = 0.0
|
|
964
|
+
for s in aggregated:
|
|
965
|
+
duration_min = (
|
|
966
|
+
(s.last_activity - s.first_activity).total_seconds() / 60.0
|
|
967
|
+
)
|
|
968
|
+
denom = s.input_tokens + s.cache_creation_tokens + s.cache_read_tokens
|
|
969
|
+
cache_pct = (
|
|
970
|
+
(s.cache_read_tokens / denom * 100.0) if denom > 0 else None
|
|
971
|
+
)
|
|
972
|
+
rows.append(TuiSessionRow(
|
|
973
|
+
started_at=s.first_activity,
|
|
974
|
+
duration_minutes=duration_min,
|
|
975
|
+
model_primary=(s.models[0] if s.models else "—"),
|
|
976
|
+
cost_usd=s.cost_usd,
|
|
977
|
+
cache_hit_pct=cache_pct,
|
|
978
|
+
project_label=(
|
|
979
|
+
_os.path.basename(s.project_path) or s.project_path
|
|
980
|
+
),
|
|
981
|
+
session_id=s.session_id,
|
|
982
|
+
))
|
|
983
|
+
total_cost += s.cost_usd
|
|
984
|
+
|
|
985
|
+
return SessionsView(
|
|
986
|
+
rows=tuple(rows),
|
|
987
|
+
aggregated=tuple(aggregated),
|
|
988
|
+
total_sessions=len(rows),
|
|
989
|
+
total_cost_usd=total_cost,
|
|
990
|
+
period_start=(now_utc - dt.timedelta(days=365)),
|
|
991
|
+
period_end=now_utc,
|
|
992
|
+
display_tz_label=_display_tz_label(display_tz),
|
|
993
|
+
)
|