cctally 1.7.3 → 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/bin/cctally CHANGED
@@ -210,6 +210,43 @@ PUBLIC_REPO = "omrikais/cctally"
210
210
  #
211
211
  # Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §6.3-6.4
212
212
 
213
+ # === _cctally_core: leaf kernel (MUST load first; sibling imports
214
+ # `from _cctally_core import …` depend on this being in sys.modules
215
+ # before any other sibling's module body runs). Spec
216
+ # 2026-05-17-cctally-core-kernel-extraction.md §2.5. ===
217
+ _cctally_core = _load_sibling("_cctally_core")
218
+
219
+ # Eager re-exports for the 24 kernel symbols. Preserves
220
+ # `cctally.<name>` attribute access AND `ns["<name>"]` dict-subscript
221
+ # paths used heavily by tests (e.g. ns["open_db"]() in 125+ sites).
222
+ # Don't add to PEP 562 __getattr__ registry — eager binding is the
223
+ # whole point.
224
+ eprint = _cctally_core.eprint
225
+ now_utc_iso = _cctally_core.now_utc_iso
226
+ parse_iso_datetime = _cctally_core.parse_iso_datetime
227
+ parse_date_str = _cctally_core.parse_date_str
228
+ format_local_iso = _cctally_core.format_local_iso
229
+ _iso_to_epoch = _cctally_core._iso_to_epoch
230
+ _format_short_duration = _cctally_core._format_short_duration
231
+ _normalize_week_boundary_dt = _cctally_core._normalize_week_boundary_dt
232
+ _now_utc = _cctally_core._now_utc
233
+ _command_as_of = _cctally_core._command_as_of
234
+ get_week_start_name = _cctally_core.get_week_start_name
235
+ compute_week_bounds = _cctally_core.compute_week_bounds
236
+ WEEKDAY_MAP = _cctally_core.WEEKDAY_MAP
237
+ DEFAULT_WEEK_START = _cctally_core.DEFAULT_WEEK_START
238
+ ensure_dirs = _cctally_core.ensure_dirs
239
+ _AlertsConfigError = _cctally_core._AlertsConfigError
240
+ _ALERTS_CONFIG_VALID_KEYS = _cctally_core._ALERTS_CONFIG_VALID_KEYS
241
+ _validate_threshold_list = _cctally_core._validate_threshold_list
242
+ _get_alerts_config = _cctally_core._get_alerts_config
243
+ open_db = _cctally_core.open_db
244
+ WeekRef = _cctally_core.WeekRef
245
+ _canonicalize_optional_iso = _cctally_core._canonicalize_optional_iso
246
+ make_week_ref = _cctally_core.make_week_ref
247
+ _get_latest_row_for_week = _cctally_core._get_latest_row_for_week
248
+ get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
249
+
213
250
  _lib_semver = _load_sibling("_lib_semver")
214
251
  _SEMVER_NUM = _lib_semver._SEMVER_NUM
215
252
  _SEMVER_RE = _lib_semver._SEMVER_RE
@@ -308,6 +345,20 @@ _aggregate_codex_sessions = _lib_aggregators._aggregate_codex_sessions
308
345
  _session_path_parts = _lib_aggregators._session_path_parts
309
346
  _aggregate_claude_sessions = _lib_aggregators._aggregate_claude_sessions
310
347
 
348
+ # View-model kernel — per-domain frozen dataclasses + builders.
349
+ # Spec: docs/superpowers/specs/2026-05-17-view-model-unification-design.md.
350
+ _lib_view_models = _load_sibling("_lib_view_models")
351
+ DailyView = _lib_view_models.DailyView
352
+ build_daily_view = _lib_view_models.build_daily_view
353
+ MonthlyView = _lib_view_models.MonthlyView
354
+ build_monthly_view = _lib_view_models.build_monthly_view
355
+ WeeklyView = _lib_view_models.WeeklyView
356
+ build_weekly_view = _lib_view_models.build_weekly_view
357
+ TrendView = _lib_view_models.TrendView
358
+ build_trend_view = _lib_view_models.build_trend_view
359
+ SessionsView = _lib_view_models.SessionsView
360
+ build_sessions_view = _lib_view_models.build_sessions_view
361
+
311
362
  _lib_render = _load_sibling("_lib_render")
312
363
  _CODEX_MONTHS = _lib_render._CODEX_MONTHS
313
364
  _render_blocks_table = _lib_render._render_blocks_table
@@ -2195,9 +2246,6 @@ def _migrate_legacy_data_dir() -> None:
2195
2246
  )
2196
2247
 
2197
2248
 
2198
- DEFAULT_WEEK_START = "monday"
2199
-
2200
-
2201
2249
  def _get_claude_data_dirs() -> list[pathlib.Path]:
2202
2250
  """Return Claude Code data directories containing a projects/ subdir."""
2203
2251
  env_val = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
