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.
@@ -808,55 +808,18 @@ class TuiCurrentWeek:
808
808
  five_hour_block: dict | None = None
809
809
 
810
810
 
811
- @dataclass
812
- class TuiTrendRow:
813
- week_label: str # e.g. "Apr 14"
814
- week_start_at: dt.datetime
815
- used_pct: float | None # None when the week has a cost snapshot
816
- # but no usage snapshot (phantom weeks)
817
- dollars_per_percent: float | None
818
- delta_dpp: float | None # vs prior week
819
- spark_height: int # 1..8 normalized
820
- is_current: bool
821
-
822
-
823
- @dataclass
824
- class WeeklyPeriodRow:
825
- """One subscription-week row for the dashboard's Weekly panel/modal.
826
-
827
- `models` is a list of `{model, display, chip, cost_usd, cost_pct}`
828
- dicts sorted by `cost_usd` descending. Pre-bucketed in Python so
829
- the React layer never re-derives per-model coloring.
830
- """
831
- label: str # "04-23" — MM-DD of the week start
832
- cost_usd: float
833
- total_tokens: int
834
- input_tokens: int
835
- output_tokens: int
836
- cache_creation_tokens: int
837
- cache_read_tokens: int
838
- used_pct: float | None # from weekly_usage_snapshots overlay
839
- dollar_per_pct: float | None # cost / used_pct when used_pct > 0
840
- delta_cost_pct: float | None # (cost - prev_cost) / prev_cost
841
- is_current: bool
842
- models: list[dict[str, Any]]
843
- week_start_at: str # ISO-8601 with tz, from SubWeek.start_ts
844
- week_end_at: str # ISO-8601 with tz, from SubWeek.end_ts
845
-
846
-
847
- @dataclass
848
- class MonthlyPeriodRow:
849
- """One calendar-month row for the dashboard's Monthly panel/modal."""
850
- label: str # "YYYY-MM"
851
- cost_usd: float
852
- total_tokens: int
853
- input_tokens: int
854
- output_tokens: int
855
- cache_creation_tokens: int
856
- cache_read_tokens: int
857
- delta_cost_pct: float | None
858
- is_current: bool
859
- models: list[dict[str, Any]]
811
+ # ---- View-model row dataclasses moved to bin/_lib_view_models.py ----
812
+ # Re-exported here so `from _cctally_tui import TuiTrendRow` and
813
+ # `ns["TuiTrendRow"]` direct-dict reads in tests keep resolving. The
814
+ # **extended** TuiTrendRow (spec §4.1: +10 nullable fields) is imported
815
+ # from the same module; the new fields default to None so existing TUI
816
+ # / dashboard fixtures that construct TuiTrendRow positionally stay
817
+ # byte-stable.
818
+ from _lib_view_models import ( # noqa: E402
819
+ TuiTrendRow,
820
+ WeeklyPeriodRow,
821
+ MonthlyPeriodRow,
822
+ )
860
823
 
861
824
 
862
825
  @dataclass
@@ -878,43 +841,8 @@ class BlocksPanelRow:
878
841
  label: str # "HH:MM MMM DD" in local tz, e.g. "14:00 Apr 26"
879
842
 
880
843
 
881
- @dataclass
882
- class DailyPanelRow:
883
- """One row of the dashboard's Daily heatmap panel.
884
-
885
- `intensity_bucket` is the server-computed quintile bucket (0..5) —
886
- bucket 0 is reserved for zero-cost days; buckets 1..5 are quintiles
887
- over non-zero days.
888
-
889
- v2.3: Added per-day token rollup + `cache_hit_pct` so the Daily
890
- detail modal can surface the same fields the CLI's `daily` command
891
- shows. Defaults preserve compatibility with `_empty_dashboard_snapshot`
892
- and any pre-v2.3 fixture that omits the new fields.
893
- """
894
- date: str # local-tz YYYY-MM-DD
895
- label: str # "MM-DD" — pre-formatted, mirrors Weekly/Monthly idiom
896
- cost_usd: float
897
- is_today: bool
898
- intensity_bucket: int # 0..5
899
- models: list[dict[str, Any]] # ModelCostRow shape, sorted desc by cost
900
- # ---- v2.3 additions: Daily modal token + cache rollup ----
901
- input_tokens: int = 0
902
- output_tokens: int = 0
903
- cache_creation_tokens: int = 0
904
- cache_read_tokens: int = 0
905
- total_tokens: int = 0
906
- cache_hit_pct: float | None = None
907
-
908
-
909
- @dataclass
910
- class TuiSessionRow:
911
- started_at: dt.datetime
912
- duration_minutes: float
913
- model_primary: str # first model used in the session
914
- cost_usd: float
915
- cache_hit_pct: float | None
916
- project_label: str # basename of project_path
917
- session_id: str # full session UUID (v2: needed for session-detail modal)
844
+ # DailyPanelRow + TuiSessionRow moved to bin/_lib_view_models.py — re-export.
845
+ from _lib_view_models import DailyPanelRow, TuiSessionRow # noqa: E402
918
846
 
919
847
 
920
848
  @dataclass
@@ -1096,6 +1024,26 @@ class DataSnapshot:
1096
1024
  # at sync-thread time so ``snapshot_to_envelope`` stays a pure
1097
1025
  # renderer; empty list when no current 5h block is bound.
1098
1026
  five_hour_milestones: list[dict] = field(default_factory=list)
1027
+ # ---- view-model unification (Bundle 1): pre-computed totals ----
1028
+ # Populated by the sync thread as sum-over-visible-rows over the
1029
+ # panel rows ``_dashboard_build_{daily,monthly,weekly}_periods``
1030
+ # returned (see ``_tui_build_snapshot``); the dashboard envelope
1031
+ # adapter emits these as ``<domain>.total_cost_usd`` /
1032
+ # ``total_tokens`` so the React panels stop running
1033
+ # ``rows.reduce(...)`` in JS. Sum-over-visible-rows is a structural
1034
+ # invariant: ``total === sum(rows[*].cost_usd)`` by construction —
1035
+ # see ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
1036
+ # ``trend_avg_dollars_per_pct`` is sourced from ``build_trend_view``
1037
+ # (3-sample-rule mean per spec §4.3). Defaults preserve
1038
+ # compatibility with pre-Bundle-1 fixture modules that construct
1039
+ # ``DataSnapshot`` positionally. Spec §6.6.
1040
+ daily_total_cost_usd: float = 0.0
1041
+ daily_total_tokens: int = 0
1042
+ monthly_total_cost_usd: float = 0.0
1043
+ monthly_total_tokens: int = 0
1044
+ weekly_total_cost_usd: float = 0.0
1045
+ weekly_total_tokens: int = 0
1046
+ trend_avg_dollars_per_pct: float | None = None
1099
1047
 
1100
1048
  @classmethod
1101
1049
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -1495,158 +1443,16 @@ def _tui_build_trend(
1495
1443
  ) -> list[TuiTrendRow]:
1496
1444
  """Build the last `count` trend rows, chronological (oldest first).
1497
1445
 
1498
- `cmd_report` inlines its row build rather than delegating to a helper,
1499
- so instead of refactoring the subcommand we call the same underlying
1500
- loaders (`get_recent_weeks` + `get_latest_usage_for_week` +
1501
- `get_latest_cost_for_week`) directly here. Output for the shared
1502
- columns (`week_start_at`, `used_pct`, `dollars_per_percent`) matches
1503
- `cmd_report` byte-for-byte — verified in the bundle regression diff.
1446
+ Bundle 1 / Task 10: wraps the unified ``build_trend_view`` kernel
1447
+ (spec §5.4) the loop body that used to live here moved into
1448
+ ``bin/_lib_view_models.build_trend_view``. The TUI snapshot module
1449
+ consumes the first 7 ``TuiTrendRow`` fields and ignores the 10
1450
+ extended fields (which exist for cmd_report's JSON contract).
1504
1451
  """
