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.
- package/CHANGELOG.md +13 -0
- package/bin/_cctally_cache_report.py +1133 -880
- package/bin/_cctally_codex.py +518 -0
- package/bin/_cctally_dashboard.py +3 -3
- package/bin/_cctally_diff.py +240 -0
- package/bin/_cctally_doctor.py +479 -0
- package/bin/_cctally_five_hour.py +1688 -0
- package/bin/_cctally_forecast.py +1979 -0
- package/bin/_cctally_milestones.py +433 -0
- package/bin/_cctally_percent_breakdown.py +199 -0
- package/bin/_cctally_pricing_check.py +393 -0
- package/bin/_cctally_record.py +5 -3
- package/bin/_cctally_reporting.py +749 -0
- package/bin/_cctally_setup.py +172 -13
- package/bin/_cctally_statusline.py +630 -0
- package/bin/_cctally_sync_week.py +5 -4
- package/bin/_cctally_weekrefs.py +450 -0
- package/bin/_lib_cache_report.py +938 -0
- package/bin/_lib_pricing_debug.py +182 -0
- package/bin/_lib_subscription_weeks.py +2 -2
- package/bin/cctally +419 -8891
- package/package.json +14 -1
|
@@ -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
|
+
)
|