@@ -2352,79 +2400,6 @@ def _resolve_primary_model_for_block(
2352
2400
 
2353
2401
 
2354
2402
 
2355
- WEEKDAY_MAP = {
2356
- "monday": 0,
2357
- "tuesday": 1,
2358
- "wednesday": 2,
2359
- "thursday": 3,
2360
- "friday": 4,
2361
- "saturday": 5,
2362
- "sunday": 6,
2363
- }
2364
-
2365
- _DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
2366
-
2367
-
2368
- def eprint(*args: Any) -> None:
2369
- print(*args, file=sys.stderr)
2370
-
2371
-
2372
- def now_utc_iso(now_utc: dt.datetime | None = None) -> str:
2373
- """Return a UTC-ISO 'Z'-suffixed timestamp with seconds precision.
2374
-
2375
- When ``now_utc`` is omitted (the default), reads wall-clock — existing
2376
- behavior, preserved byte-for-byte for all existing callers. When a
2377
- tz-aware UTC datetime is supplied (typically via ``_command_as_of()``),
2378
- it is used verbatim so callers that honor ``CCTALLY_AS_OF`` get a
2379
- stable, caller-pinned timestamp.
2380
- """
2381
- value = now_utc if now_utc is not None else dt.datetime.now(dt.timezone.utc)
2382
- return (
2383
- value.astimezone(dt.timezone.utc)
2384
- .replace(microsecond=0)
2385
- .isoformat()
2386
- .replace("+00:00", "Z")
2387
- )
2388
-
2389
-
2390
- def _iso_to_epoch(s: str) -> int:
2391
- """Parse an ISO-8601 timestamp and return Unix epoch seconds.
2392
-
2393
- Naive ISO strings (no timezone) are treated as UTC, matching the
2394
- statusline-command.sh ``_iso_to_epoch`` helper. ``Z`` suffix is
2395
- handled by mapping to ``+00:00`` since ``datetime.fromisoformat``
2396
- accepts ``Z`` natively from Python 3.11.
2397
- """
2398
- s = s.strip()
2399
- if s.endswith("Z"):
2400
- s = s[:-1] + "+00:00"
2401
- parsed = dt.datetime.fromisoformat(s)
2402
- if parsed.tzinfo is None:
2403
- parsed = parsed.replace(tzinfo=dt.timezone.utc)
2404
- return int(parsed.timestamp())
2405
-
2406
-
2407
- def _format_short_duration(seconds: int) -> str:
2408
- """Format a duration as a short top-two-units string.
2409
-
2410
- Examples: ``6d 4h``, ``2h 15m``, ``2h``, ``45m``, ``30s``, ``0s``.
2411
- Mirrors the shape used by ``~/.claude/statusline-command.sh``'s
2412
- format_duration helper. Negative inputs clamp to ``0s``.
2413
- """
2414
- s = max(0, int(seconds))
2415
- if s >= 86400:
2416
- days = s // 86400
2417
- hours = (s % 86400) // 3600
2418
- return f"{days}d {hours}h" if hours else f"{days}d"
2419
- if s >= 3600:
2420
- hours = s // 3600
2421
- minutes = (s % 3600) // 60
2422
- return f"{hours}h {minutes}m" if minutes else f"{hours}h"
2423
- if s >= 60:
2424
- return f"{s // 60}m"
2425
- return f"{s}s"
2426
-
2427
-
2428
2403
  def _read_keychain_oauth_blob() -> str | None:
2429
2404
  """Read the Claude Code keychain entry on macOS via `security`.
2430
2405
 
@@ -2557,96 +2532,6 @@ def _select_last_known_snapshot() -> dict | None:
2557
2532
  }
2558
2533
 
2559
2534
 
2560
- def ensure_dirs() -> None:
2561
- APP_DIR.mkdir(parents=True, exist_ok=True)
2562
- LOG_DIR.mkdir(parents=True, exist_ok=True)
2563
-
2564
-
2565
-
2566
-
2567
- class _AlertsConfigError(ValueError):
2568
- """Raised by _get_alerts_config on invalid alerts block."""
2569
-
2570
-
2571
- _ALERTS_CONFIG_VALID_KEYS = {"enabled", "weekly_thresholds", "five_hour_thresholds"}
2572
-
2573
-
2574
- def _validate_threshold_list(name: str, value: object) -> "list[int]":
2575
- """Validate one of the alerts threshold lists.
2576
-
2577
- Rules: non-empty list of plain ints (NOT bools — `bool` is an `int`
2578
- subclass), each in [1, 100], strictly increasing (no duplicates).
2579
- Error messages mention `alerts.<name>` so users can locate the
2580
- offending key in their config.json.
2581
- """
2582
- if not isinstance(value, list):
2583
- raise _AlertsConfigError(f"alerts.{name} must be a list of integers")
2584
- if len(value) == 0:
2585
- raise _AlertsConfigError(
2586
- f"alerts.{name} must not be empty (disable alerts via alerts.enabled=false)"
2587
- )
2588
- out: "list[int]" = []
2589
- prev = -1
2590
- seen: "set[int]" = set()
2591
- for item in value:
2592
- if not isinstance(item, int) or isinstance(item, bool):
2593
- raise _AlertsConfigError(
2594
- f"alerts.{name} items must be integers, got {type(item).__name__}: {item!r}"
2595
- )
2596
- if item < 1 or item > 100:
2597
- raise _AlertsConfigError(
2598
- f"alerts.{name} items must be in [1, 100], got {item}"
2599
- )
2600
- if item in seen:
2601
- raise _AlertsConfigError(
2602
- f"alerts.{name} contains duplicate value {item}"
2603
- )
2604
- if item <= prev:
2605
- raise _AlertsConfigError(
2606
- f"alerts.{name} must be strictly increasing, got {prev} then {item}"
2607
- )
2608
- seen.add(item)
2609
- prev = item
2610
- out.append(item)
2611
- return out
2612
-
2613
-
2614
- def _get_alerts_config(cfg: "dict | None") -> dict:
2615
- """Return the validated alerts block. Raises _AlertsConfigError on failure.
2616
-
2617
- Defaults applied at read time so future default-tuning takes effect
2618
- for users who never customized. Unknown sub-keys under `alerts.*`
2619
- emit a one-line warn-and-ignore (mirrors the `display.tz` posture
2620
- for forward compatibility).
2621
- """
2622
- block = (cfg or {}).get("alerts", {}) or {}
2623
- if not isinstance(block, dict):
2624
- raise _AlertsConfigError("alerts must be an object")
2625
- # warn-and-ignore unknown keys (forward compat; matches display.tz posture)
2626
- for k in block.keys():
2627
- if k not in _ALERTS_CONFIG_VALID_KEYS:
2628
- print(
2629
- f"warning: ignoring unknown alerts config key: {k}",
2630
- file=sys.stderr,
2631
- )
2632
- enabled = block.get("enabled", False)
2633
- if not isinstance(enabled, bool):
2634
- raise _AlertsConfigError(
2635
- f"alerts.enabled must be a JSON boolean, got {type(enabled).__name__}: {enabled!r}"
2636
- )
2637
- weekly = _validate_threshold_list(
2638
- "weekly_thresholds", block.get("weekly_thresholds", [90, 95])
2639
- )
2640
- five_hour = _validate_threshold_list(
2641
- "five_hour_thresholds", block.get("five_hour_thresholds", [90, 95])
2642
- )
2643
- return {
2644
- "enabled": enabled,
2645
- "weekly_thresholds": weekly,
2646
- "five_hour_thresholds": five_hour,
2647
- }
2648
-
2649
-
2650
2535
  def _normalize_alerts_enabled_value(raw: str) -> bool:
2651
2536
  """Normalize a CLI string value to a JSON bool. Raises ValueError on unknown.
2652
2537
 
@@ -2724,60 +2609,6 @@ ORIGINAL_ENTRYPOINT: "str | None" = None
2724
2609
  _UPDATE_WORKER: "UpdateWorker | None" = None
2725
2610
 
2726
2611
 
2727
- def get_week_start_name(config: dict[str, Any], override: str | None = None) -> str:
2728
- if override:
2729
- name = override.strip().lower()
2730
- else:
2731
- name = str(config.get("collector", {}).get("week_start", DEFAULT_WEEK_START)).strip().lower()
2732
- if name not in WEEKDAY_MAP:
2733
- raise ValueError(
2734
- f"Invalid week start '{name}'. Allowed: {', '.join(WEEKDAY_MAP.keys())}"
2735
- )
2736
- return name
2737
-
2738
-
2739
- def compute_week_bounds(anchor_dt: dt.datetime, week_start_name: str) -> tuple[dt.date, dt.date]:
2740
- start_idx = WEEKDAY_MAP[week_start_name]
2741
- # internal fallback: host-local intentional
2742
- local_anchor = anchor_dt.astimezone()
2743
- local_date = local_anchor.date()
2744
- diff = (local_date.weekday() - start_idx) % 7
2745
- start = local_date - dt.timedelta(days=diff)
2746
- end = start + dt.timedelta(days=6)
2747
- return start, end
2748
-
2749
-
2750
- def parse_date_str(value: str, label: str) -> dt.date:
2751
- s = value.strip()
2752
- if not _DATE_RE.match(s):
2753
- raise ValueError(f"{label} must be YYYY-MM-DD")
2754
- return dt.date.fromisoformat(s)
2755
-
2756
-
2757
- def parse_iso_datetime(value: str, label: str) -> dt.datetime:
2758
- s = value.strip()
2759
- if not s:
2760
- raise ValueError(f"{label} must be a non-empty ISO datetime")
2761
- try:
2762
- parsed = dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
2763
- except ValueError as exc:
2764
- raise ValueError(f"{label} must be ISO datetime") from exc
2765
-
2766
- if parsed.tzinfo is None:
2767
- # internal fallback: host-local intentional
2768
- local_tz = dt.datetime.now().astimezone().tzinfo
2769
- parsed = parsed.replace(tzinfo=local_tz)
2770
- # internal fallback: host-local intentional
2771
- return parsed.astimezone()
2772
-
2773
-
2774
- def format_local_iso(d: dt.date, end_of_day: bool) -> str:
2775
- t = dt.time(23, 59, 59) if end_of_day else dt.time(0, 0, 0)
2776
- # internal fallback: host-local intentional
2777
- local_dt = dt.datetime.combine(d, t).astimezone()
2778
- return local_dt.isoformat(timespec="seconds")
2779
-
2780
-
2781
2612
  def _parse_iso_datetime_optional(value: Any) -> dt.datetime | None:
2782
2613
  if not isinstance(value, str) or not value.strip():
2783
2614
  return None
@@ -3652,489 +3483,6 @@ def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
3652
3483
  return 0.0
3653
3484
 
3654
3485
 
3655
- def _normalize_week_boundary_dt(value: dt.datetime) -> dt.datetime:
3656
- """
3657
- Normalize known Anthropic boundary jitter.
3658
-
3659
- Anthropic resets are always on hour boundaries. Relative reset text
3660
- ("in XX hr YY min") produces minute-level drift on every capture, and
3661
- the UI occasionally alternates between HH:00 and HH-1:59 for the same
3662
- logical reset.
3663
-
3664
- Canonicalization: round to the nearest hour.
3665
- - minutes 0..29 -> HH:00
3666
- - minutes 30..59 -> (HH+1):00
3667
- """
3668
- normalized = value.replace(second=0, microsecond=0)
3669
- if normalized.minute >= 30:
3670
- normalized = (normalized + dt.timedelta(hours=1)).replace(
3671
- minute=0,
3672
- second=0,
3673
- microsecond=0,
3674
- )
3675
- elif normalized.minute > 0:
3676
- normalized = normalized.replace(
3677
- minute=0,
3678
- second=0,
3679
- microsecond=0,
3680
- )
3681
- return normalized
3682
-
3683
-
3684
- def open_db() -> sqlite3.Connection:
3685
- ensure_dirs()
3686
- conn = sqlite3.connect(DB_PATH)
3687
- conn.row_factory = sqlite3.Row
3688
- conn.execute("PRAGMA journal_mode=WAL")
3689
- conn.execute("PRAGMA synchronous=NORMAL")
3690
- conn.execute(
3691
- """
3692
- CREATE TABLE IF NOT EXISTS weekly_usage_snapshots (
3693
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3694
- captured_at_utc TEXT NOT NULL,
3695
- week_start_date TEXT NOT NULL,
3696
- week_end_date TEXT NOT NULL,
3697
- week_start_at TEXT,
3698
- week_end_at TEXT,
3699
- weekly_percent REAL NOT NULL,
3700
- page_url TEXT,
3701
- source TEXT NOT NULL DEFAULT 'userscript',
3702
- payload_json TEXT NOT NULL
3703
- )
3704
- """
3705
- )
3706
- conn.execute(
3707
- """
3708
- CREATE INDEX IF NOT EXISTS idx_usage_week_time
3709
- ON weekly_usage_snapshots(week_start_date, captured_at_utc DESC, id DESC)
3710
- """
3711
- )
3712
- conn.execute(
3713
- """
3714
- CREATE TABLE IF NOT EXISTS weekly_cost_snapshots (
3715
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3716
- captured_at_utc TEXT NOT NULL,
3717
- week_start_date TEXT NOT NULL,
3718
- week_end_date TEXT NOT NULL,
3719
- week_start_at TEXT,
3720
- week_end_at TEXT,
3721
- range_start_iso TEXT,
3722
- range_end_iso TEXT,
3723
- cost_usd REAL NOT NULL,
3724
- source TEXT NOT NULL DEFAULT 'cctally-range-cost',
3725
- mode TEXT NOT NULL DEFAULT 'auto',
3726
- project TEXT
3727
- )
3728
- """
3729
- )
3730
- conn.execute(
3731
- """
3732
- CREATE INDEX IF NOT EXISTS idx_cost_week_time
3733
- ON weekly_cost_snapshots(week_start_date, captured_at_utc DESC, id DESC)
3734
- """
3735
- )
3736
-
3737
- add_column_if_missing(conn, "weekly_usage_snapshots", "week_start_at", "TEXT")
3738
- add_column_if_missing(conn, "weekly_usage_snapshots", "week_end_at", "TEXT")
3739
- add_column_if_missing(conn, "weekly_usage_snapshots", "five_hour_percent", "REAL")
3740
- add_column_if_missing(conn, "weekly_usage_snapshots", "five_hour_resets_at", "TEXT")
3741
- # five_hour_window_key — canonical (10-min-floored epoch) key for
3742
- # jitter-tolerant equality. Anthropic's status-line API jitters
3743
- # rate_limits.5h.resets_at by ~seconds within the same physical 5h
3744
- # window; joining on the raw ISO string treats each jittered fetch as
3745
- # a new window, escaping the monotonic clamp at cmd_record_usage.
3746
- # Backfill is RESUMABLE: Python's sqlite3 auto-commits DDL,
3747
- # so a process killed mid-loop would leave the column added with NULL
3748
- # keys for unprocessed rows. The gating below detects that partial
3749
- # state on the next open_db() call (`five_hour_resets_at IS NOT NULL
3750
- # AND five_hour_window_key IS NULL`) and completes the backfill, so
3751
- # the original Bug B can't silently re-emerge for half-migrated rows.
3752
- needs_5h_key_backfill = add_column_if_missing(
3753
- conn, "weekly_usage_snapshots", "five_hour_window_key", "INTEGER"
3754
- )
3755
- if not needs_5h_key_backfill and conn.execute(
3756
- "SELECT 1 FROM weekly_usage_snapshots "
3757
- "WHERE five_hour_resets_at IS NOT NULL "
3758
- " AND five_hour_window_key IS NULL "
3759
- "LIMIT 1"
3760
- ).fetchone() is not None:
3761
- needs_5h_key_backfill = True
3762
-
3763
- if needs_5h_key_backfill:
3764
- backfill_rows = conn.execute(
3765
- "SELECT id, five_hour_resets_at FROM weekly_usage_snapshots "
3766
- "WHERE five_hour_resets_at IS NOT NULL "
3767
- " AND five_hour_window_key IS NULL"
3768
- ).fetchall()
3769
- for row in backfill_rows:
3770
- try:
3771
- iso = row[1]
3772
- d = parse_iso_datetime(iso, "five_hour_resets_at backfill")
3773
- epoch = int(d.timestamp())
3774
- key = _canonical_5h_window_key(epoch)
3775
- conn.execute(
3776
- "UPDATE weekly_usage_snapshots "
3777
- "SET five_hour_window_key = ? WHERE id = ?",
3778
- (key, row[0]),
3779
- )
3780
- except (ValueError, TypeError) as exc:
3781
- eprint(f"[migration] skipped row {row[0]}: {exc}")
3782
- conn.execute(
3783
- "CREATE INDEX IF NOT EXISTS idx_weekly_usage_snapshots_5h_window_key "
3784
- "ON weekly_usage_snapshots(five_hour_window_key)"
3785
- )
3786
- conn.commit()
3787
-
3788
- add_column_if_missing(conn, "weekly_cost_snapshots", "week_start_at", "TEXT")
3789
- add_column_if_missing(conn, "weekly_cost_snapshots", "week_end_at", "TEXT")
3790
- add_column_if_missing(conn, "weekly_cost_snapshots", "range_start_iso", "TEXT")
3791
- add_column_if_missing(conn, "weekly_cost_snapshots", "range_end_iso", "TEXT")
3792
-
3793
- conn.execute(
3794
- """
3795
- CREATE INDEX IF NOT EXISTS idx_usage_week_start_at_time
3796
- ON weekly_usage_snapshots(week_start_at, captured_at_utc DESC, id DESC)
3797
- """
3798
- )
3799
- conn.execute(
3800
- """
3801
- CREATE INDEX IF NOT EXISTS idx_cost_week_start_at_time
3802
- ON weekly_cost_snapshots(week_start_at, captured_at_utc DESC, id DESC)
3803
- """
3804
- )
3805
-
3806
- conn.execute(
3807
- """
3808
- CREATE TABLE IF NOT EXISTS percent_milestones (
3809
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3810
- captured_at_utc TEXT NOT NULL,
3811
- week_start_date TEXT NOT NULL,
3812
- week_end_date TEXT NOT NULL,
3813
- week_start_at TEXT,
3814
- week_end_at TEXT,
3815
- percent_threshold INTEGER NOT NULL,
3816
- cumulative_cost_usd REAL NOT NULL,
3817
- marginal_cost_usd REAL,
3818
- usage_snapshot_id INTEGER NOT NULL,
3819
- cost_snapshot_id INTEGER NOT NULL,
3820
- reset_event_id INTEGER NOT NULL DEFAULT 0,
3821
- UNIQUE(week_start_date, percent_threshold, reset_event_id)
3822
- )
3823
- """
3824
- )
3825
-
3826
- add_column_if_missing(conn, "percent_milestones", "five_hour_percent_at_crossing", "REAL")
3827
- # reset_event_id: segment column added by migration 005. Fresh-install
3828
- # DBs get it via the live CREATE TABLE above + the dispatcher
3829
- # fast-stamps the migration. Existing pre-005 DBs trip the migration's
3830
- # rename-recreate-copy idiom (handler in _cctally_db.py); the handler's
3831
- # fast-path probe stamps the marker when the column is already present
3832
- # (covers the corner case where a partially-upgraded DB has the column
3833
- # but not the new UNIQUE — re-run is safe).
3834
-
3835
- # alerted_at: populated by the alert-dispatch path when a milestone-INSERT
3836
- # row's threshold matches the user's configured alerts.weekly_thresholds /
3837
- # alerts.five_hour_thresholds (and alerts.enabled is true). NULL means
3838
- # "alerts were disabled at the moment of crossing OR the threshold wasn't
3839
- # in the configured list" — never "alert delivery failed" (dispatch is
3840
- # best-effort and write-once forward-only). The matching ALTER for
3841
- # `five_hour_milestones` lives right after that table's CREATE block
3842
- # below, since the table doesn't exist yet at this point in `open_db()`.
3843
- add_column_if_missing(conn, "percent_milestones", "alerted_at", "TEXT")
3844
-
3845
- # Mid-week reset events: when Anthropic advances `rate_limits.seven_day.
3846
- # resets_at` before the previously-declared reset actually fires (i.e.,
3847
- # gives the user a fresh weekly window before the old one naturally
3848
- # expired), we record one row here so display + cost layers can treat
3849
- # the effective reset moment as the old week's end AND the new week's
3850
- # start — preventing the API's -7d-derived new week from overlapping
3851
- # the old week. Inserted by cmd_record_usage on detection; read by
3852
- # _apply_reset_events_to_weekrefs and the cost live-recompute path.
3853
- conn.execute(
3854
- """
3855
- CREATE TABLE IF NOT EXISTS week_reset_events (
3856
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3857
- detected_at_utc TEXT NOT NULL,
3858
- old_week_end_at TEXT NOT NULL,
3859
- new_week_end_at TEXT NOT NULL,
3860
- effective_reset_at_utc TEXT NOT NULL,
3861
- UNIQUE(old_week_end_at, new_week_end_at)
3862
- )
3863
- """
3864
- )
3865
- _backfill_week_reset_events(conn)
3866
-
3867
- # ── five_hour_reset_events (Anthropic-issued in-place 5h credits) ──
3868
- # Parallel concept to ``week_reset_events`` for the 5h dimension; lives
3869
- # adjacent in ``_apply_schema`` because the two carry the same kind of
3870
- # signal at different cadences. Diverges from weekly in that the payload
3871
- # is the *percent values* (prior + post) rather than boundary keys,
3872
- # because the 5h variant has a stable ``five_hour_window_key`` and only
3873
- # the percent moves. See spec
3874
- # docs/superpowers/specs/2026-05-16-5h-in-place-credit-detection.md §3.1
3875
- # for rationale.
3876
- #
3877
- # UNIQUE(five_hour_window_key, effective_reset_at_utc) — supports stacked
3878
- # credits across DISTINCT 10-min slots inside one block (see spec §2.3
3879
- # "Bounded stacked-credit resolution" for the cap statement: ~30 distinct
3880
- # slots per 5h block when floor matches ``_canonical_5h_window_key``'s
3881
- # 600-second floor; same-slot collisions silently absorbed by
3882
- # INSERT OR IGNORE — an intentional cap, not a bug).
3883
- #
3884
- # No FK per CLAUDE.md gotcha: FKs in this codebase are documentation-only
3885
- # (``PRAGMA foreign_keys`` not enabled). ``five_hour_window_key`` provides
3886
- # the join key without a formal FK.
3887
- #
3888
- # No ``_backfill_five_hour_reset_events`` call follows (forward-only ship
3889
- # per spec Q5; historical backfill deferred to a future issue).
3890
- conn.execute(
3891
- """
3892
- CREATE TABLE IF NOT EXISTS five_hour_reset_events (
3893
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3894
- detected_at_utc TEXT NOT NULL,
3895
- five_hour_window_key INTEGER NOT NULL,
3896
- prior_percent REAL NOT NULL,
3897
- post_percent REAL NOT NULL,
3898
- effective_reset_at_utc TEXT NOT NULL,
3899
- UNIQUE(five_hour_window_key, effective_reset_at_utc)
3900
- )
3901
- """
3902
- )
3903
-
3904
- # ── five_hour_blocks (rollup, one row per API-anchored 5h block) ──
3905
- conn.execute(
3906
- """
3907
- CREATE TABLE IF NOT EXISTS five_hour_blocks (
3908
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3909
- five_hour_window_key INTEGER NOT NULL UNIQUE,
3910
- five_hour_resets_at TEXT NOT NULL,
3911
- block_start_at TEXT NOT NULL,
3912
- first_observed_at_utc TEXT NOT NULL,
3913
- last_observed_at_utc TEXT NOT NULL,
3914
- final_five_hour_percent REAL NOT NULL,
3915
- seven_day_pct_at_block_start REAL,
3916
- seven_day_pct_at_block_end REAL,
3917
- crossed_seven_day_reset INTEGER NOT NULL DEFAULT 0,
3918
- total_input_tokens INTEGER NOT NULL DEFAULT 0,
3919
- total_output_tokens INTEGER NOT NULL DEFAULT 0,
3920
- total_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
3921
- total_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3922
- total_cost_usd REAL NOT NULL DEFAULT 0,
3923
- is_closed INTEGER NOT NULL DEFAULT 0,
3924
- created_at_utc TEXT NOT NULL,
3925
- last_updated_at_utc TEXT NOT NULL
3926
- )
3927
- """
3928
- )
3929
- conn.execute(
3930
- """
3931
- CREATE INDEX IF NOT EXISTS idx_five_hour_blocks_block_start
3932
- ON five_hour_blocks(block_start_at DESC)
3933
- """
3934
- )
3935
-
3936
- # ── five_hour_milestones (per-percent crossings inside a 5h block) ──
3937
- conn.execute(
3938
- """
3939
- CREATE TABLE IF NOT EXISTS five_hour_milestones (
3940
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3941
- block_id INTEGER NOT NULL,
3942
- five_hour_window_key INTEGER NOT NULL,
3943
- percent_threshold INTEGER NOT NULL,
3944
- captured_at_utc TEXT NOT NULL,
3945
- usage_snapshot_id INTEGER NOT NULL,
3946
- block_input_tokens INTEGER NOT NULL DEFAULT 0,
3947
- block_output_tokens INTEGER NOT NULL DEFAULT 0,
3948
- block_cache_create_tokens INTEGER NOT NULL DEFAULT 0,
3949
- block_cache_read_tokens INTEGER NOT NULL DEFAULT 0,
3950
- block_cost_usd REAL NOT NULL DEFAULT 0,
3951
- marginal_cost_usd REAL,
3952
- seven_day_pct_at_crossing REAL,
3953
- reset_event_id INTEGER NOT NULL DEFAULT 0,
3954
- UNIQUE(five_hour_window_key, percent_threshold, reset_event_id),
3955
- FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
3956
- )
3957
- """
3958
- )
3959
- conn.execute(
3960
- """
3961
- CREATE INDEX IF NOT EXISTS idx_five_hour_milestones_block
3962
- ON five_hour_milestones(block_id)
3963
- """
3964
- )
3965
-
3966
- # alerted_at: see the matching ALTER on `percent_milestones` above for
3967
- # rationale. Same write-once forward-only semantics: the alert-dispatch
3968
- # path stamps this column on milestone-INSERT rows whose threshold
3969
- # matches the user's configured `alerts.five_hour_thresholds`. NULL =
3970
- # "alerts disabled at moment of crossing OR threshold not configured"
3971
- # — never "delivery failed".
3972
- add_column_if_missing(conn, "five_hour_milestones", "alerted_at", "TEXT")
3973
-
3974
- # reset_event_id: segment column added by migration 006. Fresh-install
3975
- # DBs get it via the live CREATE TABLE above + the dispatcher fast-stamps
3976
- # the migration marker (the live DDL must carry the column AND the 3-col
3977
- # UNIQUE for fast-stamp to be safe — see spec §3.2). Existing pre-006
3978
- # DBs trip the migration's rename-recreate-copy idiom (handler in
3979
- # bin/_cctally_db.py); the handler's fast-path probe stamps the marker
3980
- # when the column is already present (covers the corner case where a
3981
- # partially-upgraded DB has the column but not the new UNIQUE — re-run
3982
- # is safe). Mirrors weekly migration 005 / `percent_milestones`.
3983
-
3984
- # ── five_hour_block_models (per-(block, model) rollup-child) ──
3985
- # MUST be created BEFORE the parent-backfill gate below, because
3986
- # _backfill_five_hour_blocks writes into this table on the fresh-install
3987
- # path. UNIQUE keyed on (five_hour_window_key, model) — durable across
3988
- # parent rebuilds. Live writes use DELETE WHERE five_hour_window_key = ?.
3989
- conn.execute(
3990
- """
3991
- CREATE TABLE IF NOT EXISTS five_hour_block_models (
3992
- id INTEGER PRIMARY KEY AUTOINCREMENT,
3993
- block_id INTEGER NOT NULL,
3994
- five_hour_window_key INTEGER NOT NULL,
3995
- model TEXT NOT NULL,
3996
- input_tokens INTEGER NOT NULL DEFAULT 0,
3997
- output_tokens INTEGER NOT NULL DEFAULT 0,
3998
- cache_create_tokens INTEGER NOT NULL DEFAULT 0,
3999
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
4000
- cost_usd REAL NOT NULL DEFAULT 0,
4001
- entry_count INTEGER NOT NULL DEFAULT 0,
4002
- UNIQUE(five_hour_window_key, model),
4003
- FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
4004
- )
4005
- """
4006
- )
4007
- conn.execute(
4008
- """
4009
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_models_block
4010
- ON five_hour_block_models(block_id)
4011
- """
4012
- )
4013
- conn.execute(
4014
- """
4015
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_models_window
4016
- ON five_hour_block_models(five_hour_window_key)
4017
- """
4018
- )
4019
-
4020
- # ── five_hour_block_projects (per-(block, project_path) rollup-child) ──
4021
- # NULL session_files.project_path → '(unknown)' sentinel at write time,
4022
- # keeping reconcile invariant SUM(child.cost) == parent.total intact.
4023
- conn.execute(
4024
- """
4025
- CREATE TABLE IF NOT EXISTS five_hour_block_projects (
4026
- id INTEGER PRIMARY KEY AUTOINCREMENT,
4027
- block_id INTEGER NOT NULL,
4028
- five_hour_window_key INTEGER NOT NULL,
4029
- project_path TEXT NOT NULL,
4030
- input_tokens INTEGER NOT NULL DEFAULT 0,
4031
- output_tokens INTEGER NOT NULL DEFAULT 0,
4032
- cache_create_tokens INTEGER NOT NULL DEFAULT 0,
4033
- cache_read_tokens INTEGER NOT NULL DEFAULT 0,
4034
- cost_usd REAL NOT NULL DEFAULT 0,
4035
- entry_count INTEGER NOT NULL DEFAULT 0,
4036
- UNIQUE(five_hour_window_key, project_path),
4037
- FOREIGN KEY (block_id) REFERENCES five_hour_blocks(id)
4038
- )
4039
- """
4040
- )
4041
- conn.execute(
4042
- """
4043
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_projects_block
4044
- ON five_hour_block_projects(block_id)
4045
- """
4046
- )
4047
- conn.execute(
4048
- """
4049
- CREATE INDEX IF NOT EXISTS idx_five_hour_block_projects_window
4050
- ON five_hour_block_projects(five_hour_window_key)
4051
- """
4052
- )
4053
-
4054
- # Migration framework dispatcher. Replaces the prior inline gate stack
4055
- # (has_blocks + _migration_done) with the framework's _run_pending_-
4056
- # migrations entry point. See spec §2.3, §5.2 + the migration handlers
4057
- # decorated with @stats_migration further down in this file.
4058
- #
4059
- # MUST run BEFORE any DDL or write that touches `schema_migrations`
4060
- # (Codex P1 #1 fix on c3625ee + e7fdcc8): the dispatcher's fresh-install
4061
- # detection snapshots `schema_migrations`'s existence in sqlite_master
4062
- # BEFORE its own CREATE TABLE IF NOT EXISTS. Pre-creating the table
4063
- # earlier in open_db() (or letting `_backfill_five_hour_blocks` insert
4064
- # markers first) flips that snapshot to True on a brand-new DB and
4065
- # dead-codes the stamp-only fast path. The dispatcher is now the sole
4066
- # creator of `schema_migrations` + `schema_migrations_skipped`.
4067
- _run_pending_migrations(
4068
- conn, registry=_STATS_MIGRATIONS, db_label="stats.db",
4069
- )
4070
-
4071
- # One-time historical backfill of five_hour_blocks (rollup only;
4072
- # milestones are forward-only per spec §4.3 / [Write-once milestones]).
4073
- # Idempotent via UNIQUE(five_hour_window_key) + INSERT OR IGNORE.
4074
- # Runs AFTER the dispatcher so `schema_migrations` exists for the
4075
- # marker INSERTs inside the backfill body, and so any fresh-install
4076
- # stamp-only path the dispatcher took above is already committed.
4077
- existing = conn.execute(
4078
- "SELECT 1 FROM five_hour_blocks LIMIT 1"
4079
- ).fetchone()
4080
- has_snapshots = conn.execute(
4081
- "SELECT 1 FROM weekly_usage_snapshots "
4082
- "WHERE five_hour_window_key IS NOT NULL "
4083
- " AND five_hour_percent IS NOT NULL "
4084
- "LIMIT 1"
4085
- ).fetchone()
4086
- if not existing and has_snapshots:
4087
- inserted = _backfill_five_hour_blocks(conn)
4088
- # Re-run the 5h dedup migration AFTER backfill creates parents.
4089
- # The dispatcher above ran while five_hour_blocks was empty, so
4090
- # the dedup handler no-op'd and stamped its marker. Snapshot
4091
- # keys can carry jitter beyond the 600s canonical floor (the
4092
- # 003_* migration handles up to 1800s grouping), so the
4093
- # backfill's `DISTINCT five_hour_window_key` over those keys
4094
- # can produce duplicate parent rows for one physical 5h
4095
- # window. Without this re-invocation those duplicates persist
4096
- # forever — the marker says it ran. Handler owns its own
4097
- # BEGIN/COMMIT and is idempotent (no groups → no-op).
4098
- #
4099
- # Honor `db skip` here as well: if the operator marked 003 as
4100
- # skipped (e.g., poison pill on their machine), we must NOT
4101
- # back-door run the handler. Duplicates introduced by the
4102
- # backfill will persist until they `db unskip` — which is the
4103
- # explicit choice the skip records. Failure path mirrors the
4104
- # dispatcher's contract: route through _log_migration_error so
4105
- # the next interactive command renders the banner, and clear
4106
- # the log entry on success so the banner auto-dismisses.
4107
- if inserted > 0:
4108
- target_name = "003_merge_5h_block_duplicates_v1"
4109
- try:
4110
- skipped = {
4111
- row[0] for row in conn.execute(
4112
- "SELECT name FROM schema_migrations_skipped"
4113
- ).fetchall()
4114
- }
4115
- except sqlite3.OperationalError:
4116
- skipped = set()
4117
- if target_name not in skipped:
4118
- for _m in _STATS_MIGRATIONS:
4119
- if _m.name == target_name:
4120
- qualified = f"stats.db:{target_name}"
4121
- try:
4122
- _m.handler(conn)
4123
- _clear_migration_error_log_entries(qualified)
4124
- except Exception as exc:
4125
- _log_migration_error(
4126
- name=qualified,
4127
- exc=exc,
4128
- tb=traceback.format_exc(),
4129
- )
4130
- eprint(f"[migration {qualified}] failed: {exc}")
4131
- break
4132
-
4133
- conn.commit()
4134
- return conn
4135
-
4136
-
4137
-
4138
3486
  @dataclass
4139
3487
  class CacheModelBreakdown:
4140
3488
  model_name: str
@@ -4637,85 +3985,6 @@ def compute_week_cost(
4637
3985
  )
4638
3986
 
4639
3987
 
4640
- def _canonicalize_optional_iso(value: str | None, label: str) -> str | None:
4641
- if value is None:
4642
- return None
4643
- s = value.strip()
4644
- if s == "":
4645
- return None
4646
- normalized = _normalize_week_boundary_dt(parse_iso_datetime(s, label)).astimezone(dt.timezone.utc)
4647
- return normalized.isoformat(timespec="seconds")
4648
-
4649
-
4650
- @dataclass(frozen=True)
4651
- class WeekRef:
4652
- week_start: dt.date
4653
- week_end: dt.date | None
4654
- week_start_at: str | None
4655
- week_end_at: str | None
4656
- key: str
4657
-
4658
-
4659
- def make_week_ref(
4660
- week_start_date: str,
4661
- week_end_date: str | None,
4662
- week_start_at: str | None = None,
4663
- week_end_at: str | None = None,
4664
- ) -> WeekRef:
4665
- week_start = dt.date.fromisoformat(week_start_date)
4666
- week_end = dt.date.fromisoformat(week_end_date) if week_end_date else None
4667
- start_at = _canonicalize_optional_iso(week_start_at, "weekStartAt")
4668
- end_at = _canonicalize_optional_iso(week_end_at, "weekEndAt")
4669
-
4670
- return WeekRef(
4671
- week_start=week_start,
4672
- week_end=week_end,
4673
- week_start_at=start_at,
4674
- week_end_at=end_at,
4675
- key=week_start.isoformat(),
4676
- )
4677
-
4678
-
4679
- def _get_latest_row_for_week(
4680
- conn: sqlite3.Connection,
4681
- table_name: str,
4682
- week_ref: WeekRef,
4683
- as_of_utc: str | None = None,
4684
- ) -> sqlite3.Row | None:
4685
- if as_of_utc is None:
4686
- return conn.execute(
4687
- f"""
4688
- SELECT *
4689
- FROM {table_name}
4690
- WHERE week_start_date = ?
4691
- ORDER BY captured_at_utc DESC, id DESC
4692
- LIMIT 1
4693
- """,
4694
- (week_ref.week_start.isoformat(),),
4695
- ).fetchone()
4696
- return conn.execute(
4697
- f"""
4698
- SELECT *
4699
- FROM {table_name}
4700
- WHERE week_start_date = ?
4701
- AND captured_at_utc <= ?
4702
- ORDER BY captured_at_utc DESC, id DESC
4703
- LIMIT 1
4704
- """,
4705
- (week_ref.week_start.isoformat(), as_of_utc),
4706
- ).fetchone()
4707
-
4708
-
4709
- def get_latest_usage_for_week(
4710
- conn: sqlite3.Connection,
4711
- week_ref: WeekRef,
4712
- as_of_utc: str | None = None,
4713
- ) -> sqlite3.Row | None:
4714
- return _get_latest_row_for_week(
4715
- conn, "weekly_usage_snapshots", week_ref, as_of_utc=as_of_utc,
4716
- )
4717
-
4718
-
4719
3988
  def get_latest_cost_for_week(conn: sqlite3.Connection, week_ref: WeekRef) -> sqlite3.Row | None:
4720
3989
  return _get_latest_row_for_week(conn, "weekly_cost_snapshots", week_ref)
4721
3990
 
@@ -5532,6 +4801,18 @@ def _load_recorded_five_hour_windows(
5532
4801
  else:
5533
4802
  d = d.astimezone(dt.timezone.utc)
5534
4803
  credit_moments.append(d)
4804
+ # Issue #44: the inner-loop break below latches onto the
4805
+ # first credit in [next_bs, rs]. With two credits inside
4806
+ # the same pre-credit canonical 5h window, the wrong one
4807
+ # (the later one) wins when SQLite returns rows in
4808
+ # insertion order rather than time order — collapsing
4809
+ # two distinct truncated anchors onto the same floored
4810
+ # bucket and silently dropping one via override-map
4811
+ # overwrite. Sort once so the break consistently picks
4812
+ # the EARLIEST credit, which is the one that actually
4813
+ # ended the earlier block (its floor equals the next
4814
+ # block's block_start_at by construction).
4815
+ credit_moments.sort()
5535
4816
  except sqlite3.DatabaseError:
5536
4817
  credit_moments = []
5537
4818
  except (sqlite3.DatabaseError, OSError):
@@ -5955,8 +5236,19 @@ def cmd_daily(args: argparse.Namespace) -> int:
5955
5236
  # Collect entries.
5956
5237
  all_entries = get_entries(range_start, range_end)
5957
5238
 
5958
- # Aggregate by display-tz date (Q5/F6: day boundary follows display.tz).
5959
- days = _aggregate_daily(all_entries, mode="auto", tz=tz)
5239
+ # Build the unified daily view (spec §5.1: gap-free; the dashboard
5240
+ # heatmap's contiguous-window materialization stays at the dashboard
5241
+ # envelope adapter so CLI byte-stability is preserved). Consume
5242
+ # `view.aggregated` (BucketUsage tuple) for the CLI renderers — the
5243
+ # JSON shape's `bucket` / `model_breakdowns` / `models: list[str]`
5244
+ # fields live on BucketUsage, not on DailyPanelRow. The builder's
5245
+ # `_aggregate_daily` call is the same one we used inline.
5246
+ view = build_daily_view(all_entries, now_utc=_command_as_of(),
5247
+ display_tz=tz)
5248
+ # `_aggregate_daily` returned ascending order; build_daily_view stores
5249
+ # `aggregated` newest-first. CLI's default order is ascending, so
5250
+ # re-reverse to match the prior on-the-wire shape.
5251
+ days = list(reversed(view.aggregated))
5960
5252
 
5961
5253
  # Shareable-reports gate: --format short-circuits the JSON / table
5962
5254
  # dispatch via `_share_render_and_emit`. The mutex in
@@ -5974,7 +5266,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
5974
5266
  # subcommands (cmd_report's --detail, etc.).
5975
5267
  display_tz_str = _share_display_tz_label(tz)
5976
5268
  snap = _build_daily_snapshot(
5977
- days,
5269
+ view,
5978
5270
  period_start=range_start,
5979
5271
  period_end=range_end,
5980
5272
  display_tz=display_tz_str,
@@ -6024,7 +5316,18 @@ def cmd_monthly(args: argparse.Namespace) -> int:
6024
5316
 
6025
5317
  all_entries = get_entries(range_start, range_end)
6026
5318
 
6027
- months = _aggregate_monthly(all_entries, mode="auto", tz=tz)
5319
+ # Build the unified monthly view (spec §5.2: drops boundary-spillover
5320
+ # bucket; computes delta_cost_pct internally). Consume
5321
+ # `view.aggregated` (BucketUsage tuple, newest-first) for CLI byte-
5322
+ # stability — `_bucket_to_json` reads BucketUsage fields not present
5323
+ # on MonthlyPeriodRow.
5324
+ #
5325
+ # Pass a large `n` so the CLI's `--since`/`--until` window controls
5326
+ # how many months render (the dashboard caps at n=12; CLI doesn't).
5327
+ view = build_monthly_view(all_entries, now_utc=_command_as_of(),
5328
+ n=10**6, display_tz=tz)
5329
+ # The view stores `aggregated` newest-first; CLI default is asc.
5330
+ months = list(reversed(view.aggregated))
6028
5331
 
6029
5332
  # Shareable-reports gate: --format short-circuits the JSON / table
6030
5333
  # dispatch via `_share_render_and_emit`. The mutex in
@@ -6039,7 +5342,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
6039
5342
  # share spec scope). Same convention as cmd_daily / cmd_report.
6040
5343
  display_tz_str = _share_display_tz_label(tz)
6041
5344
  snap = _build_monthly_snapshot(
6042
- months,
5345
+ view,
6043
5346
  period_start=range_start,
6044
5347
  period_end=range_end,
6045
5348
  display_tz=display_tz_str,
@@ -6104,15 +5407,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
6104
5407
  else:
6105
5408
  fetch_start = range_start
6106
5409
  all_entries = get_entries(fetch_start, range_end)
6107
- buckets = _aggregate_weekly(all_entries, weeks)
6108
-
6109
- # Align overlay (used_pct, $/1%) to the same order as `buckets`.
6110
- # `buckets` may be a subset of `weeks` (weeks with no entries are
6111
- # dropped by _aggregate_weekly), so we look up each bucket's SubWeek
6112
- # by `start_date.isoformat()` — the invariant enforced by
6113
- # _aggregate_weekly is that every emitted bucket key maps to exactly
6114
- # one SubWeek in `weeks`.
6115
- #
5410
+
6116
5411
  # Bound the usage-snapshot lookup to `<= range_end` so historical
6117
5412
  # `--until <past date>` queries pick the usage% that was current at
6118
5413
  # the end of the requested window rather than the globally latest
@@ -6128,25 +5423,19 @@ def cmd_weekly(args: argparse.Namespace) -> int:
6128
5423
  .isoformat()
6129
5424
  .replace("+00:00", "Z")
6130
5425
  )
6131
- overlay: list[tuple[float | None, float | None]] = []
6132
- for bucket in buckets:
6133
- sw = next(w for w in weeks if w.start_date.isoformat() == bucket.bucket)
6134
- ref = make_week_ref(
6135
- week_start_date=sw.start_date.isoformat(),
6136
- week_end_date=sw.end_date.isoformat(),
6137
- week_start_at=sw.start_ts,
6138
- week_end_at=sw.end_ts,
6139
- )
6140
- row = get_latest_usage_for_week(conn, ref, as_of_utc=as_of_utc)
6141
- # `weekly_usage_snapshots.weekly_percent` is NOT NULL per schema,
6142
- # but we still guard against missing rows (no snapshot captured
6143
- # yet for the week).
6144
- if row is not None and row["weekly_percent"] is not None:
6145
- pct = float(row["weekly_percent"])
6146
- dpc = (bucket.cost_usd / pct) if pct > 0 else None
6147
- overlay.append((pct, dpc))
6148
- else:
6149
- overlay.append((None, None))
5426
+
5427
+ # Build the unified weekly view (spec §5.3): runs _aggregate_weekly,
5428
+ # overlays weekly_usage_snapshots per WeekRef. view.aggregated is
5429
+ # the BucketUsage tuple newest-first; view.overlay is the parallel
5430
+ # (used_pct, dollar_per_pct) tuple. We reverse both for CLI's
5431
+ # default asc rendering so the existing renderer's len-equality
5432
+ # assertions stay aligned.
5433
+ view = build_weekly_view(
5434
+ conn, all_entries, weeks=weeks, now_utc=now_utc,
5435
+ display_tz=args._resolved_tz, as_of_utc=as_of_utc,
5436
+ )
5437
+ buckets = list(reversed(view.aggregated))
5438
+ overlay = list(reversed(view.overlay))
6150
5439
 
6151
5440
  # Shareable-reports gate: --format short-circuits the JSON / table
6152
5441
  # dispatch via `_share_render_and_emit`. The mutex in
@@ -6161,7 +5450,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
6161
5450
  if getattr(args, "format", None):
6162
5451
  display_tz_str = _share_display_tz_label(args._resolved_tz)
6163
5452
  snap = _build_weekly_snapshot(
6164
- buckets, overlay,
5453
+ view,
6165
5454
  period_start=range_start,
6166
5455
  period_end=range_end,
6167
5456
  display_tz=display_tz_str,
@@ -6406,46 +5695,6 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
6406
5695
  return 0
6407
5696
 
6408
5697
 
6409
- def _command_as_of() -> dt.datetime:
6410
- """Testing hook: CCTALLY_AS_OF env var overrides wall-clock `now` for
6411
- time-dependent commands. Shared by cmd_project, cmd_weekly,
6412
- cmd_cache_report, cmd_codex_weekly, cmd_diff (and any future
6413
- time-dependent command). Format: ISO-8601 with Z or explicit tz offset.
6414
- """
6415
- override = os.environ.get("CCTALLY_AS_OF")
6416
- if override:
6417
- override = override.strip()
6418
- if override.endswith("Z"):
6419
- override = override[:-1] + "+00:00"
6420
- return dt.datetime.fromisoformat(override).astimezone(dt.timezone.utc)
6421
- return dt.datetime.now(dt.timezone.utc)
6422
-
6423
-
6424
- def _now_utc() -> dt.datetime:
6425
- """UTC now, with CCTALLY_AS_OF env override for fixture-stability.
6426
-
6427
- Single time source for the `update` subcommand and its supporting
6428
- state machine (TTL gates, ``remind_after.until_utc`` comparisons,
6429
- log timestamps, install-method detection cache). Mirrors the
6430
- documented CCTALLY_AS_OF precedent (see CLAUDE.md — `project` has
6431
- a hidden `CCTALLY_AS_OF` env hook, and `_command_as_of` /
6432
- `_share_now_utc` reuse it for `weekly`/`forecast`/share-render).
6433
- Accepts ISO-8601 with `Z` or explicit offset; result is always
6434
- tz-aware UTC.
6435
-
6436
- Raises ValueError on malformed CCTALLY_AS_OF — deliberate fail-loud
6437
- for the dev hook so fixture authors notice typos immediately rather
6438
- than silently falling back to wall-clock time.
6439
- """
6440
- override = os.environ.get("CCTALLY_AS_OF")
6441
- if override:
6442
- override = override.strip()
6443
- if override.endswith("Z"):
6444
- override = override[:-1] + "+00:00"
6445
- return dt.datetime.fromisoformat(override).astimezone(dt.timezone.utc)
6446
- return dt.datetime.now(dt.timezone.utc)
6447
-
6448
-
6449
5698
  def _load_week_snapshots(
6450
5699
  since: dt.datetime, until: dt.datetime
6451
5700
  ) -> dict[dt.datetime, float]:
@@ -7279,7 +6528,20 @@ def cmd_session(args: argparse.Namespace) -> int:
7279
6528
  range_start, range_end = range
7280
6529
 
7281
6530
  entries = get_claude_session_entries(range_start, range_end)
7282
- sessions = _aggregate_claude_sessions(entries)
6531
+ # Unified view-model kernel (spec §6.5). `limit=None` keeps the
6532
+ # full aggregator output — `cctally session` has no `--limit` flag
6533
+ # and emits every session in the requested range. `view.aggregated`
6534
+ # is the `list[ClaudeSessionUsage]` shape the legacy CLI / share
6535
+ # renderers consume (table, --json, share-snapshot); `view.rows`
6536
+ # is the typed `TuiSessionRow` tuple reserved for the TUI /
6537
+ # dashboard wiring in Task 15 / 16. Keeping both shapes parallel
6538
+ # at the builder preserves the resumed-session merge invariant
6539
+ # documented in CLAUDE.md (one sessionId across multiple JSONL
6540
+ # files collapses to ONE entry in BOTH tuples).
6541
+ view = build_sessions_view(
6542
+ entries, now_utc=_command_as_of(), limit=None, display_tz=tz,
6543
+ )
6544
+ sessions = list(view.aggregated)
7283
6545
 
7284
6546
  # Shareable-reports gate: --format short-circuits the JSON / table
7285
6547
  # dispatch via `_share_render_and_emit`. The mutex in
@@ -7313,7 +6575,7 @@ def cmd_session(args: argparse.Namespace) -> int:
7313
6575
  # cmd_daily / cmd_project.
7314
6576
  display_tz_str = _share_display_tz_label(tz)
7315
6577
  snap = _build_session_snapshot(
7316
- sessions,
6578
+ view,
7317
6579
  period_start=range_start,
7318
6580
  period_end=range_end,
7319
6581
  display_tz=display_tz_str,
@@ -8647,7 +7909,7 @@ def cmd_report(args: argparse.Namespace) -> int:
8647
7909
  we_d + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
8648
7910
  )
8649
7911
  snap = _build_report_snapshot(
8650
- [],
7912
+ TrendView(),
8651
7913
  period_start=ws_dt,
8652
7914
  period_end=we_dt,
8653
7915
  display_tz=display_tz_str,
@@ -8663,124 +7925,113 @@ def cmd_report(args: argparse.Namespace) -> int:
8663
7925
  print("No data yet. Add record-usage to your status line script (see record-usage --help).")
8664
7926
  return 0
8665
7927
 
8666
- trend: list[dict[str, Any]] = []
8667
- current_row: dict[str, Any] | None = None
8668
-
8669
- # Resolve oauth_usage cfg once -- it's loop-invariant across all
8670
- # trend rows. Falls back to defaults on config validation error.
8671
- try:
8672
- _fresh_cfg = _get_oauth_usage_config(config)
8673
- except OauthUsageConfigError:
8674
- _fresh_cfg = _get_oauth_usage_config({})
8675
-
8676
- # Build a set of week_start_date keys that occur MORE THAN ONCE
8677
- # in `weeks` — these are credited weeks (round-3, Bug B) where
8678
- # `_apply_reset_events_to_weekrefs` synthesized a pre-credit
8679
- # ref alongside the post-credit ref. Both refs share
8680
- # `week_start_date` so the default
8681
- # ``get_latest_usage_for_week(conn, ref)`` returns the SAME row
8682
- # (the most recent snapshot in the whole week) for both,
8683
- # collapsing the pre-credit row's `weeklyPercent` to the
8684
- # post-credit value. For those keys ONLY, pin `as_of_utc` to
8685
- # the ref's `week_end_at` so each segment renders its own
8686
- # latest snapshot. Non-credit weeks (single ref per key) keep
8687
- # the legacy unfiltered lookup so test fixtures that seed
8688
- # snapshots OUTSIDE the API-derived week window (e.g.
8689
- # `test_report_freshness`) keep finding their rows.
8690
- _split_keys = {
8691
- r.week_start.isoformat()
8692
- for r in weeks
8693
- if sum(
8694
- 1 for x in weeks
8695
- if x.week_start.isoformat() == r.week_start.isoformat()
8696
- ) > 1
8697
- }
8698
-
8699
- for week_ref in weeks:
8700
- key = week_ref.week_start.isoformat()
8701
- usage = get_latest_usage_for_week(
8702
- conn,
8703
- week_ref,
8704
- as_of_utc=(
8705
- week_ref.week_end_at if key in _split_keys else None
8706
- ),
7928
+ # Build the unified trend view (spec §5.4). `build_trend_view`
7929
+ # owns the per-row construction, including:
7930
+ # - get_latest_usage_for_week with split-key as_of_utc pinning
7931
+ # for credited weeks (Bug D / round-3 Bug B parity)
7932
+ # - _week_ref_has_reset_event _compute_cost_for_weekref bypass
7933
+ # for reset-affected weeks
7934
+ # - freshness sub-dict derivation
7935
+ # - 3-sample-rule average
7936
+ # Note: build_trend_view returns rows oldest-first (chronological);
7937
+ # cmd_report's JSON contract is newest-first to mirror
7938
+ # get_recent_weeks's order we reverse below.
7939
+ view = build_trend_view(conn, now_utc=_command_as_of(), n=args.weeks,
7940
+ display_tz=tz)
7941
+ # Serialize TuiTrendRow today's camelCase keys. Order:
7942
+ # newest-first (matches the prior cmd_report behavior).
7943
+ # Map week_start_date original WeekRef ISO strings so the
7944
+ # JSON serialization preserves the snapshot-stored tz format
7945
+ # (`+00:00` for UTC-anchored weeks) — TuiTrendRow's datetime
7946
+ # form re-localizes via parse_iso_datetime, which would emit
7947
+ # `+03:00` on a UTC+3 host and break byte-stability.
7948
+ #
7949
+ # Index by ``(week_start_date_iso, week_start_at_utc_instant)``
7950
+ # so ``_row_to_dict`` resolves a row's original WeekRef ISO
7951
+ # strings in O(1) credited weeks share ``week_start_date`` so
7952
+ # the UTC-instant disambiguates them. The lookup key matches the
7953
+ # row-side derivation in ``_row_to_dict`` (UTC instant from the
7954
+ # parsed datetime).
7955
+ week_iso_by_key: dict[tuple[str, dt.datetime],
7956
+ tuple[str | None, str | None]] = {}
7957
+ for wr in weeks:
7958
+ if wr.week_start_at is None:
7959
+ continue
7960
+ try:
7961
+ wr_utc = parse_iso_datetime(
7962
+ wr.week_start_at, "wr.week_start_at",
7963
+ ).astimezone(dt.timezone.utc)
7964
+ except ValueError:
7965
+ continue
7966
+ week_iso_by_key[(wr.week_start.isoformat(), wr_utc)] = (
7967
+ wr.week_start_at,
7968
+ wr.week_end_at,
8707
7969
  )
8708
7970
 
8709
- # For weeks touched by a reset event, the cached
8710
- # weekly_cost_snapshots row covers the API-derived range (which
8711
- # extends past the reset into the NEW week or starts days
8712
- # BEFORE the reset in the new week). Bypass the cache and live-
8713
- # compute from session_entries over the effective range that
8714
- # _apply_reset_events_to_weekrefs baked into the ref. Normal
8715
- # weeks still hit the cache — zero perf change for the common
8716
- # path. `cost_captured_at` is pinned to the usage snapshot's
8717
- # captured time (not wall-clock `now_utc`): `_trend_row_recency_
8718
- # seconds` ranks the trend table by the freshest-data timestamp,
8719
- # and if every reset-affected week reported wall-clock now the
8720
- # oldest such week would sort like the newest and the current
8721
- # subscription week could slide below historical rows.
8722
- cost: sqlite3.Row | None = None
8723
- usage_captured_at = usage["captured_at_utc"] if usage else None
8724
- if _week_ref_has_reset_event(conn, week_ref):
8725
- cost_usd = _compute_cost_for_weekref(week_ref)
8726
- cost_captured_at = usage_captured_at if cost_usd is not None else None
8727
- range_start_iso = week_ref.week_start_at
8728
- range_end_iso = week_ref.week_end_at
8729
- else:
8730
- cost = get_latest_cost_for_week(conn, week_ref)
8731
- cost_usd = float(cost["cost_usd"]) if cost else None
8732
- cost_captured_at = cost["captured_at_utc"] if cost else None
8733
- range_start_iso = cost["range_start_iso"] if cost and cost["range_start_iso"] else None
8734
- range_end_iso = cost["range_end_iso"] if cost and cost["range_end_iso"] else None
8735
-
8736
- percent = float(usage["weekly_percent"]) if usage else None
8737
- ratio = (cost_usd / percent) if (cost_usd is not None and percent and percent > 0) else None
8738
- usage_captured_dt = _parse_iso_datetime_optional(usage_captured_at)
8739
- cost_captured_dt = _parse_iso_datetime_optional(cost_captured_at)
8740
-
8741
- as_of_dt: dt.datetime | None = None
8742
- if usage_captured_dt and cost_captured_dt:
8743
- as_of_dt = usage_captured_dt if usage_captured_dt >= cost_captured_dt else cost_captured_dt
8744
- else:
8745
- as_of_dt = usage_captured_dt or cost_captured_dt
8746
- as_of = as_of_dt.isoformat(timespec="seconds") if as_of_dt else None
8747
-
8748
- row = {
8749
- "weekStartDate": week_ref.week_start.isoformat(),
8750
- "weekEndDate": week_ref.week_end.isoformat() if week_ref.week_end else None,
8751
- "weekStartAt": week_ref.week_start_at,
8752
- "weekEndAt": week_ref.week_end_at,
8753
- "weeklyPercent": percent,
8754
- "weeklyCostUSD": round(cost_usd, 9) if cost_usd is not None else None,
8755
- "dollarsPerPercent": round(ratio, 9) if ratio is not None else None,
8756
- "usageCapturedAt": usage_captured_at,
8757
- "costCapturedAt": cost_captured_at,
8758
- "asOf": as_of,
8759
- "rangeStartIso": range_start_iso,
8760
- "rangeEndIso": range_end_iso,
7971
+ def _row_to_dict(r):
7972
+ # Match this row's WeekRef by (week_start_date, UTC instant
7973
+ # of parsed week_start_at) credited weeks share
7974
+ # week_start_date so we disambiguate via the UTC instant.
7975
+ wsd_str = (
7976
+ r.week_start_date.isoformat() if r.week_start_date else None
7977
+ )
7978
+ ws_at = ws_at_end = None
7979
+ if wsd_str is not None and r.week_start_at is not None:
7980
+ r_utc = r.week_start_at.astimezone(dt.timezone.utc)
7981
+ hit = week_iso_by_key.get((wsd_str, r_utc))
7982
+ if hit is not None:
7983
+ ws_at, ws_at_end = hit
7984
+
7985
+ d: dict[str, Any] = {
7986
+ "weekStartDate": wsd_str,
7987
+ "weekEndDate": (
7988
+ r.week_end_date.isoformat() if r.week_end_date else None
7989
+ ),
7990
+ "weekStartAt": ws_at,
7991
+ "weekEndAt": ws_at_end,
7992
+ "weeklyPercent": r.used_pct,
7993
+ "weeklyCostUSD": (
7994
+ round(r.weekly_cost_usd, 9)
7995
+ if r.weekly_cost_usd is not None else None
7996
+ ),
7997
+ "dollarsPerPercent": (
7998
+ round(r.dollars_per_percent, 9)
7999
+ if r.dollars_per_percent is not None else None
8000
+ ),
8001
+ "usageCapturedAt": r.usage_captured_at,
8002
+ "costCapturedAt": r.cost_captured_at,
8003
+ "asOf": r.as_of,
8004
+ "rangeStartIso": r.range_start_iso,
8005
+ "rangeEndIso": r.range_end_iso,
8761
8006
  }
8762
- if usage_captured_at:
8763
- age_s = _seconds_since_iso(usage_captured_at)
8764
- if age_s is not None and age_s <= 86400:
8765
- row["freshness"] = {
8766
- "label": _freshness_label(age_s, _fresh_cfg),
8767
- "captured_at": usage_captured_at,
8768
- "age_seconds": int(age_s),
8769
- }
8007
+ if r.freshness:
8008
+ d["freshness"] = r.freshness
8009
+ return d
8010
+
8011
+ # view.rows is oldest-first; reverse for cmd_report's newest-first
8012
+ # JSON contract. Also need WeekRef-based current_row matching —
8013
+ # use weekRef key + week_start_at to disambiguate credited weeks.
8014
+ # We re-walk the original `weeks` list to map (key, week_start_at)
8015
+ # → the corresponding dict row.
8016
+ trend: list[dict[str, Any]] = []
8017
+ current_row: dict[str, Any] | None = None
8018
+ # `view.rows` order = chrono asc (oldest first). Build trend in
8019
+ # the reverse order (newest first) to match the historical
8020
+ # cmd_report contract.
8021
+ # The view's TuiTrendRow doesn't carry WeekRef.key directly; we
8022
+ # use (week_start_date, week_start_at) for the match — week_start_at
8023
+ # in TuiTrendRow is a parsed datetime, and current_ref carries
8024
+ # ISO strings.
8025
+ for r in reversed(view.rows):
8026
+ row = _row_to_dict(r)
8770
8027
  trend.append(row)
8771
- # Bug D (v1.7.2 round-4): for credited weeks `weeks` contains
8772
- # TWO refs sharing `key` (pre-credit + post-credit segments).
8773
- # `current_ref` was routed through
8774
- # `_apply_reset_events_to_weekrefs` above so its
8775
- # `week_start_at` reflects the post-credit segment's effective
8776
- # start (or the original start for non-credit weeks). Match on
8777
- # BOTH `key` AND `week_start_at` so the pre-credit ref doesn't
8778
- # overwrite the post-credit row's selection on the second
8779
- # iteration (last-write-wins on key-only equality picked the
8780
- # wrong row on the user's live data).
8028
+ # Match against current_ref. Compare by week_start ISO date
8029
+ # AND week_start_at ISO string.
8030
+ week_start_at_iso = row["weekStartAt"]
8781
8031
  if (
8782
- week_ref.key == current_ref.key
8783
- and week_ref.week_start_at == current_ref.week_start_at
8032
+ r.week_start_date is not None
8033
+ and r.week_start_date.isoformat() == current_ref.key
8034
+ and week_start_at_iso == current_ref.week_start_at
8784
8035
  ):
8785
8036
  current_row = row
8786
8037
 
@@ -8824,17 +8075,28 @@ def cmd_report(args: argparse.Namespace) -> int:
8824
8075
  # detail isn't in the share spec scope). Same convention applies
8825
8076
  # to other share-enabled subcommands (cmd_daily's --breakdown,
8826
8077
  # etc.).
8827
- ordered_trend = list(reversed(trend))
8828
- if ordered_trend:
8829
- first_wsd = ordered_trend[0].get("weekStartDate")
8830
- last_wed = ordered_trend[-1].get("weekEndDate") or ordered_trend[-1].get("weekStartDate")
8078
+ #
8079
+ # `view.rows` is already chronological (oldest-first), the
8080
+ # order the chart needs. period_start / period_end derived
8081
+ # from the view's oldest / newest rows.
8082
+ if view.rows:
8083
+ first_r = view.rows[0]
8084
+ last_r = view.rows[-1]
8085
+ first_wsd = (
8086
+ first_r.week_start_date.isoformat()
8087
+ if first_r.week_start_date else None
8088
+ )
8089
+ last_wed = (
8090
+ last_r.week_end_date.isoformat()
8091
+ if last_r.week_end_date else first_wsd
8092
+ )
8831
8093
  period_start = _share_parse_date_to_dt(first_wsd, tz)
8832
8094
  period_end = _share_parse_date_to_dt(last_wed, tz)
8833
8095
  else:
8834
8096
  period_start = period_end = _share_now_utc()
8835
8097
  display_tz_str = _share_display_tz_label(tz)
8836
8098
  snap = _build_report_snapshot(
8837
- ordered_trend,
8099
+ view,
8838
8100
  period_start=period_start,
8839
8101
  period_end=period_end,
8840
8102
  display_tz=display_tz_str,
@@ -13056,7 +12318,7 @@ def _share_display_tz_label(tz: "ZoneInfo | None") -> str:
13056
12318
 
13057
12319
 
13058
12320
  def _build_report_snapshot(
13059
- rows: list[dict[str, object]],
12321
+ view: "TrendView",
13060
12322
  *,
13061
12323
  period_start: dt.datetime,
13062
12324
  period_end: dt.datetime,
@@ -13067,21 +12329,23 @@ def _build_report_snapshot(
13067
12329
  ) -> "ShareSnapshot":
13068
12330
  """Build a ShareSnapshot for `cctally report`.
13069
12331
 
13070
- `rows` is the in-memory `trend` list produced by `cmd_report` a list
13071
- of dicts keyed in the existing JSON-shape camelCase
13072
- (`weekStartDate`, `weeklyPercent`, `weeklyCostUSD`, `dollarsPerPercent`).
13073
- The plan's snake_case keys (`week_start_date`, `used_pct`, `cost_usd`,
13074
- `dollar_per_pct`) are NOT the actual `cmd_report` data shape — see
13075
- Implementor 7 commit body for the deviation.
12332
+ Consumes the unified TrendView (spec §6.4). `view.rows` is the
12333
+ chronological (oldest-first) TuiTrendRow tuple exactly the order
12334
+ the chart needs (BarChart polyline trends left→right with time);
12335
+ no reversal needed.
13076
12336
 
13077
- Caller MUST pass `rows` in chronological order (oldest first) so the
13078
- chart line trends left→right with time. `cmd_report`'s native `trend`
13079
- is newest-first; the gate site reverses before calling.
12337
+ The earlier camelCase-dict workaround (recorded in the commit body
12338
+ of Implementor 7 of the share-v2 work) is obsolete: `TuiTrendRow`
12339
+ now carries 10 nullable extended fields (spec §4.1) and is the
12340
+ single typed shape that flows through both CLI report and share
12341
+ builders. Cmd_report's JSON serialization happens at the gate site
12342
+ (camelCase mapping done in cmd_report); this function reads
12343
+ attributes directly from the typed row.
13080
12344
 
13081
- `theme` and `reveal_projects` flow into the subtitle directly so the
13082
- builder owns the canonical subtitle shape — no post-build re-stamp at
13083
- the gate site. The forward-reference return type matches the kernel's
13084
- lazy-import boundary.
12345
+ `theme` and `reveal_projects` flow into the subtitle directly so
12346
+ the builder owns the canonical subtitle shape — no post-build
12347
+ re-stamp at the gate site. The forward-reference return type
12348
+ matches the kernel's lazy-import boundary.
13085
12349
  """
13086
12350
  _lib_share = _share_load_lib()
13087
12351
  columns = (
@@ -13091,12 +12355,11 @@ def _build_report_snapshot(
13091
12355
  _lib_share.ColumnSpec(key="dpp", label="$ / %", align="right",
13092
12356
  emphasis=True),
13093
12357
  )
12358
+ rows = view.rows # oldest-first; matches chart's left→right walk.
13094
12359
  snap_rows: list = []
13095
12360
  chart_pts: list = []
13096
12361
  for i, r in enumerate(rows):
13097
- wsd = r.get("weekStartDate")
13098
- # `weekStartDate` is sourced from `week_ref.week_start.isoformat()`
13099
- # — guaranteed `str`. Empty / unparseable falls back to em-dash.
12362
+ wsd = r.week_start_date.isoformat() if r.week_start_date else None
13100
12363
  if isinstance(wsd, str) and wsd:
13101
12364
  try:
13102
12365
  week_label = dt.date.fromisoformat(wsd).strftime("%b %d")
@@ -13109,9 +12372,9 @@ def _build_report_snapshot(
13109
12372
  # share artifact follows the same convention. Coercing None to
13110
12373
  # 0.0 would render `$0.00` / `0.0%` — indistinguishable from a
13111
12374
  # genuine zero, and would skew the avg / chart.
13112
- used_pct_raw = r.get("weeklyPercent")
13113
- cost_raw = r.get("weeklyCostUSD")
13114
- dpp_raw = r.get("dollarsPerPercent")
12375
+ used_pct_raw = r.used_pct
12376
+ cost_raw = r.weekly_cost_usd
12377
+ dpp_raw = r.dollars_per_percent
13115
12378
  snap_rows.append(_lib_share.Row(cells={
13116
12379
  "week": _lib_share.TextCell(week_label),
13117
12380
  "used": (
@@ -13140,10 +12403,17 @@ def _build_report_snapshot(
13140
12403
  _lib_share.LineChart(points=tuple(chart_pts), y_label="$ / %")
13141
12404
  if len(chart_pts) >= 3 else None
13142
12405
  )
13143
- avg_dpp = (
13144
- sum(p.y_value for p in chart_pts) / len(chart_pts)
13145
- if chart_pts else 0.0
13146
- )
12406
+ # Source the avg from the view (3-sample rule). Falls back to a
12407
+ # length-based average over the chart points for the <3-sample case
12408
+ # so the Totalled cell always renders something concrete; preserves
12409
+ # the prior $0.00 sentinel on empty data.
12410
+ if view.avg_dollars_per_pct is not None:
12411
+ avg_dpp = view.avg_dollars_per_pct
12412
+ else:
12413
+ avg_dpp = (
12414
+ sum(p.y_value for p in chart_pts) / len(chart_pts)
12415
+ if chart_pts else 0.0
12416
+ )
13147
12417
  totals = (
13148
12418
  _lib_share.Totalled(label="Avg $/%", value=f"${avg_dpp:,.2f}"),
13149
12419
  )
@@ -13172,7 +12442,7 @@ def _build_report_snapshot(
13172
12442
 
13173
12443
 
13174
12444
  def _build_daily_snapshot(
13175
- rows: list["BucketUsage"],
12445
+ view: "DailyView",
13176
12446
  *,
13177
12447
  period_start: dt.datetime,
13178
12448
  period_end: dt.datetime,
@@ -13183,10 +12453,11 @@ def _build_daily_snapshot(
13183
12453
  ) -> "ShareSnapshot":
13184
12454
  """Build a ShareSnapshot for `cctally daily`.
13185
12455
 
13186
- `rows` is the in-memory `BucketUsage` list produced by `_aggregate_daily`
13187
- inside `cmd_daily`. Each bucket has: `bucket` (YYYY-MM-DD date string),
13188
- `cost_usd`, `model_breakdowns` (list[dict] sorted by cost desc; first
13189
- entry is the top model).
12456
+ Consumes the unified DailyView (spec §6.1). `view.aggregated` is
12457
+ the gap-free BucketUsage tuple in newest-first order; we reverse
12458
+ here so BarChart bars render left-to-right chronologically.
12459
+ `view.total_cost_usd` is the pre-computed sum (replacing the
12460
+ prior inline re-totaling).
13190
12461
 
13191
12462
  Deviations from the plan sketch (which assumed dict rows with keys
13192
12463
  `date` / `cost_usd` / `pct_of_week` / `top_model`):
@@ -13199,13 +12470,11 @@ def _build_daily_snapshot(
13199
12470
  - `top_model` is the first entry of `model_breakdowns` (sorted by cost
13200
12471
  desc per upstream ccusage parity); empty → "—".
13201
12472
 
13202
- Caller MUST pass `rows` in chronological order so the BarChart bars
13203
- line up left-to-right with time. `_aggregate_daily` already returns
13204
- asc; the gate site re-sorts after `args.order == "desc"` was applied.
13205
-
13206
- `theme` and `reveal_projects` flow into the subtitle directly so the
13207
- builder owns the canonical subtitle shape — no post-build re-stamp at
13208
- the gate site.
12473
+ `period_start` / `period_end` / `display_tz` are passed by the
12474
+ caller (they reflect the CLI's `--since` / `--until` window which
12475
+ may extend past the data window). `theme` and `reveal_projects`
12476
+ flow into the subtitle directly so the builder owns the canonical
12477
+ subtitle shape no post-build re-stamp at the gate site.
13209
12478
  """
13210
12479
  _lib_share = _share_load_lib()
13211
12480
  columns = (
@@ -13217,9 +12486,11 @@ def _build_daily_snapshot(
13217
12486
  _lib_share.ColumnSpec(key="top_model", label="Top Model",
13218
12487
  align="left"),
13219
12488
  )
13220
- total_cost = 0.0
13221
- for r in rows:
13222
- total_cost += float(getattr(r, "cost_usd", 0.0) or 0.0)
12489
+ # Caller MUST pass rows in chronological order so the BarChart bars
12490
+ # line up left-to-right with time. view.aggregated is newest-first
12491
+ # (matches dashboard convention); reverse for chronological iteration.
12492
+ rows = list(reversed(view.aggregated))
12493
+ total_cost = view.total_cost_usd
13223
12494
 
13224
12495
  snap_rows: list = []
13225
12496
  chart_pts: list = []
@@ -13287,7 +12558,7 @@ def _build_daily_snapshot(
13287
12558
 
13288
12559
 
13289
12560
  def _build_monthly_snapshot(
13290
- rows: list["BucketUsage"],
12561
+ view: "MonthlyView",
13291
12562
  *,
13292
12563
  period_start: dt.datetime,
13293
12564
  period_end: dt.datetime,
@@ -13298,9 +12569,9 @@ def _build_monthly_snapshot(
13298
12569
  ) -> "ShareSnapshot":
13299
12570
  """Build a ShareSnapshot for `cctally monthly`.
13300
12571
 
13301
- `rows` is the in-memory `BucketUsage` list produced by `_aggregate_monthly`
13302
- inside `cmd_monthly`. Each bucket has: `bucket` (YYYY-MM string),
13303
- `cost_usd`, and `model_breakdowns` (list[dict] sorted by cost desc).
12572
+ Consumes the unified MonthlyView (spec §6.2). `view.aggregated` is
12573
+ the gap-free BucketUsage tuple in newest-first order; we reverse
12574
+ so BarChart bars render left-to-right chronologically.
13304
12575
 
13305
12576
  Deviations from the plan sketch (which assumed dict rows with keys
13306
12577
  `month` / `cost_usd` / `sessions`):
@@ -13313,14 +12584,15 @@ def _build_monthly_snapshot(
13313
12584
  - `Δ vs prior` is computed on `cost_usd` between consecutive ASC-sorted
13314
12585
  months, matching the plan's intent.
13315
12586
 
13316
- Caller MUST pass `rows` in chronological order so the BarChart bars line
13317
- up left-to-right with time. `_aggregate_monthly` already returns asc;
13318
- the gate site fires before the `--order desc` reversal in `cmd_monthly`.
13319
-
13320
- `theme` and `reveal_projects` flow into the subtitle directly so the
13321
- builder owns the canonical subtitle shape — no post-build re-stamp at
13322
- the gate site.
12587
+ `period_start` / `period_end` / `display_tz` are passed by the
12588
+ caller (the CLI's `--since` / `--until` window may extend past
12589
+ the data window). `theme` / `reveal_projects` flow into the
12590
+ subtitle directly so the builder owns the canonical subtitle
12591
+ shape no post-build re-stamp at the gate site.
13323
12592
  """
12593
+ # Caller MUST pass rows in chronological order so the BarChart bars
12594
+ # line up left-to-right with time. view.aggregated is newest-first.
12595
+ rows = list(reversed(view.aggregated))
13324
12596
  _lib_share = _share_load_lib()
13325
12597
  columns = (
13326
12598
  _lib_share.ColumnSpec(key="month", label="Month", align="left"),
@@ -13398,8 +12670,7 @@ def _build_monthly_snapshot(
13398
12670
 
13399
12671
 
13400
12672
  def _build_weekly_snapshot(
13401
- rows: list["BucketUsage"],
13402
- overlay: list[tuple[float | None, float | None]],
12673
+ view: "WeeklyView",
13403
12674
  *,
13404
12675
  period_start: dt.datetime,
13405
12676
  period_end: dt.datetime,
@@ -13411,16 +12682,19 @@ def _build_weekly_snapshot(
13411
12682
  ) -> "ShareSnapshot":
13412
12683
  """Build a ShareSnapshot for `cctally weekly`.
13413
12684
 
13414
- `rows` is the in-memory `BucketUsage` list produced by `_aggregate_weekly`
13415
- inside `cmd_weekly`; each bucket carries `bucket` (week_start_date as
13416
- "YYYY-MM-DD"), `cost_usd`, `total_tokens`, and `model_breakdowns`
13417
- (list[dict] sorted by cost desc, each `{modelName, ..., cost}`).
12685
+ Consumes the unified WeeklyView (spec §6.3). `view.aggregated` is
12686
+ the gap-free BucketUsage tuple newest-first; `view.overlay` is the
12687
+ parallel `(used_pct, dollars_per_pct)` tuple. We reverse both for
12688
+ chronological iteration so BarChart bars render left-to-right
12689
+ with time.
13418
12690
 
13419
- `overlay` is the parallel list of `(used_pct, dollars_per_pct)` tuples
13420
- computed by `cmd_weekly` from `weekly_usage_snapshots`. Either component
13421
- may be `None` for a week that has no captured snapshot yet — surfaces
13422
- in the snapshot row as a `0.0` PercentCell so the column stays aligned
13423
- (matching the table renderer's "no data 0%" behavior).
12691
+ Each bucket carries `bucket` (week_start_date as "YYYY-MM-DD"),
12692
+ `cost_usd`, `total_tokens`, and `model_breakdowns` (list[dict]
12693
+ sorted by cost desc, each `{modelName, ..., cost}`). Either
12694
+ overlay component may be `None` for a week with no captured
12695
+ snapshot — surfaces in the snapshot row as a `0.0` PercentCell so
12696
+ the column stays aligned (matching the table renderer's "no data
12697
+ → 0%" behavior).
13424
12698
 
13425
12699
  Deviations from the plan sketch (which assumed dict rows with keys
13426
12700
  `week_start_date` / `used_pct` / `cost_usd` / `sessions` /
@@ -13444,14 +12718,14 @@ def _build_weekly_snapshot(
13444
12718
  All model-axis iteration uses a single sorted list (`all_model_keys`)
13445
12719
  so column / stack ordering is deterministic across runs.
13446
12720
 
13447
- Caller MUST pass `rows` (and `overlay` aligned to it) in chronological
13448
- order so the BarChart bars line up left-to-right with time. The gate
13449
- site fires BEFORE the `--order desc` reversal in `cmd_weekly`.
13450
-
13451
12721
  `theme` and `reveal_projects` flow into the subtitle directly so the
13452
12722
  builder owns the canonical subtitle shape — no post-build re-stamp at
13453
12723
  the gate site.
13454
12724
  """
12725
+ # view.aggregated / view.overlay are newest-first; reverse for asc
12726
+ # so BarChart bars are chronological.
12727
+ rows = list(reversed(view.aggregated))
12728
+ overlay = list(reversed(view.overlay))
13455
12729
  _lib_share = _share_load_lib()
13456
12730
  columns_list: list = [
13457
12731
  _lib_share.ColumnSpec(key="week", label="Week Start", align="left"),
@@ -14119,7 +13393,7 @@ def _session_disambiguate_labels(
14119
13393
 
14120
13394
 
14121
13395
  def _build_session_snapshot(
14122
- sessions: list["ClaudeSessionUsage"],
13396
+ view: "SessionsView",
14123
13397
  *,
14124
13398
  period_start: dt.datetime,
14125
13399
  period_end: dt.datetime,
@@ -14132,11 +13406,17 @@ def _build_session_snapshot(
14132
13406
  ) -> "ShareSnapshot":
14133
13407
  """Build a ShareSnapshot for `cctally session`.
14134
13408
 
14135
- `sessions` is the in-memory `ClaudeSessionUsage` list produced by
14136
- `_aggregate_claude_sessions` inside `cmd_session`. Each session has:
14137
- `session_id` (UUID), `project_path` (filesystem path), `cost_usd`,
14138
- `last_activity` (`dt.datetime`), `models` (first-seen-order
14139
- `list[str]`), and the token aggregates.
13409
+ Consumes the unified ``SessionsView`` (spec §6.5). ``view.aggregated``
13410
+ is the ``ClaudeSessionUsage`` tuple the shape this builder needs
13411
+ for ``source_paths`` / ``model_breakdowns`` / ``last_activity``
13412
+ (fields ``view.rows`` / ``TuiSessionRow`` doesn't carry). The
13413
+ in-memory shape is unchanged at the read boundary — only the
13414
+ parameter container differs.
13415
+
13416
+ Each ``ClaudeSessionUsage`` has: ``session_id`` (UUID),
13417
+ ``project_path`` (filesystem path), ``cost_usd``,
13418
+ ``last_activity`` (``dt.datetime``), ``models`` (first-seen-order
13419
+ ``list[str]``), and the token aggregates.
14140
13420
 
14141
13421
  Privacy invariant (Section 8.4 / Section 5.3): the builder populates
14142
13422
  `ProjectCell.label`, `ChartPoint.project_label`, and
@@ -14161,12 +13441,14 @@ def _build_session_snapshot(
14161
13441
  not present on session data) to suffix `" (parent)"` on
14162
13442
  collisions before the scrubber runs.
14163
13443
 
14164
- Caller MUST pass `sessions` already sorted in the desired order;
14165
- the builder re-sorts internally by descending cost so the chart's
14166
- HBar bars rank consistently with the anonymization-mapping
14167
- (`_build_anon_mapping` also sorts by descending cost) keeping
14168
- `project-1` aligned with the highest-cost bar in the chart even
14169
- when the user asked for `--order asc`.
13444
+ Caller MUST pass ``view`` whose ``aggregated`` tuple is already
13445
+ sorted in the desired order (``cmd_session`` keeps the
13446
+ aggregator's descending-by-last_activity sort); the builder
13447
+ re-sorts internally by descending cost so the chart's HBar bars
13448
+ rank consistently with the anonymization-mapping
13449
+ (``_build_anon_mapping`` also sorts by descending cost) — keeping
13450
+ ``project-1`` aligned with the highest-cost bar in the chart even
13451
+ when the user asked for ``--order asc``.
14170
13452
 
14171
13453
  `top_n`, when set (must be `>= 1`; caller validates), truncates
14172
13454
  BOTH the table rows and the chart points to the top-N by cost.
@@ -14193,7 +13475,8 @@ def _build_session_snapshot(
14193
13475
  # Sort by descending cost so the snapshot's chart-order matches the
14194
13476
  # `_build_anon_mapping` sort key (also descending cost).
14195
13477
  sorted_sessions = sorted(
14196
- sessions, key=lambda s: -float(getattr(s, "cost_usd", 0.0) or 0.0)
13478
+ view.aggregated,
13479
+ key=lambda s: -float(getattr(s, "cost_usd", 0.0) or 0.0),
14197
13480
  )
14198
13481
  # Apply --top-n truncation (caller validated >= 1). Truncation status
14199
13482
  # gates the title shape below.