cctally 1.7.4 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ )