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/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/bin/_cctally_alerts.py +12 -5
- package/bin/_cctally_cache.py +12 -11
- package/bin/_cctally_config.py +34 -19
- package/bin/_cctally_core.py +890 -0
- package/bin/_cctally_dashboard.py +175 -233
- package/bin/_cctally_db.py +89 -20
- package/bin/_cctally_record.py +76 -75
- package/bin/_cctally_refresh.py +35 -20
- package/bin/_cctally_setup.py +26 -16
- package/bin/_cctally_sync_week.py +21 -6
- package/bin/_cctally_tui.py +151 -306
- package/bin/_cctally_update.py +11 -16
- package/bin/_lib_aggregators.py +7 -1
- package/bin/_lib_diff_kernel.py +19 -21
- package/bin/_lib_subscription_weeks.py +17 -9
- package/bin/_lib_view_models.py +993 -0
- package/bin/cctally +338 -1055
- package/dashboard/static/assets/{index-DhCnIFq9.js → index-CfXu9Fx_.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +10 -8
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
|
-
#
|
|
5959
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6132
|
-
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
6142
|
-
|
|
6143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
#
|
|
8670
|
-
#
|
|
8671
|
-
|
|
8672
|
-
|
|
8673
|
-
|
|
8674
|
-
|
|
8675
|
-
|
|
8676
|
-
#
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
#
|
|
8680
|
-
#
|
|
8681
|
-
#
|
|
8682
|
-
#
|
|
8683
|
-
#
|
|
8684
|
-
#
|
|
8685
|
-
#
|
|
8686
|
-
#
|
|
8687
|
-
#
|
|
8688
|
-
#
|
|
8689
|
-
#
|
|
8690
|
-
|
|
8691
|
-
|
|
8692
|
-
|
|
8693
|
-
|
|
8694
|
-
|
|
8695
|
-
|
|
8696
|
-
|
|
8697
|
-
|
|
8698
|
-
|
|
8699
|
-
|
|
8700
|
-
|
|
8701
|
-
|
|
8702
|
-
|
|
8703
|
-
|
|
8704
|
-
|
|
8705
|
-
|
|
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
|
-
|
|
8710
|
-
#
|
|
8711
|
-
#
|
|
8712
|
-
#
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
8722
|
-
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
|
|
8728
|
-
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
8732
|
-
|
|
8733
|
-
|
|
8734
|
-
|
|
8735
|
-
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
8739
|
-
|
|
8740
|
-
|
|
8741
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
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
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
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
|
-
#
|
|
8772
|
-
#
|
|
8773
|
-
|
|
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
|
-
|
|
8783
|
-
and
|
|
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
|
-
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13071
|
-
|
|
13072
|
-
(
|
|
13073
|
-
|
|
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
|
-
|
|
13078
|
-
|
|
13079
|
-
|
|
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
|
|
13082
|
-
builder owns the canonical subtitle shape — no post-build
|
|
13083
|
-
the gate site. The forward-reference return type
|
|
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.
|
|
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.
|
|
13113
|
-
cost_raw = r.
|
|
13114
|
-
dpp_raw = r.
|
|
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
|
-
|
|
13144
|
-
|
|
13145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13187
|
-
|
|
13188
|
-
|
|
13189
|
-
|
|
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
|
-
|
|
13203
|
-
|
|
13204
|
-
|
|
13205
|
-
|
|
13206
|
-
|
|
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
|
-
|
|
13221
|
-
|
|
13222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13302
|
-
|
|
13303
|
-
|
|
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
|
-
|
|
13317
|
-
|
|
13318
|
-
the
|
|
13319
|
-
|
|
13320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13415
|
-
|
|
13416
|
-
|
|
13417
|
-
|
|
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
|
-
|
|
13420
|
-
|
|
13421
|
-
|
|
13422
|
-
|
|
13423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14136
|
-
|
|
14137
|
-
|
|
14138
|
-
|
|
14139
|
-
|
|
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
|
|
14165
|
-
|
|
14166
|
-
|
|
14167
|
-
|
|
14168
|
-
|
|
14169
|
-
|
|
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
|
-
|
|
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.
|