1505
- # `get_recent_weeks` returns WeekRef rows DESC by week_start_date,
1506
- # already routed through `_apply_reset_events_to_weekrefs` so credited
1507
- # weeks come back as TWO refs (pre-credit + post-credit) sharing the
1508
- # same `WeekRef.key`.
1509
- week_refs = get_recent_weeks(conn, max(1, count))
1510
-
1511
- # Figure out which week_ref corresponds to the current subscription week.
1512
- # Mirrors `cmd_report`'s Bug D pattern: build a current_ref from the
1513
- # latest usage snapshot, route it through `_apply_reset_events_to_weekrefs`
1514
- # so its `week_start_at` reflects the post-credit segment (or the
1515
- # original start for non-credit weeks), then disambiguate the
1516
- # synthesized pre-credit ref from the live post-credit ref via BOTH
1517
- # `key` AND `week_start_at`. Key-only equality marks both segments
1518
- # as current, which is why the dashboard's trend panel previously
1519
- # showed two adjacent rows both highlighted as "current" with
1520
- # identical 4.0% values on the user's live in-place credit data.
1521
- latest_usage = conn.execute(
1522
- "SELECT week_start_date, week_end_date "
1523
- "FROM weekly_usage_snapshots "
1524
- "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
1525
- ).fetchone()
1526
- current_key: str | None = None
1527
- current_week_start_at: str | None = None
1528
- if latest_usage is not None and latest_usage["week_start_date"] is not None:
1529
- current_key = latest_usage["week_start_date"]
1530
- try:
1531
- c = _cctally()
1532
- canon_start, canon_end = c._get_canonical_boundary_for_date(
1533
- conn, latest_usage["week_start_date"]
1534
- )
1535
- current_ref = make_week_ref(
1536
- week_start_date=latest_usage["week_start_date"],
1537
- week_end_date=latest_usage["week_end_date"],
1538
- week_start_at=canon_start,
1539
- week_end_at=canon_end,
1540
- )
1541
- _adjusted = c._apply_reset_events_to_weekrefs(conn, [current_ref])
1542
- if _adjusted:
1543
- current_week_start_at = _adjusted[0].week_start_at
1544
- except (ValueError, sqlite3.DatabaseError, AttributeError):
1545
- current_week_start_at = None
1546
-
1547
- # Build an intermediate list of (week_ref, used_pct, dpp) in oldest-first
1548
- # chronological order.
1549
- chrono = list(reversed(week_refs))
1550
- # Split-key set (Bug D): credited weeks appear twice in `week_refs`
1551
- # with identical `WeekRef.key`. For those keys ONLY, pin
1552
- # `as_of_utc=week_ref.week_end_at` so each segment finds its own
1553
- # latest snapshot — without this both segments collapse to the
1554
- # post-credit snapshot's weekly_percent. Non-credit weeks (single
1555
- # ref per key) keep the legacy unfiltered lookup.
1556
- _split_keys = {
1557
- r.key
1558
- for r in week_refs
1559
- if sum(1 for x in week_refs if x.key == r.key) > 1
1560
- }
1561
- intermediate: list[tuple[Any, float | None, float | None]] = []
1562
- for week_ref in chrono:
1563
- usage = get_latest_usage_for_week(
1564
- conn,
1565
- week_ref,
1566
- as_of_utc=(
1567
- week_ref.week_end_at if week_ref.key in _split_keys else None
1568
- ),
1569
- )
1570
- # See cmd_report for why reset-affected weeks skip the cost cache
1571
- # and live-compute from session_entries over the effective range.
1572
- if _week_ref_has_reset_event(conn, week_ref):
1573
- cost_usd = _compute_cost_for_weekref(week_ref)
1574
- else:
1575
- cost = get_latest_cost_for_week(conn, week_ref)
1576
- cost_usd = float(cost["cost_usd"]) if cost else None
1577
- percent = float(usage["weekly_percent"]) if usage else None
1578
- ratio = (cost_usd / percent) if (
1579
- cost_usd is not None and percent and percent > 0
1580
- ) else None
1581
- intermediate.append((week_ref, percent, ratio))
1582
-
1583
- # Normalize dpp into spark heights 1..8 across the window.
1584
- dpps = [d for _, _, d in intermediate if d is not None]
1585
- if dpps:
1586
- lo, hi = min(dpps), max(dpps)
1587
- span = (hi - lo) or 1e-9
1588
- else:
1589
- lo, hi, span = 0.0, 1.0, 1e-9
1590
-
1591
- out: list[TuiTrendRow] = []
1592
- prev_dpp: float | None = None
1593
- for week_ref, percent, dpp in intermediate:
1594
- delta = (dpp - prev_dpp) if (dpp is not None and prev_dpp is not None) else None
1595
- spark = 1
1596
- if dpp is not None:
1597
- spark = int(round((dpp - lo) / span * 7)) + 1
1598
- spark = max(1, min(8, spark))
1599
- # WeekRef.week_start is a date; synthesize a UTC datetime so
1600
- # TuiTrendRow carries a timezone-aware instant (prefer the explicit
1601
- # week_start_at if present).
1602
- if week_ref.week_start_at:
1603
- week_start_dt = parse_iso_datetime(
1604
- week_ref.week_start_at, "week_start_at"
1605
- )
1606
- week_label = format_display_dt(
1607
- week_start_dt, display_tz, fmt="%b %d", suffix=False,
1608
- )
1609
- else:
1610
- week_start_dt = dt.datetime.combine(
1611
- week_ref.week_start, dt.time(0, 0), dt.timezone.utc
1612
- )
1613
- # No real boundary instant — format the calendar date directly so
1614
- # localizing midnight-UTC doesn't shift it to the prior day in
1615
- # zones west of UTC (e.g. 2026-04-14 → "Apr 13" in America/New_York).
1616
- week_label = week_ref.week_start.strftime("%b %d")
1617
- # Bug G (v1.7.2 round-5): match on BOTH `key` AND `week_start_at`
1618
- # for credited weeks so the pre-credit synthesized ref doesn't
1619
- # also light up as "current" — both refs share `key`, only their
1620
- # `week_start_at` differs (post-credit = effective reset moment,
1621
- # pre-credit = original API-derived start). Non-credit weeks
1622
- # have only one ref per key so `week_start_at` matching is
1623
- # automatic. When `current_week_start_at` is None (no reset
1624
- # event for the current week, or the resolution above failed),
1625
- # falls back to legacy key-only matching.
1626
- is_cur = (
1627
- current_key is not None
1628
- and week_ref.key == current_key
1629
- and (
1630
- current_week_start_at is None
1631
- or week_ref.week_start_at == current_week_start_at
1632
- )
1633
- )
1634
- out.append(TuiTrendRow(
1635
- week_label=week_label,
1636
- week_start_at=week_start_dt,
1637
- # Preserve None when no usage snapshot exists for this week —
1638
- # matches `cmd_report`'s "n/a" rendering (9980) and avoids
1639
- # fabricating a 0.0% row for the phantom-week case (cost
1640
- # snapshot present, usage snapshot absent).
1641
- used_pct=float(percent) if percent is not None else None,
1642
- dollars_per_percent=dpp,
1643
- delta_dpp=delta,
1644
- spark_height=spark,
1645
- is_current=is_cur,
1646
- ))
1647
- if dpp is not None:
1648
- prev_dpp = dpp
1649
- return out
1452
+ c = _cctally()
1453
+ view = c.build_trend_view(conn, now_utc=now_utc, n=max(1, count),
1454
+ display_tz=display_tz)
1455
+ return list(view.rows)
1650
1456
 
