cctally 1.22.2 → 1.22.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1979 @@
1
+ """Forecast + Budget + Report command family.
2
+
3
+ Holds `cmd_report`, `cmd_forecast`, `cmd_budget`, the forecast core cluster
4
+ (window resolvers, ForecastInputs/ForecastOutput/BudgetRow, _compute_forecast,
5
+ the forecast render helpers, _iso_z), and the 10 budget render/snapshot helpers.
6
+
7
+ Honest *name* imports are KERNEL-ONLY (_cctally_core) + stdlib. Every library
8
+ kernel + sibling helper this module needs (build_forecast_view, build_trend_view,
9
+ compute_budget_status, project_linear, _build_{forecast,report,budget}_snapshot,
10
+ _share_*, format_display_dt, resolve_display_tz, get_recent_weeks,
11
+ _reconcile_budget_on_config_write, ...) is reached via the call-time _cctally()
12
+ accessor so test monkeypatches through cctally's namespace are preserved (§2).
13
+
14
+ The budget WRITE-PATH cluster (insert_budget_milestone,
15
+ _reconcile_budget_milestones_on_set, _reconcile_budget_on_config_write) lives in
16
+ _cctally_milestones.py (re-exported on the cctally ns), and the WeekRef JOIN
17
+ cluster (get_recent_weeks, _apply_reset_events_to_weekrefs,
18
+ _get_canonical_boundary_for_date) lives in _cctally_weekrefs.py (re-exported on
19
+ the cctally ns); both are reached via c.
20
+
21
+ _iso_z is defined HERE (intra-module) and re-exported in bin/cctally AFTER the
22
+ dashboard _iso_z binding so cctally._iso_z stays the forecast version
23
+ (the dt-only variant _lib_diff_kernel + _cctally_five_hour depend on).
24
+
25
+ Spec: docs/superpowers/specs/2026-05-31-extract-forecast-budget-cmd-design.md
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import datetime as dt
31
+ import json
32
+ import math
33
+ import os
34
+ import sqlite3
35
+ import sys
36
+ from dataclasses import dataclass
37
+
38
+ from _cctally_core import (
39
+ _command_as_of, _normalize_week_boundary_dt, compute_week_bounds,
40
+ eprint, get_week_start_name, make_week_ref, now_utc_iso,
41
+ open_db, parse_iso_datetime,
42
+ )
43
+ # Non-kernel _cctally_core re-exports used by the moved code. Verified NOT
44
+ # ns-patched (§5.A gate); honest-import permitted (the §8.1b gate allows any
45
+ # _cctally_core import; neither is in KERNEL_SYMBOLS or PROMOTED_GLOBALS, so the
46
+ # kernel-invariant tests are unaffected). _BudgetConfigError is an exception
47
+ # class — honest-import avoids the except-over-accessor foot-gun
48
+ # (gotcha_except_over_callable_shim_typeerrors).
49
+ from _cctally_core import _BudgetConfigError, _get_budget_config
50
+
51
+
52
+ def _cctally():
53
+ """Resolve the current `cctally` module at call-time (§2)."""
54
+ return sys.modules["cctally"]
55
+
56
+
57
+ # === moved verbatim from bin/cctally (class ForecastInputs -> _build_budget_no_budget_snapshot) ===
58
+ @dataclass
59
+ class ForecastInputs:
60
+ now_utc: dt.datetime
61
+ week_start_at: dt.datetime
62
+ week_end_at: dt.datetime
63
+ elapsed_hours: float
64
+ elapsed_fraction: float
65
+ remaining_hours: float
66
+ remaining_days: float
67
+ # Current state
68
+ p_now: float
69
+ five_hour_percent: float | None
70
+ spent_usd: float
71
+ snapshot_count: int
72
+ latest_snapshot_at: dt.datetime
73
+ # Rate inputs
74
+ p_24h_ago: float | None
75
+ t_24h_actual_hours: float | None
76
+ # $/1% selection
77
+ dollars_per_percent: float
78
+ dollars_per_percent_source: str # "this_week" | "trailing_4wk_median" | "this_week_sparse"
79
+ # Confidence
80
+ confidence: str # "high" | "low"
81
+ low_confidence_reasons: list[str]
82
+
83
+
84
+ def _resolve_forecast_now(as_of: str | None) -> dt.datetime:
85
+ """Return `now` as a UTC-aware datetime, honoring --as-of for tests.
86
+
87
+ When ``--as-of`` is omitted, delegates to ``_command_as_of()`` so the
88
+ CCTALLY_AS_OF env var (the canonical testing hook shared with weekly,
89
+ cache-report, codex-weekly, and project) is also honored. Wall-clock
90
+ behavior is preserved when neither mechanism is set.
91
+ """
92
+ if as_of:
93
+ return parse_iso_datetime(as_of, "--as-of").astimezone(dt.timezone.utc)
94
+ return _command_as_of()
95
+
96
+
97
+ def _fetch_current_week_snapshots(conn: sqlite3.Connection, now_utc: dt.datetime):
98
+ """Return (week_start_at, week_end_at, list[(captured_at, percent, five_hr)])
99
+ for the subscription week containing `now_utc`, or None if no snapshot
100
+ exists for the current week.
101
+
102
+ Selection: the snapshot whose [week_start_at, week_end_at) contains
103
+ now_utc; ties (none expected) broken by max(captured_at_utc).
104
+
105
+ Includes a date-only fallback when no boundary-aware row matches —
106
+ synthesizes a UTC window from week_start_date/week_end_date at local
107
+ midnight so installs that only have legacy date-based rows for the active
108
+ week still get a forecast.
109
+
110
+ Same-week NULL-timestamp rows (week_start_at IS NULL AND week_start_date
111
+ matches chosen) are folded in as well — upgrade-window mid-migration rows
112
+ stay visible.
113
+
114
+ Samples are filtered to `captured_at <= now_utc` so that `--as-of <past>`
115
+ is deterministic (no leak of future snapshots into samples[-1] / p_now /
116
+ snapshot_count / latest_snapshot_at).
117
+ """
118
+ candidates = conn.execute(
119
+ "SELECT week_start_at, week_end_at, week_start_date, MAX(captured_at_utc) AS latest_cap "
120
+ "FROM weekly_usage_snapshots "
121
+ "WHERE week_start_at IS NOT NULL AND week_end_at IS NOT NULL "
122
+ "GROUP BY week_start_at, week_end_at, week_start_date"
123
+ ).fetchall()
124
+ chosen = None
125
+ chosen_cap = None
126
+ for r in candidates:
127
+ try:
128
+ ws = parse_iso_datetime(r[0], "week_start_at")
129
+ we = parse_iso_datetime(r[1], "week_end_at")
130
+ except ValueError:
131
+ continue
132
+ if ws <= now_utc < we:
133
+ cap = r[3]
134
+ if chosen is None or (cap is not None and (chosen_cap is None or cap > chosen_cap)):
135
+ chosen = r
136
+ chosen_cap = cap
137
+ if chosen is None:
138
+ # Date-only fallback: find a week whose [week_start_date, week_end_date]
139
+ # (inclusive) contains today's local date. Synthesize a UTC window from
140
+ # those dates at local midnight.
141
+ # internal fallback: host-local intentional
142
+ today_local_str = now_utc.astimezone().date().isoformat()
143
+ drow = conn.execute(
144
+ "SELECT week_start_date, week_end_date "
145
+ "FROM weekly_usage_snapshots "
146
+ "WHERE week_start_date <= ? AND week_end_date >= ? "
147
+ "GROUP BY week_start_date, week_end_date "
148
+ "ORDER BY MAX(captured_at_utc) DESC LIMIT 1",
149
+ (today_local_str, today_local_str),
150
+ ).fetchone()
151
+ if drow is None:
152
+ return None
153
+ # internal fallback: host-local intentional
154
+ local_tz = dt.datetime.now().astimezone().tzinfo
155
+ ws_date = dt.date.fromisoformat(drow[0])
156
+ we_date = dt.date.fromisoformat(drow[1])
157
+ week_start_at = dt.datetime.combine(ws_date, dt.time(0, 0), local_tz).astimezone(dt.timezone.utc)
158
+ week_end_at = dt.datetime.combine(we_date + dt.timedelta(days=1), dt.time(0, 0), local_tz).astimezone(dt.timezone.utc)
159
+ rows = conn.execute(
160
+ "SELECT captured_at_utc, weekly_percent, five_hour_percent "
161
+ "FROM weekly_usage_snapshots "
162
+ "WHERE week_start_date = ? "
163
+ "ORDER BY captured_at_utc ASC",
164
+ (drow[0],),
165
+ ).fetchall()
166
+ samples = [
167
+ (parse_iso_datetime(r[0], "captured_at_utc"), float(r[1]),
168
+ float(r[2]) if r[2] is not None else None)
169
+ for r in rows
170
+ ]
171
+ samples = [s for s in samples if s[0] <= now_utc]
172
+ return week_start_at, week_end_at, samples
173
+ row = chosen
174
+ week_start_at = parse_iso_datetime(row[0], "week_start_at")
175
+ week_end_at = parse_iso_datetime(row[1], "week_end_at")
176
+ # Collect every textual variant of week_start_at that parses to the same
177
+ # instant as the chosen row, so legacy local-offset rows and newly
178
+ # UTC-canonicalized rows for the SAME week are loaded together during an
179
+ # upgrade-in-progress window.
180
+ matching_texts: list[str] = []
181
+ for r in candidates:
182
+ try:
183
+ rws = parse_iso_datetime(r[0], "week_start_at")
184
+ except ValueError:
185
+ continue
186
+ if rws == week_start_at:
187
+ matching_texts.append(r[0])
188
+ chosen_date = chosen[2]
189
+ placeholders = ",".join("?" * len(matching_texts))
190
+ rows = conn.execute(
191
+ f"SELECT captured_at_utc, weekly_percent, five_hour_percent "
192
+ f"FROM weekly_usage_snapshots "
193
+ f"WHERE week_start_at IN ({placeholders}) "
194
+ f" OR (week_start_at IS NULL AND week_start_date = ?) "
195
+ f"ORDER BY captured_at_utc ASC",
196
+ tuple(matching_texts) + (chosen_date,),
197
+ ).fetchall()
198
+ samples = [
199
+ (parse_iso_datetime(r[0], "captured_at_utc"), float(r[1]),
200
+ float(r[2]) if r[2] is not None else None)
201
+ for r in rows
202
+ ]
203
+ samples = [s for s in samples if s[0] <= now_utc]
204
+ return week_start_at, week_end_at, samples
205
+
206
+
207
+ def _apply_midweek_reset_override(
208
+ conn: sqlite3.Connection,
209
+ week_start_at: dt.datetime,
210
+ week_end_at: dt.datetime,
211
+ samples: list,
212
+ ) -> tuple[dt.datetime, list]:
213
+ """If the current week's end_at matches a recorded reset event's
214
+ ``new_week_end_at``, shift ``week_start_at`` to the effective reset
215
+ moment and drop pre-reset samples.
216
+
217
+ Keeps callers (``_load_forecast_inputs``, ``_tui_build_current_week``)
218
+ from reporting spent_usd summed across the pre-reset window.
219
+
220
+ Returns the (possibly-shifted) ``week_start_at`` and a
221
+ (possibly-filtered) samples list. On any SQL or parse error, returns
222
+ the inputs unchanged — the override is best-effort.
223
+ """
224
+ try:
225
+ end_iso = _normalize_week_boundary_dt(
226
+ week_end_at.astimezone(dt.timezone.utc)
227
+ ).isoformat(timespec="seconds")
228
+ event_row = conn.execute(
229
+ "SELECT effective_reset_at_utc FROM week_reset_events "
230
+ "WHERE new_week_end_at = ?",
231
+ (end_iso,),
232
+ ).fetchone()
233
+ if event_row and event_row["effective_reset_at_utc"]:
234
+ reset_dt = parse_iso_datetime(
235
+ event_row["effective_reset_at_utc"], "reset_event.effective"
236
+ )
237
+ if reset_dt > week_start_at:
238
+ week_start_at = reset_dt
239
+ samples = [s for s in samples if s[0] >= reset_dt]
240
+ except (sqlite3.DatabaseError, ValueError):
241
+ pass
242
+ return week_start_at, samples
243
+
244
+
245
+ def _resolve_current_budget_window(conn, now_utc):
246
+ """Return ``(effective_week_start_dt, week_end_dt)`` for the subscription
247
+ week containing ``now_utc``, honoring a mid-week reset re-anchor; or
248
+ ``None`` if no snapshot exists yet.
249
+
250
+ Reuses the SAME reset-aware resolution forecast/weekly use
251
+ (``_fetch_current_week_snapshots`` + ``_apply_midweek_reset_override``)
252
+ so the budget display window and the alert-firing window (Task 3) agree.
253
+ Unlike forecast's ``_load_forecast_inputs``, this does NOT short-circuit
254
+ on an empty samples list — budget computes live spend from
255
+ ``session_entries`` regardless of whether a usage snapshot landed inside
256
+ the window, so the worst case is ``spent_usd = 0`` (spec §6), not a
257
+ no-window outcome.
258
+ """
259
+ fetched = _fetch_current_week_snapshots(conn, now_utc)
260
+ if fetched is None:
261
+ return None
262
+ week_start_at, week_end_at, samples = fetched
263
+ week_start_at, _samples = _apply_midweek_reset_override(
264
+ conn, week_start_at, week_end_at, samples
265
+ )
266
+ return (week_start_at, week_end_at)
267
+
268
+
269
+ def _build_budget_status_inputs(conn, *, target_usd, now_utc, alert_thresholds):
270
+ """Gather live spend over the current subscription week and build a
271
+ :class:`BudgetInputs`. Returns ``None`` when no week window resolves.
272
+
273
+ Spend is recomputed live via ``_sum_cost_for_range(..., mode="auto")``
274
+ (the same path ``weekly`` / ``forecast`` use — pricing edits take effect
275
+ immediately; F3's reconcile invariant is pinned here, NOT to snapshot
276
+ ``report``). ``recent_24h_usd`` is a second trailing-24h call that is NOT
277
+ display-only: in ``_lib_budget.compute_budget_status`` it feeds
278
+ ``rate_recent → rate_high → projected_high → projected``, which drives the
279
+ ok/warn/over verdict. It is therefore clamped to the current budget week
280
+ (``max(week_start_at, now - 24h)``) so a heavy spend just before reset
281
+ can't leak last week's dollars into a fresh week's verdict (false
282
+ WARN/OVER).
283
+ """
284
+ c = _cctally()
285
+ window = _resolve_current_budget_window(conn, now_utc)
286
+ if window is None:
287
+ return None
288
+ week_start_at, week_end_at = window
289
+ spent = c._sum_cost_for_range(week_start_at, now_utc, mode="auto")
290
+ # Clamp the recent-rate window at the week start: both bounds are tz-aware
291
+ # so max() is well-defined. Without this, a brand-new week (now < week
292
+ # start + 24h) would pull pre-reset spend into rate_recent and flip a
293
+ # fresh verdict to warn/over.
294
+ recent_start = max(week_start_at, now_utc - dt.timedelta(hours=24))
295
+ recent_24h = c._sum_cost_for_range(recent_start, now_utc, mode="auto")
296
+ return c.BudgetInputs(
297
+ target_usd=float(target_usd),
298
+ spent_usd=float(spent),
299
+ recent_24h_usd=float(recent_24h),
300
+ week_start_at=week_start_at,
301
+ week_end_at=week_end_at,
302
+ now=now_utc,
303
+ alert_thresholds=tuple(alert_thresholds),
304
+ )
305
+
306
+
307
+ def _select_dollars_per_percent(
308
+ conn: sqlite3.Connection,
309
+ now_utc: dt.datetime,
310
+ current_week_start: dt.datetime,
311
+ p_now: float,
312
+ spent_usd: float,
313
+ *,
314
+ skip_sync: bool = False,
315
+ ) -> tuple[float, str]:
316
+ """Return (dollars_per_percent, source_label). See spec §1 selection rule.
317
+
318
+ Eligible prior week: week_end_at < now_utc AND final_weekly_percent >= 1.
319
+ Uses the existing `_sum_cost_for_range` helper (which opens the cache DB
320
+ via `get_entries`); `conn` is only used for snapshot queries.
321
+ """
322
+ c = _cctally()
323
+ # Path 1: current week, stable sample.
324
+ if p_now >= 10.0 and p_now > 0:
325
+ return spent_usd / p_now, "this_week"
326
+
327
+ # Path 2: trailing 4-week median.
328
+ # Fetch all eligible prior weeks then Python-filter (legacy rows may carry
329
+ # non-UTC offsets that break lexical compare against an ISO-UTC bound).
330
+ rows = conn.execute(
331
+ "SELECT week_start_at, week_end_at, MAX(weekly_percent) AS final_pct, "
332
+ " MAX(captured_at_utc) AS latest_cap "
333
+ "FROM weekly_usage_snapshots "
334
+ "WHERE week_start_at IS NOT NULL AND week_end_at IS NOT NULL "
335
+ "GROUP BY week_start_at "
336
+ "HAVING MAX(weekly_percent) >= 1"
337
+ ).fetchall()
338
+ by_instant: dict[dt.datetime, dict] = {}
339
+ for r in rows:
340
+ try:
341
+ ws = parse_iso_datetime(r[0], "week_start_at")
342
+ we = parse_iso_datetime(r[1], "week_end_at")
343
+ except ValueError:
344
+ continue
345
+ slot = by_instant.get(ws)
346
+ final_pct = float(r[2])
347
+ if slot is None:
348
+ by_instant[ws] = {"we": we, "final_pct": final_pct}
349
+ else:
350
+ slot["final_pct"] = max(slot["final_pct"], final_pct)
351
+
352
+ eligible: list[tuple[dt.datetime, dt.datetime, float]] = [
353
+ (ws, v["we"], v["final_pct"])
354
+ for ws, v in by_instant.items()
355
+ if ws < current_week_start and v["we"] < now_utc and v["final_pct"] >= 1.0
356
+ ]
357
+ eligible.sort(key=lambda x: x[0], reverse=True)
358
+ prior = eligible[:4]
359
+ if len(prior) >= 4:
360
+ import statistics
361
+ values: list[float] = []
362
+ for ws, we, final_pct in prior:
363
+ week_cost = c._sum_cost_for_range(ws, we, mode="auto", skip_sync=skip_sync)
364
+ values.append(week_cost / final_pct)
365
+ return statistics.median(values), "trailing_4wk_median"
366
+
367
+ # Path 3: fall back to current week even if sparse.
368
+ if p_now > 0:
369
+ return spent_usd / p_now, "this_week_sparse"
370
+ # p_now == 0: no signal. Return 0; math layer guards against div-by-zero.
371
+ return 0.0, "this_week_sparse"
372
+
373
+
374
+ def _assess_forecast_confidence(
375
+ elapsed_hours: float, p_now: float, snapshot_count: int
376
+ ) -> tuple[str, list[str]]:
377
+ """Binary confidence (spec §2)."""
378
+ reasons: list[str] = []
379
+ if elapsed_hours < 24:
380
+ reasons.append("elapsed_hours<24")
381
+ if p_now < 2:
382
+ reasons.append("percent<2")
383
+ if snapshot_count < 3:
384
+ reasons.append("snapshots<3")
385
+ return ("low", reasons) if reasons else ("high", [])
386
+
387
+
388
+ def _pick_p_24h_ago(
389
+ samples: list[tuple[dt.datetime, float, float | None]],
390
+ now_utc: dt.datetime,
391
+ ) -> tuple[float | None, float | None]:
392
+ """Return (p_24h_ago, t_24h_actual_hours). Closest to (now_utc - 24h)
393
+ by absolute time delta. t_24h_actual_hours can be <24h even when
394
+ ≥24h samples exist — that is fine; the confidence logic (spec §2)
395
+ keys on sample availability, not on the picked sample's age.
396
+ Returns (None, None) if there are no samples."""
397
+ if not samples:
398
+ return None, None
399
+ target = now_utc - dt.timedelta(hours=24)
400
+ pick = min(samples, key=lambda s: abs((s[0] - target).total_seconds()))
401
+ t_actual = (now_utc - pick[0]).total_seconds() / 3600.0
402
+ return pick[1], t_actual
403
+
404
+
405
+ def _load_forecast_inputs(
406
+ conn: sqlite3.Connection,
407
+ now_utc: dt.datetime,
408
+ *,
409
+ skip_sync: bool = False,
410
+ ) -> ForecastInputs | None:
411
+ """Gather everything from the DB. Returns None if no current-week snapshot.
412
+
413
+ When `skip_sync=True`, all JSONL-backed cost lookups skip the ingest pass
414
+ and serve whatever is already in the cache (honors `forecast --no-sync`).
415
+ """
416
+ c = _cctally()
417
+ fetched = _fetch_current_week_snapshots(conn, now_utc)
418
+ if fetched is None:
419
+ return None
420
+ week_start_at, week_end_at, samples = fetched
421
+ if not samples:
422
+ return None
423
+
424
+ # Mid-week reset override: shift week_start_at to the effective
425
+ # reset moment and drop pre-reset samples so elapsed/remaining math
426
+ # and spent_usd reflect the post-reset window only.
427
+ week_start_at, samples = _apply_midweek_reset_override(
428
+ conn, week_start_at, week_end_at, samples
429
+ )
430
+
431
+ if not samples:
432
+ return None
433
+ latest = samples[-1]
434
+ p_now = latest[1]
435
+ five_hr = latest[2]
436
+
437
+ elapsed_hours = (now_utc - week_start_at).total_seconds() / 3600.0
438
+ total_hours = (week_end_at - week_start_at).total_seconds() / 3600.0
439
+ elapsed_fraction = max(0.01, min(0.99, elapsed_hours / total_hours if total_hours else 0.5))
440
+ remaining_hours = max(0.0, (week_end_at - now_utc).total_seconds() / 3600.0)
441
+ remaining_days = remaining_hours / 24.0
442
+
443
+ # Live compute current-week spend via the existing helper (opens cache.db
444
+ # internally); mirrors `weekly`'s pattern of not trusting weekly_cost_snapshots.
445
+ spent_usd = c._sum_cost_for_range(
446
+ week_start_at, now_utc, mode="auto", skip_sync=skip_sync
447
+ )
448
+ p_24h_ago, t_24h = _pick_p_24h_ago(samples, now_utc)
449
+
450
+ # Cache is warm for this invocation after the spent_usd lookup. Suppress
451
+ # re-syncs in downstream cost lookups (trailing-4wk-median loop hits
452
+ # _sum_cost_for_range once per historical week).
453
+ dpp, dpp_source = _select_dollars_per_percent(
454
+ conn, now_utc, week_start_at, p_now, spent_usd, skip_sync=True
455
+ )
456
+ confidence, reasons = _assess_forecast_confidence(elapsed_hours, p_now, len(samples))
457
+ target_24h = now_utc - dt.timedelta(hours=24)
458
+ has_sample_ge_24h = any(s[0] <= target_24h for s in samples)
459
+ if not has_sample_ge_24h:
460
+ reasons = list(reasons) + ["no_sample_ge_24h"]
461
+ confidence = "low"
462
+
463
+ return ForecastInputs(
464
+ now_utc=now_utc,
465
+ week_start_at=week_start_at,
466
+ week_end_at=week_end_at,
467
+ elapsed_hours=elapsed_hours,
468
+ elapsed_fraction=elapsed_fraction,
469
+ remaining_hours=remaining_hours,
470
+ remaining_days=remaining_days,
471
+ p_now=p_now,
472
+ five_hour_percent=five_hr,
473
+ spent_usd=spent_usd,
474
+ snapshot_count=len(samples),
475
+ latest_snapshot_at=latest[0],
476
+ p_24h_ago=p_24h_ago,
477
+ t_24h_actual_hours=t_24h,
478
+ dollars_per_percent=dpp,
479
+ dollars_per_percent_source=dpp_source,
480
+ confidence=confidence,
481
+ low_confidence_reasons=reasons,
482
+ )
483
+
484
+
485
+ @dataclass
486
+ class BudgetRow:
487
+ target_percent: int
488
+ pct_headroom: float | None # None when already past target
489
+ dollars_per_day: float | None
490
+ percent_per_day: float | None
491
+
492
+
493
+ @dataclass
494
+ class ForecastOutput:
495
+ inputs: ForecastInputs
496
+ r_avg: float # pct per hour, week-avg
497
+ r_recent: float | None # pct per hour, 24h recent; None if no prior sample
498
+ final_percent_low: float
499
+ final_percent_high: float
500
+ projected_cap: bool
501
+ already_capped: bool
502
+ cap_at: dt.datetime | None
503
+ budgets: list[BudgetRow]
504
+
505
+
506
+ def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOutput:
507
+ """Implements spec §2. targets are sorted desc for stable output (100, 90, …)."""
508
+ c = _cctally()
509
+ # Rate methods
510
+ r_avg = inputs.p_now / inputs.elapsed_hours if inputs.elapsed_hours > 0 else 0.0
511
+ if inputs.p_24h_ago is not None and inputs.t_24h_actual_hours:
512
+ r_recent: float | None = max(
513
+ 0.0, (inputs.p_now - inputs.p_24h_ago) / inputs.t_24h_actual_hours
514
+ )
515
+ else:
516
+ r_recent = None
517
+
518
+ # Projected final % — routed through the shared project_linear primitive
519
+ # (spec F1). r_recent is None ⇒ collapse to the average projection.
520
+ if r_recent is None:
521
+ final_low, final_high = c.project_linear(
522
+ inputs.p_now, inputs.remaining_hours, r_avg, r_avg
523
+ )
524
+ else:
525
+ a, b = c.project_linear(
526
+ inputs.p_now, inputs.remaining_hours, r_avg, r_recent
527
+ )
528
+ final_low, final_high = min(a, b), max(a, b)
529
+
530
+ already_capped = inputs.p_now >= 100.0
531
+ projected_cap = already_capped or final_high >= 100.0
532
+
533
+ cap_at: dt.datetime | None = None
534
+ if not already_capped and projected_cap:
535
+ r_pessimistic = max(r_avg, r_recent or 0.0)
536
+ if r_pessimistic > 0:
537
+ hours_to_cap = (100.0 - inputs.p_now) / r_pessimistic
538
+ if hours_to_cap < inputs.remaining_hours:
539
+ cap_at = inputs.now_utc + dt.timedelta(hours=hours_to_cap)
540
+
541
+ # Budgets
542
+ budgets: list[BudgetRow] = []
543
+ if not already_capped:
544
+ for t in sorted(targets, reverse=True):
545
+ headroom = t - inputs.p_now
546
+ if headroom <= 0 or inputs.remaining_days <= 0:
547
+ budgets.append(BudgetRow(target_percent=t, pct_headroom=None,
548
+ dollars_per_day=None, percent_per_day=None))
549
+ continue
550
+ dollars_day = (headroom * inputs.dollars_per_percent) / inputs.remaining_days
551
+ pct_day = headroom / inputs.remaining_days
552
+ budgets.append(BudgetRow(
553
+ target_percent=t,
554
+ pct_headroom=headroom,
555
+ dollars_per_day=dollars_day,
556
+ percent_per_day=pct_day,
557
+ ))
558
+
559
+ return ForecastOutput(
560
+ inputs=inputs,
561
+ r_avg=r_avg,
562
+ r_recent=r_recent,
563
+ final_percent_low=final_low,
564
+ final_percent_high=final_high,
565
+ projected_cap=projected_cap,
566
+ already_capped=already_capped,
567
+ cap_at=cap_at,
568
+ budgets=budgets,
569
+ )
570
+
571
+
572
+ def _parse_forecast_targets(raw: str) -> list[int]:
573
+ """Parse --targets '100,90' → [100, 90]. Validate 0 < n <= 200."""
574
+ out: list[int] = []
575
+ for tok in raw.split(","):
576
+ tok = tok.strip()
577
+ if not tok:
578
+ continue
579
+ try:
580
+ n = int(tok)
581
+ except ValueError as exc:
582
+ raise ValueError(f"invalid --targets token: {tok!r}") from exc
583
+ if not (0 < n <= 200):
584
+ raise ValueError(f"--targets value out of range: {n}")
585
+ out.append(n)
586
+ if not out:
587
+ raise ValueError("--targets produced no valid values")
588
+ return out
589
+
590
+
591
+ TOOL_VERSION = "forecast-v1" # Bumped on material JSON-schema changes.
592
+
593
+
594
+ def _iso_z(d: dt.datetime) -> str:
595
+ return d.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
596
+
597
+
598
+ def _build_forecast_json_payload(out: ForecastOutput) -> dict:
599
+ """Dict shape for the forecast JSON endpoint and for the dashboard
600
+ envelope's ``forecast.explain`` subtree (design spec §2.2).
601
+
602
+ Pure function — no I/O, no clock reads."""
603
+ i = out.inputs
604
+ payload = {
605
+ "week": {
606
+ "start_at": _iso_z(i.week_start_at),
607
+ "end_at": _iso_z(i.week_end_at),
608
+ "elapsed_hours": round(i.elapsed_hours, 3),
609
+ "elapsed_fraction": round(i.elapsed_fraction, 4),
610
+ "remaining_hours": round(i.remaining_hours, 3),
611
+ "remaining_days": round(i.remaining_days, 3),
612
+ },
613
+ "current": {
614
+ "weekly_percent": round(i.p_now, 3),
615
+ "five_hour_percent": (None if i.five_hour_percent is None
616
+ else round(i.five_hour_percent, 3)),
617
+ "spent_usd": round(i.spent_usd, 6),
618
+ "snapshot_count": i.snapshot_count,
619
+ "latest_snapshot_at": _iso_z(i.latest_snapshot_at),
620
+ },
621
+ "rates": {
622
+ "week_average_pct_per_hour": round(out.r_avg, 6),
623
+ "recent_24h_pct_per_hour": (None if out.r_recent is None
624
+ else round(out.r_recent, 6)),
625
+ "dollars_per_percent": round(i.dollars_per_percent, 6),
626
+ "dollars_per_percent_source": i.dollars_per_percent_source,
627
+ },
628
+ "forecast": {
629
+ "final_percent_low": round(out.final_percent_low, 3),
630
+ "final_percent_high": round(out.final_percent_high, 3),
631
+ "projected_cap": out.projected_cap,
632
+ "cap_at": (None if out.cap_at is None else _iso_z(out.cap_at)),
633
+ "already_capped": out.already_capped,
634
+ "confidence": i.confidence,
635
+ "low_confidence_reasons": list(i.low_confidence_reasons),
636
+ },
637
+ "budget": [
638
+ {
639
+ "target_percent": b.target_percent,
640
+ "pct_headroom": (None if b.pct_headroom is None
641
+ else round(b.pct_headroom, 3)),
642
+ "dollars_per_day":(None if b.dollars_per_day is None
643
+ else round(b.dollars_per_day, 6)),
644
+ "percent_per_day":(None if b.percent_per_day is None
645
+ else round(b.percent_per_day, 3)),
646
+ }
647
+ for b in out.budgets
648
+ ],
649
+ "meta": {
650
+ "generated_at": _iso_z(i.now_utc),
651
+ "tool_version": TOOL_VERSION,
652
+ },
653
+ }
654
+ return payload
655
+
656
+
657
+ def _emit_forecast_json(out: ForecastOutput) -> str:
658
+ return json.dumps(_build_forecast_json_payload(out), indent=2)
659
+
660
+
661
+ def _render_forecast_status_line(out: ForecastOutput, color: bool) -> str:
662
+ """Compact one-line status-line segment (spec §5)."""
663
+ c = _cctally()
664
+ def _c(s: str, code: str) -> str:
665
+ return c._style_ansi(s, code, color)
666
+
667
+ i = out.inputs
668
+ if out.already_capped:
669
+ return _c("\u26a0 CAPPED", "31") # red
670
+ if i.confidence == "low":
671
+ return _c("tracking\u2026", "2") # dim
672
+ low = out.final_percent_low
673
+ high = out.final_percent_high
674
+ low_disp = round(low)
675
+ high_disp = round(high)
676
+ pct_range = f"{low_disp}\u2013{high_disp}%"
677
+ if high_disp >= 100:
678
+ # Conservative (to-90%) budget for actionability.
679
+ budget = next((b for b in out.budgets if b.target_percent == 90), None)
680
+ if budget is None or budget.dollars_per_day is None:
681
+ budget_str = ""
682
+ else:
683
+ budget_str = f" ${budget.dollars_per_day:.2f}/d"
684
+ return _c(f"\u26a0 proj {pct_range}{budget_str}", "31") # red
685
+ if high_disp >= 90:
686
+ return _c(f"proj {pct_range}", "33") # yellow
687
+ return _c(f"proj {pct_range}", "36") # cyan
688
+
689
+
690
+ def _forecast_color_enabled(mode: str, stream) -> bool:
691
+ """Resolve --color {auto,always,never} + NO_COLOR. Returns bool."""
692
+ if mode == "never":
693
+ return False
694
+ if "NO_COLOR" in os.environ:
695
+ return False
696
+ if mode == "always":
697
+ return True
698
+ # auto
699
+ try:
700
+ return stream.isatty()
701
+ except (AttributeError, ValueError):
702
+ return False
703
+
704
+
705
+ def _render_forecast_progress_bar(
706
+ used: float, low: float, high: float,
707
+ width: int, unicode_ok: bool, color: bool,
708
+ ) -> list[str]:
709
+ """Return list of rendered lines: axis labels, ticks, bar, 100%-caption.
710
+
711
+ `width` is the usable character width inside the box (not counting outer
712
+ border + 2-col padding). scale = max(100, high) so >100% zone is visible.
713
+ """
714
+ c = _cctally()
715
+ scale = max(100.0, high)
716
+
717
+ def _pos(v: float) -> int:
718
+ return max(0, min(width, int(round((v / scale) * width))))
719
+
720
+ i_used = _pos(used)
721
+ i_low = _pos(low)
722
+ i_high = _pos(high)
723
+ i_100 = _pos(100.0)
724
+ # _pos() can return `width` exactly when v == scale (i.e., when high<=100
725
+ # so scale==100 and _pos(100)==width). Clamp for anything that INDEXES
726
+ # into a width-sized list (cap_row below). The `idx < i_X` comparisons
727
+ # in the bar loop work fine with an unclamped value.
728
+ i_100 = min(width - 1, i_100)
729
+
730
+ if unicode_ok:
731
+ glyph_used = "\u2588" # █
732
+ glyph_low = "\u2593" # ▓
733
+ glyph_gap = "\u2592" # ▒
734
+ glyph_over = "\u2588" # █ (red)
735
+ else:
736
+ glyph_used = "#"
737
+ glyph_low = "="
738
+ glyph_gap = "-"
739
+ glyph_over = "#"
740
+
741
+ bar_chars: list[str] = []
742
+ for idx in range(width):
743
+ if idx < i_used:
744
+ ch = c._style_ansi(glyph_used, "32", color) # green
745
+ elif idx < i_low:
746
+ ch = c._style_ansi(glyph_low, "33", color) # yellow
747
+ elif idx < i_high and idx < i_100:
748
+ ch = c._style_ansi(glyph_gap, "33", color) # yellow
749
+ elif idx < i_100:
750
+ ch = " "
751
+ elif idx < i_high:
752
+ ch = c._style_ansi(glyph_over, "31", color) # red: >100 zone
753
+ else:
754
+ ch = " "
755
+ bar_chars.append(ch)
756
+ bar_line = "".join(bar_chars)
757
+
758
+ # Axis: 0 25 50 75 100 >100 (when scale>100)
759
+ ticks = [0, 25, 50, 75, 100] + ([int(scale)] if scale > 100 else [])
760
+ axis_line = ["\u2500"] * width if unicode_ok else ["-"] * width
761
+ label_slots = [" "] * width
762
+ for t in ticks:
763
+ pos = _pos(t)
764
+ pos = min(width - 1, pos)
765
+ axis_line[pos] = ("\u253c" if unicode_ok else "+")
766
+ lbl = f"{t}%" if t > 0 else "0%"
767
+ # Left-justify the label starting at pos; if the label would run past
768
+ # the right edge (e.g. the 100% tick at width-1 would clip "100%" to
769
+ # "1"), left-shift the label so it fits within [0, width).
770
+ start = pos
771
+ if pos + len(lbl) > width:
772
+ start = max(0, width - len(lbl))
773
+ for j, c in enumerate(lbl):
774
+ if start + j < width:
775
+ label_slots[start + j] = c
776
+ axis_str = "".join(axis_line)
777
+ label_str = "".join(label_slots)
778
+
779
+ # 100% caption row: a "│" at i_100.
780
+ cap_row = [" "] * width
781
+ if 0 <= i_100 < width:
782
+ cap_row[i_100] = "\u2502" if unicode_ok else "|"
783
+ cap_str = "".join(cap_row)
784
+
785
+ return [label_str, axis_str, bar_line, cap_str]
786
+
787
+
788
+ def _render_forecast_terminal(out: "ForecastOutput", args, color: bool) -> str:
789
+ """Full box-frame terminal render (spec §4)."""
790
+ c = _cctally()
791
+ i = out.inputs
792
+ unicode_ok = c._supports_unicode_stdout()
793
+
794
+ # Outer frame width: 60 cols inner by default; expand up to terminal width.
795
+ try:
796
+ term_w = os.get_terminal_size().columns
797
+ except OSError:
798
+ term_w = 80
799
+ inner_w = max(56, min(78, term_w - 2))
800
+
801
+ def _box_top() -> str:
802
+ return ("\u256d" + "\u2500" * inner_w + "\u256e") if unicode_ok else ("+" + "-" * inner_w + "+")
803
+
804
+ def _box_bot() -> str:
805
+ return ("\u2570" + "\u2500" * inner_w + "\u256f") if unicode_ok else ("+" + "-" * inner_w + "+")
806
+
807
+ def _box_mid() -> str:
808
+ return ("\u251c" + "\u2500" * inner_w + "\u2524") if unicode_ok else ("+" + "-" * inner_w + "+")
809
+
810
+ def _row(text: str) -> str:
811
+ # Row width must match border width (inner_w + 2).
812
+ # Layout: border + " " + text + pad + border → 3 + text + pad == inner_w + 2,
813
+ # so pad = inner_w - 1 - len(text).
814
+ border = "\u2502" if unicode_ok else "|"
815
+ pad = inner_w - 1 - len(c._ANSI_ESC_RE.sub("", text))
816
+ pad = max(0, pad)
817
+ return border + " " + text + " " * pad + border
818
+
819
+ # ── Panel 1: title
820
+ # cmd_forecast attaches the resolved zone (ZoneInfo or None for "local")
821
+ # via args._resolved_tz. None means "host local"; bare astimezone() honors it.
822
+ # Times pass through format_display_dt so a zone-label suffix tells the user
823
+ # whether "Mon 5PM" is local or UTC \u2014 without the suffix, --tz handling was
824
+ # silent on the rendered terminal text.
825
+ tz_render = getattr(args, "_resolved_tz", None)
826
+ fmt_dt = lambda d: c.format_display_dt( # noqa: E731
827
+ d, tz_render, fmt="%a %-I%p", suffix=True
828
+ )
829
+ title = c._style_ansi("Subscription Forecast", "36", color)
830
+ # %b %-d sites carry suffix for symmetry with fmt_dt \u2014 Option A from the
831
+ # localize-datetime-display reviewer: "Replace each _localize().strftime()
832
+ # with format_display_dt(...suffix=True)".
833
+ subtitle = (f"Week {c.format_display_dt(i.week_start_at, tz_render, fmt='%b %-d', suffix=True)} "
834
+ f"\u2192 {c.format_display_dt(i.week_end_at, tz_render, fmt='%b %-d', suffix=True)} "
835
+ f"(resets {fmt_dt(i.week_end_at)}, {i.remaining_days:.1f}d remaining)")
836
+
837
+ # ── Panel 2: used / forecast / bar
838
+ used_line = f"Used {i.p_now:.1f}% ${i.spent_usd:.2f}"
839
+ if out.already_capped:
840
+ forecast_line = c._style_ansi(
841
+ f"\u26a0 CAPPED at {i.p_now:.1f}% \u2014 reset {fmt_dt(i.week_end_at)} "
842
+ f"({i.remaining_days:.1f}d)", "31", color)
843
+ elif i.confidence == "low":
844
+ reasons = ", ".join(i.low_confidence_reasons)
845
+ forecast_line = c._style_ansi(
846
+ f"\u26a0 LOW CONF \u2014 insufficient data ({reasons})", "33", color)
847
+ else:
848
+ low, high = out.final_percent_low, out.final_percent_high
849
+ low_rnd = round(low)
850
+ high_rnd = round(high)
851
+ high_disp = ">999%" if high > 999 else f"{high_rnd}%"
852
+ glyph_color = "31" if high_rnd >= 100 else ("33" if high_rnd >= 90 else "32")
853
+ warn = ""
854
+ if high_rnd >= 100:
855
+ warn = c._style_ansi(" \u26a0 may cap", "31", color)
856
+ elif high_rnd >= 90:
857
+ warn = c._style_ansi(" approaching 100%", "33", color)
858
+ forecast_line = c._style_ansi(
859
+ f"Forecast {low_rnd}%\u2013{high_disp}", glyph_color, color) + warn
860
+
861
+ bar_lines = _render_forecast_progress_bar(
862
+ used=i.p_now,
863
+ low=out.final_percent_low,
864
+ high=out.final_percent_high,
865
+ width=inner_w - 2,
866
+ unicode_ok=unicode_ok,
867
+ color=color,
868
+ )
869
+
870
+ # ── Panel 3: budget
871
+ budget_header = f"Daily budget \u2014 {i.remaining_days:.1f} days remaining"
872
+ budget_rows = []
873
+ for b in out.budgets:
874
+ if b.dollars_per_day is None:
875
+ budget_rows.append(f" to {b.target_percent:>3}% \u2014 past target")
876
+ else:
877
+ budget_rows.append(
878
+ f" to {b.target_percent:>3}% ${b.dollars_per_day:>6.2f}/day"
879
+ f" {b.percent_per_day:>5.1f}%/day")
880
+
881
+ # ── Footer
882
+ footer_bits = []
883
+ footer_bits.append(c._style_ansi(
884
+ f"rate source: {i.dollars_per_percent_source.replace('_', ' ')}", "2", color))
885
+ if out.cap_at is not None:
886
+ # format_display_dt: zone-label suffix disambiguates --tz vs host-local
887
+ # in the rendered footer (matches the reset-chip subtitle).
888
+ cap_str = c.format_display_dt(
889
+ out.cap_at, tz_render, fmt="%a %-I:%M%p", suffix=True,
890
+ )
891
+ footer_bits.append(c._style_ansi(f"\u26a0 projected cap: {cap_str}", "31", color))
892
+ footer = " ".join(footer_bits)
893
+
894
+ lines: list[str] = []
895
+ lines.append(_box_top())
896
+ lines.append(_row(title))
897
+ lines.append(_row(c._style_ansi(subtitle, "2", color)))
898
+ lines.append(_box_mid())
899
+ lines.append(_row(used_line))
900
+ lines.append(_row(forecast_line))
901
+ lines.append(_row(""))
902
+ for bl in bar_lines:
903
+ lines.append(_row(bl))
904
+ if not out.already_capped and i.confidence != "low":
905
+ lines.append(_box_mid())
906
+ lines.append(_row(budget_header))
907
+ lines.append(_row(""))
908
+ for br in budget_rows:
909
+ lines.append(_row(br))
910
+ lines.append(_box_bot())
911
+ lines.append(" " + footer)
912
+
913
+ # --explain footer
914
+ if getattr(args, "explain", False):
915
+ r_rec = "\u2014" if out.r_recent is None else f"{out.r_recent:.3f}%/h"
916
+ lines.append(c._style_ansi(
917
+ f" r_avg={out.r_avg:.3f}%/h \u00b7 r_recent={r_rec} \u00b7 "
918
+ f"{i.snapshot_count} snapshots \u00b7 $/1% source={i.dollars_per_percent_source}",
919
+ "2", color))
920
+
921
+ return "\n".join(lines)
922
+
923
+
924
+ def cmd_report(args: argparse.Namespace) -> int:
925
+ c = _cctally()
926
+ c._share_validate_args(args)
927
+ if args.sync_current:
928
+ sync_ns = argparse.Namespace(
929
+ week_start=None,
930
+ week_end=None,
931
+ week_start_name=args.week_start_name,
932
+ mode=args.mode,
933
+ offline=args.offline,
934
+ project=args.project,
935
+ json=False,
936
+ quiet=True,
937
+ )
938
+ c.cmd_sync_week(sync_ns)
939
+
940
+ config = c.load_config()
941
+ tz = c.resolve_display_tz(args, config)
942
+ args._resolved_tz = tz
943
+ week_start_name = get_week_start_name(config, args.week_start_name)
944
+
945
+ conn = open_db()
946
+ try:
947
+ latest_usage = conn.execute(
948
+ """
949
+ SELECT *
950
+ FROM weekly_usage_snapshots
951
+ ORDER BY captured_at_utc DESC, id DESC
952
+ LIMIT 1
953
+ """
954
+ ).fetchone()
955
+ if latest_usage is not None:
956
+ date_str = latest_usage["week_start_date"]
957
+ canon_start, canon_end = c._get_canonical_boundary_for_date(conn, date_str)
958
+ current_ref = make_week_ref(
959
+ week_start_date=date_str,
960
+ week_end_date=latest_usage["week_end_date"],
961
+ week_start_at=canon_start,
962
+ week_end_at=canon_end,
963
+ )
964
+ else:
965
+ now_local = dt.datetime.now().astimezone(tz)
966
+ current_start, current_end = compute_week_bounds(now_local, week_start_name)
967
+ current_ref = make_week_ref(
968
+ week_start_date=current_start.isoformat(),
969
+ week_end_date=current_end.isoformat(),
970
+ )
971
+
972
+ # Bug D (v1.7.2 round-4): when an in-place credit event exists for
973
+ # the current subscription week, `_apply_reset_events_to_weekrefs`
974
+ # synthesizes a pre-credit ref alongside the post-credit one (both
975
+ # share `WeekRef.key`). The live "current week" segment is the
976
+ # POST-credit one (its `week_start_at` was shifted to the
977
+ # effective reset moment). Route `current_ref` through the same
978
+ # override so its `week_start_at` reflects the post-credit start;
979
+ # this lets the per-row match below disambiguate the synthesized
980
+ # pre-credit ref from the live post-credit ref via both
981
+ # `key` AND `week_start_at`. Order contract from
982
+ # `_apply_reset_events_to_weekrefs`: post-credit ref lands at
983
+ # index 0, pre-credit at index 1. Non-credit weeks return the
984
+ # single input ref unchanged, so this is a no-op on the common
985
+ # path.
986
+ _adjusted_current = c._apply_reset_events_to_weekrefs(conn, [current_ref])
987
+ if _adjusted_current:
988
+ current_ref = _adjusted_current[0]
989
+
990
+ weeks = c.get_recent_weeks(conn, max(1, args.weeks))
991
+ if not weeks:
992
+ # Format-aware empty path mirrors cmd_forecast:18578-18629 — a
993
+ # fresh install requesting `report --format html` should emit a
994
+ # uniformly-shaped artifact, not a free-form "No data yet"
995
+ # sentence the share consumer can't parse.
996
+ if getattr(args, "format", None):
997
+ display_tz_str = c._share_display_tz_label(tz)
998
+ # Anchor the period_label on the current subscription
999
+ # week so the artifact's subtitle is meaningful (the
1000
+ # week the report WOULD describe if data existed).
1001
+ # `_command_as_of()` honors CCTALLY_AS_OF — keeps the
1002
+ # period_label coherent with `generated_at` (which goes
1003
+ # through `_share_now_utc` from the same env hook) so
1004
+ # fixture goldens don't drift when the harness host's
1005
+ # wall-clock day rolls past CCTALLY_AS_OF.
1006
+ now_local = _command_as_of().astimezone(tz)
1007
+ local_tz = now_local.tzinfo
1008
+ ws_d, we_d = compute_week_bounds(now_local, week_start_name)
1009
+ ws_dt = dt.datetime.combine(
1010
+ ws_d, dt.time.min, tzinfo=local_tz
1011
+ )
1012
+ we_dt = dt.datetime.combine(
1013
+ we_d + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
1014
+ )
1015
+ snap = c._build_report_snapshot(
1016
+ c.TrendView(),
1017
+ period_start=ws_dt,
1018
+ period_end=we_dt,
1019
+ display_tz=display_tz_str,
1020
+ version=c._share_resolve_version(),
1021
+ theme=args.theme,
1022
+ reveal_projects=args.reveal_projects,
1023
+ )
1024
+ c._share_render_and_emit(snap, args)
1025
+ return 0
1026
+ if args.json:
1027
+ print(json.dumps({"current": None, "trend": []}, indent=2))
1028
+ else:
1029
+ print("No data yet. Add record-usage to your status line script (see record-usage --help).")
1030
+ return 0
1031
+
1032
+ # Build the unified trend view (spec §5.4). `build_trend_view`
1033
+ # owns the per-row construction, including:
1034
+ # - get_latest_usage_for_week with split-key as_of_utc pinning
1035
+ # for credited weeks (Bug D / round-3 Bug B parity)
1036
+ # - _week_ref_has_reset_event → _compute_cost_for_weekref bypass
1037
+ # for reset-affected weeks
1038
+ # - freshness sub-dict derivation
1039
+ # - 3-sample-rule average
1040
+ # Note: build_trend_view returns rows oldest-first (chronological);
1041
+ # cmd_report's JSON contract is newest-first to mirror
1042
+ # get_recent_weeks's order — we reverse below.
1043
+ view = c.build_trend_view(conn, now_utc=_command_as_of(), n=args.weeks,
1044
+ display_tz=tz)
1045
+ # Serialize TuiTrendRow → today's camelCase keys. Order:
1046
+ # newest-first (matches the prior cmd_report behavior).
1047
+ # Map week_start_date → original WeekRef ISO strings so the
1048
+ # JSON serialization preserves the snapshot-stored tz format
1049
+ # (`+00:00` for UTC-anchored weeks) — TuiTrendRow's datetime
1050
+ # form re-localizes via parse_iso_datetime, which would emit
1051
+ # `+03:00` on a UTC+3 host and break byte-stability.
1052
+ #
1053
+ # Index by ``(week_start_date_iso, week_start_at_utc_instant)``
1054
+ # so ``_row_to_dict`` resolves a row's original WeekRef ISO
1055
+ # strings in O(1) — credited weeks share ``week_start_date`` so
1056
+ # the UTC-instant disambiguates them. The lookup key matches the
1057
+ # row-side derivation in ``_row_to_dict`` (UTC instant from the
1058
+ # parsed datetime).
1059
+ week_iso_by_key: dict[tuple[str, dt.datetime],
1060
+ tuple[str | None, str | None]] = {}
1061
+ for wr in weeks:
1062
+ if wr.week_start_at is None:
1063
+ continue
1064
+ try:
1065
+ wr_utc = parse_iso_datetime(
1066
+ wr.week_start_at, "wr.week_start_at",
1067
+ ).astimezone(dt.timezone.utc)
1068
+ except ValueError:
1069
+ continue
1070
+ week_iso_by_key[(wr.week_start.isoformat(), wr_utc)] = (
1071
+ wr.week_start_at,
1072
+ wr.week_end_at,
1073
+ )
1074
+
1075
+ def _row_to_dict(r):
1076
+ # Match this row's WeekRef by (week_start_date, UTC instant
1077
+ # of parsed week_start_at) — credited weeks share
1078
+ # week_start_date so we disambiguate via the UTC instant.
1079
+ wsd_str = (
1080
+ r.week_start_date.isoformat() if r.week_start_date else None
1081
+ )
1082
+ ws_at = ws_at_end = None
1083
+ if wsd_str is not None and r.week_start_at is not None:
1084
+ r_utc = r.week_start_at.astimezone(dt.timezone.utc)
1085
+ hit = week_iso_by_key.get((wsd_str, r_utc))
1086
+ if hit is not None:
1087
+ ws_at, ws_at_end = hit
1088
+
1089
+ d: dict[str, Any] = {
1090
+ "weekStartDate": wsd_str,
1091
+ "weekEndDate": (
1092
+ r.week_end_date.isoformat() if r.week_end_date else None
1093
+ ),
1094
+ "weekStartAt": ws_at,
1095
+ "weekEndAt": ws_at_end,
1096
+ "weeklyPercent": r.used_pct,
1097
+ "weeklyCostUSD": (
1098
+ round(r.weekly_cost_usd, 9)
1099
+ if r.weekly_cost_usd is not None else None
1100
+ ),
1101
+ "dollarsPerPercent": (
1102
+ round(r.dollars_per_percent, 9)
1103
+ if r.dollars_per_percent is not None else None
1104
+ ),
1105
+ "usageCapturedAt": r.usage_captured_at,
1106
+ "costCapturedAt": r.cost_captured_at,
1107
+ "asOf": r.as_of,
1108
+ "rangeStartIso": r.range_start_iso,
1109
+ "rangeEndIso": r.range_end_iso,
1110
+ }
1111
+ if r.freshness:
1112
+ d["freshness"] = r.freshness
1113
+ return d
1114
+
1115
+ # view.rows is oldest-first; reverse for cmd_report's newest-first
1116
+ # JSON contract. Also need WeekRef-based current_row matching —
1117
+ # use weekRef key + week_start_at to disambiguate credited weeks.
1118
+ # We re-walk the original `weeks` list to map (key, week_start_at)
1119
+ # → the corresponding dict row.
1120
+ trend: list[dict[str, Any]] = []
1121
+ current_row: dict[str, Any] | None = None
1122
+ # `view.rows` order = chrono asc (oldest first). Build trend in
1123
+ # the reverse order (newest first) to match the historical
1124
+ # cmd_report contract.
1125
+ # The view's TuiTrendRow doesn't carry WeekRef.key directly; we
1126
+ # use (week_start_date, week_start_at) for the match — week_start_at
1127
+ # in TuiTrendRow is a parsed datetime, and current_ref carries
1128
+ # ISO strings.
1129
+ for r in reversed(view.rows):
1130
+ row = _row_to_dict(r)
1131
+ trend.append(row)
1132
+ # Match against current_ref. Compare by week_start ISO date
1133
+ # AND week_start_at ISO string.
1134
+ week_start_at_iso = row["weekStartAt"]
1135
+ if (
1136
+ r.week_start_date is not None
1137
+ and r.week_start_date.isoformat() == current_ref.key
1138
+ and week_start_at_iso == current_ref.week_start_at
1139
+ ):
1140
+ current_row = row
1141
+
1142
+ if current_row is None and trend:
1143
+ current_row = trend[0]
1144
+
1145
+ output = {
1146
+ "current": current_row,
1147
+ "trend": trend,
1148
+ "weekStartRule": week_start_name,
1149
+ "generatedAt": now_utc_iso(),
1150
+ "currentWeek": {
1151
+ "weekStartDate": current_ref.week_start.isoformat(),
1152
+ "weekEndDate": current_ref.week_end.isoformat() if current_ref.week_end else None,
1153
+ "weekStartAt": current_ref.week_start_at,
1154
+ "weekEndAt": current_ref.week_end_at,
1155
+ },
1156
+ }
1157
+
1158
+ if args.detail:
1159
+ milestone_rows = c.get_milestones_for_week(conn, current_ref.week_start.isoformat())
1160
+ output["milestones"] = [
1161
+ {
1162
+ "percentThreshold": int(m["percent_threshold"]),
1163
+ "cumulativeCostUSD": round(float(m["cumulative_cost_usd"]), 9),
1164
+ "marginalCostUSD": round(float(m["marginal_cost_usd"]), 9) if m["marginal_cost_usd"] is not None else None,
1165
+ "capturedAt": m["captured_at_utc"],
1166
+ }
1167
+ for m in milestone_rows
1168
+ ]
1169
+
1170
+ # Shareable-reports gate: --format short-circuits the terminal/JSON
1171
+ # paths. The mutex in `_add_share_args` guarantees --format and
1172
+ # --json are not both set, so checking --format first is unambiguous.
1173
+ # Snapshot rows are reversed to ascending chronological order so
1174
+ # the line chart trends left->right with time (`get_recent_weeks`
1175
+ # returns newest-first; `trend` mirrors that order).
1176
+ if getattr(args, "format", None):
1177
+ # Note: --detail is a no-op under --format (snapshot focuses on
1178
+ # the headline weekly-trend table + chart; per-percent milestone
1179
+ # detail isn't in the share spec scope). Same convention applies
1180
+ # to other share-enabled subcommands (cmd_daily's --breakdown,
1181
+ # etc.).
1182
+ #
1183
+ # `view.rows` is already chronological (oldest-first), the
1184
+ # order the chart needs. period_start / period_end derived
1185
+ # from the view's oldest / newest rows.
1186
+ if view.rows:
1187
+ first_r = view.rows[0]
1188
+ last_r = view.rows[-1]
1189
+ first_wsd = (
1190
+ first_r.week_start_date.isoformat()
1191
+ if first_r.week_start_date else None
1192
+ )
1193
+ last_wed = (
1194
+ last_r.week_end_date.isoformat()
1195
+ if last_r.week_end_date else first_wsd
1196
+ )
1197
+ period_start = c._share_parse_date_to_dt(first_wsd, tz)
1198
+ period_end = c._share_parse_date_to_dt(last_wed, tz)
1199
+ else:
1200
+ period_start = period_end = c._share_now_utc()
1201
+ display_tz_str = c._share_display_tz_label(tz)
1202
+ snap = c._build_report_snapshot(
1203
+ view,
1204
+ period_start=period_start,
1205
+ period_end=period_end,
1206
+ display_tz=display_tz_str,
1207
+ version=c._share_resolve_version(),
1208
+ theme=args.theme,
1209
+ reveal_projects=args.reveal_projects,
1210
+ )
1211
+ c._share_render_and_emit(snap, args)
1212
+ return 0
1213
+
1214
+ if args.json:
1215
+ print(json.dumps(output, indent=2))
1216
+ return 0
1217
+
1218
+ if current_row is not None:
1219
+ week_window = c._format_week_window(
1220
+ current_row.get("weekStartDate"),
1221
+ current_row.get("weekEndDate"),
1222
+ current_row.get("weekStartAt"),
1223
+ current_row.get("weekEndAt"),
1224
+ tz=tz,
1225
+ )
1226
+ wp = current_row["weeklyPercent"]
1227
+ wc = current_row["weeklyCostUSD"]
1228
+ dpp = current_row["dollarsPerPercent"]
1229
+ print(
1230
+ c._boxed_table(
1231
+ ["Week Window", "Usage %", "Cost USD", "$ / 1%"],
1232
+ [[
1233
+ week_window,
1234
+ f"{wp:.2f}%" if wp is not None else "n/a",
1235
+ f"${wc:.6f}" if wc is not None else "n/a",
1236
+ f"${dpp:.6f}" if dpp is not None else "n/a",
1237
+ ]],
1238
+ ["left", "right", "right", "right"],
1239
+ )
1240
+ )
1241
+ print()
1242
+
1243
+ print("Trend:")
1244
+ headers = [
1245
+ "#",
1246
+ "Week Window",
1247
+ "Usage %",
1248
+ "Cost USD",
1249
+ "$ / 1%",
1250
+ "As Of",
1251
+ "Usage Captured",
1252
+ "Cost Captured",
1253
+ ]
1254
+ display_trend = sorted(
1255
+ trend,
1256
+ key=c._trend_row_recency_seconds,
1257
+ reverse=True,
1258
+ )
1259
+ table_rows: list[list[str]] = []
1260
+ for idx, row in enumerate(display_trend, start=1):
1261
+ percent = "n/a" if row["weeklyPercent"] is None else f"{row['weeklyPercent']:.2f}%"
1262
+ cost = "n/a" if row["weeklyCostUSD"] is None else f"${row['weeklyCostUSD']:.6f}"
1263
+ dpp = "n/a" if row["dollarsPerPercent"] is None else f"${row['dollarsPerPercent']:.6f}"
1264
+ week_window = c._format_week_window(
1265
+ row.get("weekStartDate"),
1266
+ row.get("weekEndDate"),
1267
+ row.get("weekStartAt"),
1268
+ row.get("weekEndAt"),
1269
+ tz=tz,
1270
+ )
1271
+ table_rows.append(
1272
+ [
1273
+ str(idx),
1274
+ week_window,
1275
+ percent,
1276
+ cost,
1277
+ dpp,
1278
+ c._format_ts_compact(row.get("asOf"), tz=tz),
1279
+ c._format_ts_compact(row.get("usageCapturedAt"), tz=tz),
1280
+ c._format_ts_compact(row.get("costCapturedAt"), tz=tz),
1281
+ ]
1282
+ )
1283
+
1284
+ print(
1285
+ c._boxed_table(
1286
+ headers,
1287
+ table_rows,
1288
+ aligns=[
1289
+ "right",
1290
+ "left",
1291
+ "right",
1292
+ "right",
1293
+ "right",
1294
+ "left",
1295
+ "left",
1296
+ "left",
1297
+ ],
1298
+ color_header=True,
1299
+ )
1300
+ )
1301
+
1302
+ if args.detail:
1303
+ milestone_rows = c.get_milestones_for_week(conn, current_ref.week_start.isoformat())
1304
+ if milestone_rows:
1305
+ print()
1306
+ print("Percent breakdown (current week):\n")
1307
+ m_headers = ["#", "Threshold", "Cumulative Cost", "Marginal Cost"]
1308
+ m_rows: list[list[str]] = []
1309
+ for idx, m in enumerate(milestone_rows, start=1):
1310
+ pct = f"{int(m['percent_threshold'])}%"
1311
+ cum = f"${float(m['cumulative_cost_usd']):.6f}"
1312
+ marg = f"${float(m['marginal_cost_usd']):.6f}" if m["marginal_cost_usd"] is not None else "n/a"
1313
+ m_rows.append([str(idx), pct, cum, marg])
1314
+ print(c._boxed_table(m_headers, m_rows, ["right", "right", "right", "right"]))
1315
+
1316
+ return 0
1317
+ finally:
1318
+ conn.close()
1319
+
1320
+
1321
+ def cmd_forecast(args: argparse.Namespace) -> int:
1322
+ """Project current-week usage to reset boundary. Emit terminal report,
1323
+ JSON, or status-line one-liner. See docs/commands/forecast.md.
1324
+ """
1325
+ c = _cctally()
1326
+ c._share_validate_args(args)
1327
+ if args.json and args.status_line:
1328
+ print("forecast: --json and --status-line are mutually exclusive",
1329
+ file=sys.stderr)
1330
+ return 1
1331
+
1332
+ # Resolve display tz via the unified --tz / config.display.tz pipeline.
1333
+ # The renderer reads it back from args._resolved_tz.
1334
+ config = c.load_config()
1335
+ args._resolved_tz = c.resolve_display_tz(args, config)
1336
+
1337
+ try:
1338
+ targets = _parse_forecast_targets(args.targets)
1339
+ except ValueError as exc:
1340
+ print(f"forecast: {exc}", file=sys.stderr)
1341
+ return 1
1342
+
1343
+ now_utc = _resolve_forecast_now(args.as_of)
1344
+ conn = open_db()
1345
+ # Cache sync is gated inside get_entries(..., skip_sync=args.no_sync); no
1346
+ # sync_cache(conn) here — that prior call ran on the stats DB connection
1347
+ # (wrong conn) and was a no-op for the real cache anyway.
1348
+
1349
+ # Route through ``build_forecast_view`` (issue #57). The View is the
1350
+ # kernel-pattern wrapper; ``view.output`` carries the existing
1351
+ # ``ForecastOutput`` math result so every downstream renderer here
1352
+ # (text / JSON / status-line / share) reuses the same projection,
1353
+ # verdict, budgets, and per-method rate fields without recomputing.
1354
+ view = c.build_forecast_view(
1355
+ conn, now_utc=now_utc, targets=tuple(targets),
1356
+ skip_sync=args.no_sync, display_tz=args._resolved_tz,
1357
+ )
1358
+ inputs = view.output.inputs if view.output is not None else None
1359
+ if inputs is None:
1360
+ # No snapshot for the current week.
1361
+ if getattr(args, "format", None):
1362
+ # Shareable-reports empty-data path: emit a "no data" snapshot
1363
+ # rather than a free-form text message so consumers of the share
1364
+ # output (md / html / svg) get a uniformly-shaped artifact.
1365
+ #
1366
+ # Compute the real subscription-week boundaries from config
1367
+ # rather than collapsing to a 0-duration `now → now` window —
1368
+ # the period_label in the artifact's subtitle is meaningful
1369
+ # (the week the forecast WOULD describe if data existed).
1370
+ # Lift the `dt.date` boundaries from `compute_week_bounds`
1371
+ # to tz-aware datetimes anchored on local midnight so the
1372
+ # PeriodSpec stays consistent with sibling builders.
1373
+ tz = getattr(args, "_resolved_tz", None)
1374
+ display_tz_str = c._share_display_tz_label(tz)
1375
+ week_start_name = get_week_start_name(
1376
+ config, getattr(args, "week_start_name", None)
1377
+ )
1378
+ ws_date, we_date = compute_week_bounds(now_utc, week_start_name)
1379
+ # internal fallback: host-local intentional
1380
+ local_tz = dt.datetime.now().astimezone().tzinfo
1381
+ week_start_dt = dt.datetime.combine(
1382
+ ws_date, dt.time.min, tzinfo=local_tz
1383
+ )
1384
+ week_end_dt = dt.datetime.combine(
1385
+ we_date + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
1386
+ )
1387
+ # Pass `low_conf=False` + explicit notes: the issue is "no data
1388
+ # recorded yet," not "thin data." LOW CONF would mislead the
1389
+ # reader into thinking a projection ran with sparse samples.
1390
+ snap = c._build_forecast_snapshot(
1391
+ week_start=week_start_dt,
1392
+ week_end=week_end_dt,
1393
+ display_tz=display_tz_str,
1394
+ version=c._share_resolve_version(),
1395
+ theme=args.theme,
1396
+ reveal_projects=args.reveal_projects,
1397
+ actual_series=[],
1398
+ projected_series=[],
1399
+ current_pct=0.0,
1400
+ projected_low_pct=0.0,
1401
+ projected_high_pct=0.0,
1402
+ days_remaining=0.0,
1403
+ dollars_per_percent=0.0,
1404
+ dollars_per_percent_source="this_week",
1405
+ low_conf=False,
1406
+ notes=(
1407
+ "No snapshots recorded for this week yet — run "
1408
+ "cctally record-usage to populate.",
1409
+ ),
1410
+ )
1411
+ c._share_render_and_emit(snap, args)
1412
+ return 0
1413
+ if args.json:
1414
+ print(json.dumps({
1415
+ "error": "no_current_week_data",
1416
+ "meta": {"generated_at": _iso_z(now_utc), "tool_version": TOOL_VERSION},
1417
+ }, indent=2))
1418
+ elif args.status_line:
1419
+ pass # silent segment
1420
+ else:
1421
+ print("forecast: no data for current week yet")
1422
+ return 0
1423
+
1424
+ output = view.output
1425
+
1426
+ # Shareable-reports gate: --format short-circuits the JSON / status-line /
1427
+ # terminal dispatch via `_share_render_and_emit`. The mutex in
1428
+ # `_add_share_args(has_status_line=True)` keeps `--format`, `--json`, and
1429
+ # `--status-line` from coexisting. The gate fires AFTER ``build_forecast_view``
1430
+ # so the snapshot reuses the same projection math as the terminal/JSON
1431
+ # paths — no parallel computation.
1432
+ if getattr(args, "format", None):
1433
+ i = output.inputs
1434
+ # Re-fetch the samples for the LineChart's actual_series. The
1435
+ # `_load_forecast_inputs` path dropped them after deriving p_now /
1436
+ # p_24h_ago / snapshot_count; re-running `_fetch_current_week_snapshots`
1437
+ # is a single indexed query against `weekly_usage_snapshots` and only
1438
+ # fires when `--format` is requested, so the cost is bounded.
1439
+ # `_apply_midweek_reset_override` is replayed so the chart axis
1440
+ # matches the (possibly-shifted) week_start_at carried by `inputs`.
1441
+ fetched = _fetch_current_week_snapshots(conn, now_utc)
1442
+ actual_series: list[tuple[str, float, float]] = []
1443
+ if fetched is not None:
1444
+ _ws_at, _we_at, raw_samples = fetched
1445
+ _ws_at_shifted, samples = _apply_midweek_reset_override(
1446
+ conn, _ws_at, _we_at, raw_samples
1447
+ )
1448
+ tz_render = getattr(args, "_resolved_tz", None)
1449
+ for cap_at, pct, _five_hr in samples:
1450
+ elapsed_h = (
1451
+ (cap_at - i.week_start_at).total_seconds() / 3600.0
1452
+ )
1453
+ lbl = c.format_display_dt(
1454
+ cap_at, tz_render, fmt="%a %H:%M", suffix=False,
1455
+ )
1456
+ actual_series.append((lbl, elapsed_h, float(pct)))
1457
+ # Projected series: a 2-point ray from (now, p_now) to
1458
+ # (week_end, projected_high). The high-end matches the terminal
1459
+ # render's "may cap" warning so the chart and table tell the same
1460
+ # story. When `already_capped` is true the ray collapses to a flat
1461
+ # horizontal line at p_now from (now → week_end) — visually
1462
+ # signals "you are pinned at the cap; no further growth expected"
1463
+ # instead of the visually-empty (no-projection) chart that was
1464
+ # confusable with "no projection computed."
1465
+ projected_series: list[tuple[str, float, float]] = []
1466
+ if i.remaining_hours > 0:
1467
+ tz_render = getattr(args, "_resolved_tz", None)
1468
+ now_label = c.format_display_dt(
1469
+ i.now_utc, tz_render, fmt="%a %H:%M", suffix=False,
1470
+ )
1471
+ end_label = c.format_display_dt(
1472
+ i.week_end_at, tz_render, fmt="%a %H:%M", suffix=False,
1473
+ )
1474
+ now_x = (i.now_utc - i.week_start_at).total_seconds() / 3600.0
1475
+ end_x = (i.week_end_at - i.week_start_at).total_seconds() / 3600.0
1476
+ if output.already_capped:
1477
+ # Flat ray: y stays at p_now across the remaining window.
1478
+ projected_series.append(
1479
+ (now_label, now_x, float(i.p_now))
1480
+ )
1481
+ projected_series.append(
1482
+ (end_label, end_x, float(i.p_now))
1483
+ )
1484
+ else:
1485
+ projected_series.append(
1486
+ (now_label, now_x, float(i.p_now))
1487
+ )
1488
+ projected_series.append(
1489
+ (end_label, end_x, float(output.final_percent_high))
1490
+ )
1491
+ display_tz_str = c._share_display_tz_label(
1492
+ getattr(args, "_resolved_tz", None)
1493
+ )
1494
+ snap = c._build_forecast_snapshot(
1495
+ week_start=i.week_start_at,
1496
+ week_end=i.week_end_at,
1497
+ display_tz=display_tz_str,
1498
+ version=c._share_resolve_version(),
1499
+ theme=args.theme,
1500
+ reveal_projects=args.reveal_projects,
1501
+ actual_series=actual_series,
1502
+ projected_series=projected_series,
1503
+ current_pct=float(i.p_now),
1504
+ projected_low_pct=float(output.final_percent_low),
1505
+ projected_high_pct=float(output.final_percent_high),
1506
+ days_remaining=float(i.remaining_days),
1507
+ dollars_per_percent=float(i.dollars_per_percent),
1508
+ dollars_per_percent_source=i.dollars_per_percent_source,
1509
+ low_conf=(i.confidence == "low"),
1510
+ )
1511
+ c._share_render_and_emit(snap, args)
1512
+ return 0
1513
+
1514
+ if args.json:
1515
+ print(_emit_forecast_json(output))
1516
+ return 0
1517
+ if args.status_line:
1518
+ # --status-line is invoked via $(cmd 2>/dev/null) by design — stdout is
1519
+ # a pipe and stderr is /dev/null, so auto-TTY detection always sees
1520
+ # non-interactive. Promote auto -> always here; NO_COLOR and explicit
1521
+ # `--color never` still disable (both handled inside _forecast_color_enabled).
1522
+ effective_mode = "always" if args.color == "auto" else args.color
1523
+ color = _forecast_color_enabled(effective_mode, sys.stdout)
1524
+ print(_render_forecast_status_line(output, color))
1525
+ return 0
1526
+
1527
+ color = _forecast_color_enabled(args.color, sys.stdout)
1528
+ print(_render_forecast_terminal(output, args, color))
1529
+ return 0
1530
+
1531
+
1532
+ # ── budget ──────────────────────────────────────────────────────────────
1533
+ # `cctally budget` — weekly equivalent-$ budget + pace + spend alerts.
1534
+ # cctally-original (NOT a ccusage drop-in) → flat surface only, no
1535
+ # claude/codex subgroup. Status reads live spend; `set`/`unset` write the
1536
+ # DEFAULT config (F4 — the path the alert firing in Task 3 reads). See
1537
+ # docs/commands/budget.md + spec §4/§6.
1538
+
1539
+ _BUDGET_JSON_SCHEMA_VERSION = 1
1540
+
1541
+
1542
+ def cmd_budget(args: argparse.Namespace) -> int:
1543
+ """Dispatch `cctally budget [set AMOUNT | unset]`. See docs/commands/budget.md."""
1544
+ c = _cctally()
1545
+ action = getattr(args, "action", None)
1546
+
1547
+ # F4: mutations always target the DEFAULT config; --config is read-only.
1548
+ # --format is a status-only render surface — reject it on set/unset.
1549
+ if action in {"set", "unset"} and getattr(args, "config", None):
1550
+ eprint(
1551
+ "cctally budget: --config is read-only; "
1552
+ "set/unset always write the default config"
1553
+ )
1554
+ return 2
1555
+ if action in {"set", "unset"} and getattr(args, "format", None):
1556
+ eprint("cctally budget: --format is not valid with set/unset")
1557
+ return 2
1558
+
1559
+ if action == "set":
1560
+ return _cmd_budget_set(args)
1561
+ if action == "unset":
1562
+ return _cmd_budget_unset(args)
1563
+
1564
+ # ── bare status ──
1565
+ # Early reject of bad share-flag combos BEFORE any DB/sync work
1566
+ # (mirrors cmd_forecast; calls sys.exit(2) directly inside).
1567
+ c._share_validate_args(args)
1568
+ config = c._load_claude_config_for_args(args) # honors --config read-only
1569
+ args._resolved_tz = c.resolve_display_tz(args, config)
1570
+ try:
1571
+ budget_cfg = _get_budget_config(config)
1572
+ except _BudgetConfigError as exc:
1573
+ eprint(f"cctally budget: {exc}")
1574
+ return 2
1575
+ target = budget_cfg["weekly_usd"]
1576
+ if target is None:
1577
+ return _budget_render_unset(args) # exit 0, friendly message
1578
+
1579
+ now_utc = _command_as_of() # honors the CCTALLY_AS_OF testing hook
1580
+ conn = open_db()
1581
+ inputs = _build_budget_status_inputs(
1582
+ conn,
1583
+ target_usd=target,
1584
+ now_utc=now_utc,
1585
+ alert_thresholds=budget_cfg["alert_thresholds"],
1586
+ )
1587
+ if inputs is None:
1588
+ # No usage snapshot yet → no resolvable week window (spec §6 worst case).
1589
+ if getattr(args, "format", None):
1590
+ snap = _build_budget_no_data_snapshot(args, budget_cfg, now_utc)
1591
+ c._share_render_and_emit(snap, args)
1592
+ return 0
1593
+ if getattr(args, "json", False):
1594
+ print(json.dumps({
1595
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
1596
+ "status": "no_data",
1597
+ "weekly_usd": target,
1598
+ }))
1599
+ return 0
1600
+ print(f"Weekly budget: ${target:,.2f} — no usage data yet this week.")
1601
+ return 0
1602
+
1603
+ status = c.compute_budget_status(inputs)
1604
+ if getattr(args, "format", None):
1605
+ snap = _build_budget_snapshot(args, budget_cfg, inputs, status)
1606
+ c._share_render_and_emit(snap, args)
1607
+ return 0
1608
+ if getattr(args, "json", False):
1609
+ return _budget_emit_json(budget_cfg, inputs, status)
1610
+ return _budget_render_terminal(args, budget_cfg, inputs, status)
1611
+
1612
+
1613
+ def _cmd_budget_set(args: argparse.Namespace) -> int:
1614
+ """`cctally budget set AMOUNT` — write `budget.weekly_usd`, preserving the
1615
+ other budget keys. Writes the DEFAULT config (F4). Task 3 appends the
1616
+ forward-only milestone reconcile here."""
1617
+ c = _cctally()
1618
+ raw = getattr(args, "amount", None)
1619
+ if raw is None:
1620
+ eprint("cctally budget: `set` requires an amount, e.g. cctally budget set 300")
1621
+ return 2
1622
+ try:
1623
+ amount = float(raw)
1624
+ except (TypeError, ValueError):
1625
+ eprint(f"cctally budget: amount must be a positive number, got {raw!r}")
1626
+ return 2
1627
+ if not math.isfinite(amount) or amount <= 0:
1628
+ eprint(f"cctally budget: amount must be a positive finite number, got {raw!r}")
1629
+ return 2
1630
+
1631
+ # Read-modify-write under config_writer_lock + _load_config_unlocked
1632
+ # (load_config inside the lock self-deadlocks — fcntl.flock is per-fd).
1633
+ # Re-validate the merged block via _get_budget_config before persisting.
1634
+ with c.config_writer_lock():
1635
+ config = c._load_config_unlocked()
1636
+ existing = config.get("budget")
1637
+ if existing is not None and not isinstance(existing, dict):
1638
+ eprint("cctally budget: budget config must be an object")
1639
+ return 2
1640
+ block = dict(existing or {})
1641
+ block["weekly_usd"] = amount
1642
+ config["budget"] = block
1643
+ try:
1644
+ validated = _get_budget_config(config)
1645
+ except _BudgetConfigError as exc:
1646
+ eprint(f"cctally budget: {exc}")
1647
+ return 2
1648
+ block["weekly_usd"] = validated["weekly_usd"]
1649
+ config["budget"] = block
1650
+ c.save_config(config)
1651
+
1652
+ weekly_usd = validated["weekly_usd"]
1653
+ alerts_enabled = validated["alerts_enabled"]
1654
+ thresholds = validated["alert_thresholds"]
1655
+
1656
+ # Forward-only-from-set reconcile (Task 3, spec §5): record thresholds
1657
+ # ALREADY crossed with alerted_at set but WITHOUT dispatch, so setting a
1658
+ # budget mid-week doesn't instant-popup; only LATER crossings fire. Runs
1659
+ # OUTSIDE the config_writer_lock (open_db has its own locking; reusing the
1660
+ # config lock here would needlessly serialize a stats.db write behind it).
1661
+ # Shared with `config set budget.*` + dashboard POST /api/settings via
1662
+ # _reconcile_budget_on_config_write (gated on _budget_alerts_active — a
1663
+ # budget with alerts off or no thresholds records nothing).
1664
+ c._reconcile_budget_on_config_write(validated)
1665
+ if getattr(args, "json", False):
1666
+ print(json.dumps({
1667
+ "status": "set",
1668
+ "weekly_usd": weekly_usd,
1669
+ "alerts_enabled": alerts_enabled,
1670
+ "alert_thresholds": list(thresholds),
1671
+ }))
1672
+ return 0
1673
+ alerts_part = "alerts on" if alerts_enabled else "alerts off"
1674
+ if thresholds:
1675
+ thr_part = " · thresholds " + " ".join(f"{t}%" for t in thresholds)
1676
+ else:
1677
+ thr_part = " · no thresholds"
1678
+ print(f"Weekly budget set to ${weekly_usd:,.2f} · {alerts_part}{thr_part}")
1679
+ return 0
1680
+
1681
+
1682
+ def _cmd_budget_unset(args: argparse.Namespace) -> int:
1683
+ """`cctally budget unset` — clear `budget.weekly_usd` (preserve
1684
+ alerts_enabled / alert_thresholds). Idempotent."""
1685
+ c = _cctally()
1686
+ with c.config_writer_lock():
1687
+ config = c._load_config_unlocked()
1688
+ existing = config.get("budget")
1689
+ if existing is not None and not isinstance(existing, dict):
1690
+ eprint("cctally budget: budget config must be an object")
1691
+ return 2
1692
+ block = dict(existing or {})
1693
+ block["weekly_usd"] = None
1694
+ config["budget"] = block
1695
+ c.save_config(config)
1696
+
1697
+ if getattr(args, "json", False):
1698
+ print(json.dumps({"status": "unset", "weekly_usd": None}))
1699
+ return 0
1700
+ print("Weekly budget cleared")
1701
+ return 0
1702
+
1703
+
1704
+ def _budget_render_unset(args: argparse.Namespace) -> int:
1705
+ """No budget set → friendly stdout message, exit 0 (NOT an error)."""
1706
+ c = _cctally()
1707
+ if getattr(args, "format", None):
1708
+ snap = _build_budget_no_budget_snapshot(args)
1709
+ c._share_render_and_emit(snap, args)
1710
+ return 0
1711
+ if getattr(args, "json", False):
1712
+ print(json.dumps({
1713
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
1714
+ "status": "unset",
1715
+ "weekly_usd": None,
1716
+ }))
1717
+ return 0
1718
+ print("No weekly budget set. Set one with: cctally budget set <amount>.")
1719
+ return 0
1720
+
1721
+
1722
+ def _budget_verdict_ansi_code(verdict: str) -> str:
1723
+ """ANSI color code for a budget verdict: ok→green, warn→amber, over→red."""
1724
+ return {"ok": "32", "warn": "33", "over": "31"}.get(verdict, "32")
1725
+
1726
+
1727
+ def _budget_render_terminal(args, budget_cfg, inputs, status) -> int:
1728
+ """Render the §4 status block to stdout. Datetimes via format_display_dt
1729
+ (honors display.tz). Verdict color ok→green / warn→amber / over→red."""
1730
+ c = _cctally()
1731
+ color = c._supports_color_stdout()
1732
+ tz_render = getattr(args, "_resolved_tz", None)
1733
+
1734
+ total_seconds = (inputs.week_end_at - inputs.week_start_at).total_seconds()
1735
+ elapsed_days = status.elapsed_fraction * total_seconds / 86400.0
1736
+ remaining_days = max(
1737
+ 0.0, total_seconds * (1.0 - status.elapsed_fraction) / 86400.0
1738
+ )
1739
+
1740
+ ws = c.format_display_dt(inputs.week_start_at, tz_render, fmt="%Y-%m-%d", suffix=False)
1741
+ we = c.format_display_dt(inputs.week_end_at, tz_render, fmt="%Y-%m-%d", suffix=False)
1742
+
1743
+ lines = []
1744
+ lines.append(
1745
+ f"Weekly budget: ${inputs.target_usd:,.2f} "
1746
+ f"(subscription week {ws} → {we})"
1747
+ )
1748
+ lines.append("")
1749
+ lines.append(
1750
+ f" Spent so far ${status.spent_usd:,.2f} "
1751
+ f"{status.consumption_pct:.1f}% of budget"
1752
+ )
1753
+ lines.append(f" Remaining ${status.remaining_usd:,.2f}")
1754
+ lines.append(
1755
+ f" Pace ${status.daily_pace_usd:,.2f}/day · "
1756
+ f"{elapsed_days:.1f} d elapsed"
1757
+ )
1758
+ lines.append(
1759
+ f" Daily budget ${status.daily_budget_remaining_usd:,.2f}/day for the "
1760
+ f"{remaining_days:.1f} d left to stay under"
1761
+ )
1762
+ verdict_label = {"ok": "OK", "warn": "WARN", "over": "OVER"}.get(
1763
+ status.verdict, status.verdict.upper()
1764
+ )
1765
+ verdict_glyph = {"ok": "✓", "warn": "⚠", "over": "✗"}.get(status.verdict, "")
1766
+ verdict_text = c._style_ansi(
1767
+ f"{verdict_glyph} {verdict_label}".strip(),
1768
+ _budget_verdict_ansi_code(status.verdict),
1769
+ color,
1770
+ )
1771
+ proj_line = (
1772
+ f" Projected EOW ${status.projected_eow_low_usd:,.0f}"
1773
+ f"–${status.projected_eow_high_usd:,.0f} → {verdict_text}"
1774
+ )
1775
+ if status.low_confidence:
1776
+ proj_line += " (LOW CONF — early in week)"
1777
+ lines.append(proj_line)
1778
+ lines.append("")
1779
+ lines.append(_budget_alerts_line(budget_cfg, status))
1780
+
1781
+ print("\n".join(lines))
1782
+ return 0
1783
+
1784
+
1785
+ def _budget_alerts_line(budget_cfg, status) -> str:
1786
+ """Render the "Alerts: ..." footer line: on/off + thresholds + crossed."""
1787
+ enabled = budget_cfg["alerts_enabled"]
1788
+ thresholds = budget_cfg["alert_thresholds"]
1789
+ if not enabled:
1790
+ return " Alerts: off"
1791
+ if not thresholds:
1792
+ return " Alerts: on · no thresholds configured"
1793
+ thr_str = " · ".join(f"{t}%" for t in thresholds)
1794
+ crossed = status.crossed_thresholds
1795
+ if crossed:
1796
+ crossed_str = ", ".join(f"{t}%" for t in crossed)
1797
+ tail = f"({crossed_str} crossed)"
1798
+ else:
1799
+ tail = "(none crossed yet)"
1800
+ return f" Alerts: on · thresholds {thr_str} · {tail}"
1801
+
1802
+
1803
+ def _budget_emit_json(budget_cfg, inputs, status) -> int:
1804
+ """Emit the full BudgetStatus + config echo + window as JSON (schemaVersion 1).
1805
+ Window timestamps are `…Z`, ignoring display.tz (every --json is UTC)."""
1806
+ payload = {
1807
+ "schemaVersion": _BUDGET_JSON_SCHEMA_VERSION,
1808
+ "status": "ok",
1809
+ "weekly_usd": inputs.target_usd,
1810
+ "alerts_enabled": budget_cfg["alerts_enabled"],
1811
+ "alert_thresholds": list(budget_cfg["alert_thresholds"]),
1812
+ "week_start_at": _iso_z(inputs.week_start_at),
1813
+ "week_end_at": _iso_z(inputs.week_end_at),
1814
+ "as_of": _iso_z(inputs.now),
1815
+ "spent_usd": status.spent_usd,
1816
+ "remaining_usd": status.remaining_usd,
1817
+ "consumption_pct": status.consumption_pct,
1818
+ "elapsed_fraction": status.elapsed_fraction,
1819
+ "projected_eow_low_usd": status.projected_eow_low_usd,
1820
+ "projected_eow_high_usd": status.projected_eow_high_usd,
1821
+ "daily_pace_usd": status.daily_pace_usd,
1822
+ "daily_budget_remaining_usd": status.daily_budget_remaining_usd,
1823
+ "verdict": status.verdict,
1824
+ "low_confidence": status.low_confidence,
1825
+ "crossed_thresholds": list(status.crossed_thresholds),
1826
+ }
1827
+ print(json.dumps(payload))
1828
+ return 0
1829
+
1830
+
1831
+ def _build_budget_snapshot(args, budget_cfg, inputs, status):
1832
+ """Build a `_lib_share.ShareSnapshot` (cmd="budget") for `--format` output.
1833
+
1834
+ `--reveal-projects` is inert for budget — there are no ProjectCells, so
1835
+ `_scrub` returns the snapshot unchanged. No parallel renderer; the gate
1836
+ calls `_share_render_and_emit(snap, args)`."""
1837
+ c = _cctally()
1838
+ _lib_share = c._share_load_lib()
1839
+ tz_label = c._share_display_tz_label(getattr(args, "_resolved_tz", None))
1840
+ period_label = c._share_period_label(
1841
+ inputs.week_start_at, inputs.week_end_at, tz_label
1842
+ )
1843
+ period = _lib_share.PeriodSpec(
1844
+ start=inputs.week_start_at, end=inputs.week_end_at,
1845
+ display_tz=tz_label, label=period_label,
1846
+ )
1847
+ columns = (
1848
+ _lib_share.ColumnSpec(key="metric", label="Metric", align="left"),
1849
+ _lib_share.ColumnSpec(key="value", label="Value", align="right",
1850
+ emphasis=True),
1851
+ )
1852
+ rows = (
1853
+ _lib_share.Row(cells={"metric": _lib_share.TextCell("Spent so far"),
1854
+ "value": _lib_share.MoneyCell(status.spent_usd)}),
1855
+ _lib_share.Row(cells={"metric": _lib_share.TextCell("Consumption"),
1856
+ "value": _lib_share.PercentCell(status.consumption_pct)}),
1857
+ _lib_share.Row(cells={"metric": _lib_share.TextCell("Remaining"),
1858
+ "value": _lib_share.MoneyCell(status.remaining_usd)}),
1859
+ _lib_share.Row(cells={"metric": _lib_share.TextCell("Daily pace"),
1860
+ "value": _lib_share.MoneyCell(status.daily_pace_usd)}),
1861
+ _lib_share.Row(cells={"metric": _lib_share.TextCell("Projected EOW (low)"),
1862
+ "value": _lib_share.MoneyCell(status.projected_eow_low_usd)}),
1863
+ _lib_share.Row(cells={"metric": _lib_share.TextCell("Projected EOW (high)"),
1864
+ "value": _lib_share.MoneyCell(status.projected_eow_high_usd)}),
1865
+ )
1866
+ notes = ("LOW CONF — early in week",) if status.low_confidence else ()
1867
+ subtitle = " · ".join([
1868
+ period_label,
1869
+ args.theme,
1870
+ "real projects" if args.reveal_projects else "projects anonymized",
1871
+ ])
1872
+ return _lib_share.ShareSnapshot(
1873
+ cmd="budget",
1874
+ title=f"Budget — week of {inputs.week_start_at.strftime('%b %d')}",
1875
+ subtitle=subtitle,
1876
+ period=period,
1877
+ columns=columns,
1878
+ rows=rows,
1879
+ chart=None,
1880
+ totals=(
1881
+ _lib_share.Totalled(label="Verdict", value=status.verdict.upper()),
1882
+ _lib_share.Totalled(label="Budget", value=f"${inputs.target_usd:,.2f}"),
1883
+ ),
1884
+ notes=notes,
1885
+ generated_at=c._share_now_utc(),
1886
+ version=c._share_resolve_version(),
1887
+ )
1888
+
1889
+
1890
+ def _build_budget_no_data_snapshot(args, budget_cfg, now_utc):
1891
+ """Share snapshot for "budget set but no usage data yet this week" — a
1892
+ uniformly-shaped artifact rather than free-form text. Computes the real
1893
+ subscription-week boundaries from config so the period label is meaningful."""
1894
+ c = _cctally()
1895
+ _lib_share = c._share_load_lib()
1896
+ config = c._load_claude_config_for_args(args)
1897
+ tz = getattr(args, "_resolved_tz", None)
1898
+ tz_label = c._share_display_tz_label(tz)
1899
+ week_start_name = get_week_start_name(
1900
+ config, getattr(args, "week_start_name", None)
1901
+ )
1902
+ ws_date, we_date = compute_week_bounds(now_utc, week_start_name)
1903
+ # internal fallback: host-local intentional
1904
+ local_tz = dt.datetime.now().astimezone().tzinfo
1905
+ week_start_dt = dt.datetime.combine(ws_date, dt.time.min, tzinfo=local_tz)
1906
+ week_end_dt = dt.datetime.combine(
1907
+ we_date + dt.timedelta(days=1), dt.time.min, tzinfo=local_tz
1908
+ )
1909
+ period_label = c._share_period_label(week_start_dt, week_end_dt, tz_label)
1910
+ target = budget_cfg["weekly_usd"]
1911
+ subtitle = " · ".join([
1912
+ period_label, args.theme,
1913
+ "real projects" if args.reveal_projects else "projects anonymized",
1914
+ ])
1915
+ return _lib_share.ShareSnapshot(
1916
+ cmd="budget",
1917
+ title=f"Budget — week of {week_start_dt.strftime('%b %d')}",
1918
+ subtitle=subtitle,
1919
+ period=_lib_share.PeriodSpec(
1920
+ start=week_start_dt, end=week_end_dt,
1921
+ display_tz=tz_label, label=period_label,
1922
+ ),
1923
+ columns=(
1924
+ _lib_share.ColumnSpec(key="metric", label="Metric", align="left"),
1925
+ _lib_share.ColumnSpec(key="value", label="Value", align="right",
1926
+ emphasis=True),
1927
+ ),
1928
+ rows=(
1929
+ _lib_share.Row(cells={
1930
+ "metric": _lib_share.TextCell("Weekly budget"),
1931
+ "value": _lib_share.MoneyCell(float(target)),
1932
+ }),
1933
+ ),
1934
+ chart=None,
1935
+ totals=(),
1936
+ notes=("No usage data recorded for this week yet — run "
1937
+ "cctally record-usage to populate.",),
1938
+ generated_at=c._share_now_utc(),
1939
+ version=c._share_resolve_version(),
1940
+ )
1941
+
1942
+
1943
+ def _build_budget_no_budget_snapshot(args):
1944
+ """Share snapshot for the "no budget set" status — a uniform artifact."""
1945
+ c = _cctally()
1946
+ _lib_share = c._share_load_lib()
1947
+ now_utc = _command_as_of()
1948
+ tz = getattr(args, "_resolved_tz", None)
1949
+ if tz is None:
1950
+ # Purely defensive: the sole caller (_budget_render_unset) already
1951
+ # resolves args._resolved_tz before dispatch, so this rarely fires —
1952
+ # resolve here anyway so the artifact always carries a tz label.
1953
+ config = c._load_claude_config_for_args(args)
1954
+ tz = c.resolve_display_tz(args, config)
1955
+ tz_label = c._share_display_tz_label(tz)
1956
+ period_label = c._share_period_label(now_utc, now_utc, tz_label)
1957
+ subtitle = " · ".join([
1958
+ period_label, args.theme,
1959
+ "real projects" if args.reveal_projects else "projects anonymized",
1960
+ ])
1961
+ return _lib_share.ShareSnapshot(
1962
+ cmd="budget",
1963
+ title="Budget — no budget set",
1964
+ subtitle=subtitle,
1965
+ period=_lib_share.PeriodSpec(
1966
+ start=now_utc, end=now_utc, display_tz=tz_label, label=period_label,
1967
+ ),
1968
+ columns=(
1969
+ _lib_share.ColumnSpec(key="metric", label="Metric", align="left"),
1970
+ _lib_share.ColumnSpec(key="value", label="Value", align="right",
1971
+ emphasis=True),
1972
+ ),
1973
+ rows=(),
1974
+ chart=None,
1975
+ totals=(),
1976
+ notes=("No weekly budget set. Set one with: cctally budget set <amount>.",),
1977
+ generated_at=c._share_now_utc(),
1978
+ version=c._share_resolve_version(),
1979
+ )