1651
1457
 
1652
1458
  def _tui_build_weekly_history(
@@ -1685,6 +1491,14 @@ def _tui_build_sessions(
1685
1491
 
1686
1492
  When `skip_sync=True`, honors the parent's `--no-sync` intent: no
1687
1493
  ingest pass, just read whatever is already cached.
1494
+
1495
+ Bundle 2 / Task 15: wraps the unified ``build_sessions_view``
1496
+ kernel — the prior 40-line inline-derivation body now lives at
1497
+ ``bin/_lib_view_models.build_sessions_view``. The TUI keeps
1498
+ ownership of the bounded 365-day scan window (rationale below) and
1499
+ consumes ``view.rows`` (the typed ``TuiSessionRow`` tuple). The
1500
+ view's parallel ``view.aggregated`` is reserved for the CLI / share
1501
+ surfaces; the TUI doesn't need ``ClaudeSessionUsage`` fields.
1688
1502
  """
1689
1503
  # Bounded scan window — the sessions pane promises "last `limit`". A
1690
1504
  # 365-day scan covers virtually all users (even one-session-every-few-days
@@ -1693,23 +1507,11 @@ def _tui_build_sessions(
1693
1507
  # on every entry in the window before slicing.
1694
1508
  range_start = now_utc - dt.timedelta(days=365)
1695
1509
  entries = get_claude_session_entries(range_start, now_utc, skip_sync=skip_sync)
1696
- sessions = _aggregate_claude_sessions(entries) # last_activity desc
1697
- out: list[TuiSessionRow] = []
1698
- for s in sessions[:limit]:
1699
- duration_min = (s.last_activity - s.first_activity).total_seconds() / 60.0
1700
- total_read = s.cache_read_tokens
1701
- total_io = s.input_tokens + s.cache_creation_tokens + s.cache_read_tokens
1702
- cache_pct = (total_read / total_io * 100) if total_io > 0 else None
1703
- out.append(TuiSessionRow(
1704
- started_at=s.first_activity,
1705
- duration_minutes=duration_min,
1706
- model_primary=(s.models[0] if s.models else "—"),
1707
- cost_usd=s.cost_usd,
1708
- cache_hit_pct=cache_pct,
1709
- project_label=os.path.basename(s.project_path) or s.project_path,
1710
- session_id=s.session_id,
1711
- ))
1712
- return out
1510
+ c = _cctally()
1511
+ view = c.build_sessions_view(
1512
+ entries, now_utc=now_utc, limit=limit, display_tz=None,
1513
+ )
1514
+ return list(view.rows)
1713
1515
 
1714
1516
 
1715
1517
  @dataclass
@@ -1853,10 +1655,19 @@ def _tui_build_snapshot(
1853
1655
  fc = _tui_build_forecast(conn, now_utc, skip_sync=skip_sync)
1854
1656
  except Exception as exc:
1855
1657
  errors.append(f"forecast: {exc}")
1658
+ # Trend: source from build_trend_view so we capture the 3-sample
1659
+ # avg_dollars_per_pct alongside the rows. The TUI build path
1660
+ # historically called _tui_build_trend (which now wraps the
1661
+ # builder); calling the builder directly here saves one
1662
+ # `_aggregate_*` round-trip.
1663
+ trend_avg_dpp = None
1856
1664
  try:
1857
- trend = _tui_build_trend(
1858
- conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
1665
+ c = _cctally()
1666
+ _trend_view = c.build_trend_view(
1667
+ conn, now_utc=now_utc, n=8, display_tz=_build_display_tz,
1859
1668
  )
1669
+ trend = list(_trend_view.rows)
1670
+ trend_avg_dpp = _trend_view.avg_dollars_per_pct
1860
1671
  except Exception as exc:
1861
1672
  errors.append(f"trend: {exc}")
1862
1673
  try:
@@ -1881,17 +1692,53 @@ def _tui_build_snapshot(
1881
1692
  except Exception as exc:
1882
1693
  errors.append(f"weekly-history: {exc}")
1883
1694
  # ---- v2.1 additions: dashboard Weekly / Monthly panels ----
1695
+ # Sync-thread view-model totals (spec §6.6): sum directly over
1696
+ # the panel rows the dashboard ACTUALLY renders. The previous
1697
+ # implementation called ``build_weekly_view`` a second time to
1698
+ # capture totals, but that builder doesn't see the Bug-K
1699
+ # pre-credit synthesized rows that ``_dashboard_build_weekly_periods``
1700
+ # layers on top (``_apply_reset_events_to_subweeks`` shifts the
1701
+ # post-reset SubWeek's ``start_ts`` so the pre-credit interval
1702
+ # has no SubWeek for ``_aggregate_weekly`` to bucket). On credit
1703
+ # weeks the sync-thread total understated the rendered footer by
1704
+ # hundreds of dollars (~$372 in the v1.7.2 round-5 data).
1705
+ # Sum-over-visible-rows is a structural invariant — see
1706
+ # ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
1707
+ weekly_total_cost_usd = 0.0
1708
+ weekly_total_tokens = 0
1884
1709
  try:
1885
1710
  weekly_periods = _dashboard_build_weekly_periods(
1886
1711
  conn, now_utc, n=12, skip_sync=skip_sync
1887
1712
  )
1713
+ # ``sum(..., 0.0)`` pins the type to ``float`` on empty rows so
1714
+ # the envelope stays byte-stable with the pre-fix ``0.0`` shape
1715
+ # (the dashboard fixture goldens assert exact JSON match).
1716
+ weekly_total_cost_usd = sum(
1717
+ (r.cost_usd for r in weekly_periods), 0.0,
1718
+ )
1719
+ weekly_total_tokens = sum(
1720
+ (r.total_tokens for r in weekly_periods), 0,
1721
+ )
1888
1722
  except Exception as exc:
1889
1723
  errors.append(f"weekly-periods: {exc}")
1724
+ # Sync-thread view-model totals (spec §6.6): sum-over-visible-rows
1725
+ # (same invariant as weekly above). Monthly has no Bug-K analogue,
1726
+ # but coupling the footer total to the panel-row source of truth
1727
+ # eliminates a parallel ``build_monthly_view`` pass that did the
1728
+ # same arithmetic with no behavioral upside.
1729
+ monthly_total_cost_usd = 0.0
1730
+ monthly_total_tokens = 0
1890
1731
  try:
1891
1732
  monthly_periods = _dashboard_build_monthly_periods(
1892
1733
  conn, now_utc, n=12, skip_sync=skip_sync,
1893
1734
  display_tz=_build_display_tz,
1894
1735
  )
1736
+ monthly_total_cost_usd = sum(
1737
+ (r.cost_usd for r in monthly_periods), 0.0,
1738
+ )
1739
+ monthly_total_tokens = sum(
1740
+ (r.total_tokens for r in monthly_periods), 0,
1741
+ )
1895
1742
  except Exception as exc:
1896
1743
  errors.append(f"monthly-periods: {exc}")
1897
1744
  # ---- v2.2 additions: dashboard Blocks / Daily panels ----
@@ -1906,11 +1753,25 @@ def _tui_build_snapshot(
1906
1753
  )
1907
1754
  except Exception as exc:
1908
1755
  errors.append(f"blocks-panel: {exc}")
1756
+ # Sync-thread view-model totals (Bundle 1 / spec §6.6):
1757
+ # sum-over-visible-rows (same invariant as weekly/monthly above).
1758
+ # Gap days in the materialized panel carry ``cost_usd=0.0`` /
1759
+ # ``total_tokens=0``, so summing the panel rows preserves the
1760
+ # gap-free totals semantically — and removes a duplicate
1761
+ # ``build_daily_view`` pass that did the same arithmetic.
1762
+ daily_total_cost_usd = 0.0
1763
+ daily_total_tokens = 0
1909
1764
  try:
1910
1765
  daily_panel = _dashboard_build_daily_panel(
1911
1766
  conn, now_utc, n=30, skip_sync=skip_sync,
1912
1767
  display_tz=_build_display_tz,
1913
1768
  )
1769
+ daily_total_cost_usd = sum(
1770
+ (r.cost_usd for r in daily_panel), 0.0,
1771
+ )
1772
+ daily_total_tokens = sum(
1773
+ (r.total_tokens for r in daily_panel), 0,
1774
+ )
1914
1775
  except Exception as exc:
1915
1776
  errors.append(f"daily-panel: {exc}")
1916
1777
  # ---- threshold-actions T5: alerts envelope array ----
@@ -1951,6 +1812,13 @@ def _tui_build_snapshot(
1951
1812
  daily_panel=daily_panel,
1952
1813
  alerts=alerts,
1953
1814
  five_hour_milestones=fh_milestones,
1815
+ daily_total_cost_usd=daily_total_cost_usd,
1816
+ daily_total_tokens=daily_total_tokens,
1817
+ monthly_total_cost_usd=monthly_total_cost_usd,
1818
+ monthly_total_tokens=monthly_total_tokens,
1819
+ weekly_total_cost_usd=weekly_total_cost_usd,
1820
+ weekly_total_tokens=weekly_total_tokens,
1821
+ trend_avg_dollars_per_pct=trend_avg_dpp,
1954
1822
  )
1955
1823
  finally:
1956
1824
  conn.close()