cctally 1.22.2 → 1.22.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,433 @@
1
+ """Weekly cost-snapshot persistence + percent/budget milestone DB layer.
2
+
3
+ Eager I/O sibling: bin/cctally loads this at startup and re-exports all 11
4
+ symbols onto the cctally namespace. Consumers reach them via the call-time
5
+ ``c = _cctally()`` accessor (forecast/config/dashboard/sync_week/view_models),
6
+ ``sys.modules["cctally"]`` shims (record/tui), and ``ns[...]`` in tests.
7
+
8
+ Holds (11): WeekCostResult, compute_week_cost, get_latest_cost_for_week,
9
+ insert_cost_snapshot, get_max_milestone_for_week, get_milestone_cost_for_week,
10
+ get_milestones_for_week, insert_percent_milestone, insert_budget_milestone,
11
+ _reconcile_budget_milestones_on_set, _reconcile_budget_on_config_write.
12
+
13
+ Accessor discipline (spec §2): _cctally_core kernel symbols + _budget_alerts_active
14
+ are honest-imported; _sum_cost_for_range / _resolve_current_budget_window are
15
+ reached via the call-time _cctally() accessor (ns-patchable). No _lib_ kernel.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import datetime as dt
20
+ import sqlite3
21
+ import sys
22
+ from dataclasses import dataclass
23
+
24
+ from _cctally_core import (
25
+ _budget_alerts_active,
26
+ _canonicalize_optional_iso,
27
+ _command_as_of,
28
+ _get_latest_row_for_week,
29
+ eprint,
30
+ format_local_iso,
31
+ now_utc_iso,
32
+ open_db,
33
+ parse_iso_datetime,
34
+ )
35
+
36
+
37
+ def _cctally():
38
+ """Call-time accessor to the cctally module namespace (ns-patchable)."""
39
+ return sys.modules["cctally"]
40
+
41
+
42
+ @dataclass
43
+ class WeekCostResult:
44
+ week_start: dt.date
45
+ week_end: dt.date
46
+ start_iso: str
47
+ end_iso: str
48
+ cost_usd: float
49
+
50
+
51
+ def compute_week_cost(
52
+ week_start: dt.date,
53
+ week_end: dt.date,
54
+ mode: str,
55
+ offline: bool,
56
+ project: str | None,
57
+ start_iso_override: str | None = None,
58
+ end_iso_override: str | None = None,
59
+ ) -> WeekCostResult:
60
+ # internal fallback: host-local intentional
61
+ now_local = dt.datetime.now().astimezone()
62
+ start_dt_override = (
63
+ parse_iso_datetime(start_iso_override, "weekStartAt")
64
+ if start_iso_override
65
+ else None
66
+ )
67
+ end_dt_override = (
68
+ parse_iso_datetime(end_iso_override, "weekEndAt")
69
+ if end_iso_override
70
+ else None
71
+ )
72
+ if start_dt_override is not None and end_dt_override is not None:
73
+ if end_dt_override <= start_dt_override:
74
+ raise ValueError("weekEndAt must be after weekStartAt")
75
+
76
+ if start_dt_override is not None:
77
+ start_iso = start_dt_override.isoformat(timespec="seconds")
78
+ else:
79
+ start_iso = format_local_iso(week_start, end_of_day=False)
80
+
81
+ if end_dt_override is not None:
82
+ in_current_window = (
83
+ start_dt_override is not None
84
+ and start_dt_override <= now_local < end_dt_override
85
+ )
86
+ end_iso = (
87
+ now_local.isoformat(timespec="seconds")
88
+ if in_current_window
89
+ else end_dt_override.isoformat(timespec="seconds")
90
+ )
91
+ else:
92
+ is_current_week = week_start <= now_local.date() <= week_end
93
+ end_iso = (
94
+ now_local.isoformat(timespec="seconds")
95
+ if is_current_week
96
+ else format_local_iso(week_end, end_of_day=True)
97
+ )
98
+
99
+ start_dt = parse_iso_datetime(start_iso, "start")
100
+ end_dt = parse_iso_datetime(end_iso, "end")
101
+
102
+ c = _cctally()
103
+ cost = c._sum_cost_for_range(start_dt, end_dt, mode=mode, project=project)
104
+
105
+ return WeekCostResult(
106
+ week_start=week_start,
107
+ week_end=week_end,
108
+ start_iso=start_iso,
109
+ end_iso=end_iso,
110
+ cost_usd=cost,
111
+ )
112
+
113
+
114
+ def get_latest_cost_for_week(conn: sqlite3.Connection, week_ref: WeekRef) -> sqlite3.Row | None:
115
+ return _get_latest_row_for_week(conn, "weekly_cost_snapshots", week_ref)
116
+
117
+
118
+ def insert_cost_snapshot(
119
+ conn: sqlite3.Connection,
120
+ week_start: dt.date,
121
+ week_end: dt.date,
122
+ week_start_at: str | None,
123
+ week_end_at: str | None,
124
+ range_start_iso: str,
125
+ range_end_iso: str,
126
+ cost_usd: float,
127
+ mode: str,
128
+ project: str | None,
129
+ ) -> int:
130
+ start_at = _canonicalize_optional_iso(week_start_at, "weekStartAt")
131
+ end_at = _canonicalize_optional_iso(week_end_at, "weekEndAt")
132
+ range_start = parse_iso_datetime(range_start_iso, "rangeStartIso").isoformat(timespec="seconds")
133
+ range_end = parse_iso_datetime(range_end_iso, "rangeEndIso").isoformat(timespec="seconds")
134
+ cur = conn.execute(
135
+ """
136
+ INSERT INTO weekly_cost_snapshots
137
+ (
138
+ captured_at_utc,
139
+ week_start_date,
140
+ week_end_date,
141
+ week_start_at,
142
+ week_end_at,
143
+ range_start_iso,
144
+ range_end_iso,
145
+ cost_usd,
146
+ mode,
147
+ project
148
+ )
149
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
150
+ """,
151
+ (
152
+ now_utc_iso(),
153
+ week_start.isoformat(),
154
+ week_end.isoformat(),
155
+ start_at,
156
+ end_at,
157
+ range_start,
158
+ range_end,
159
+ cost_usd,
160
+ mode,
161
+ project,
162
+ ),
163
+ )
164
+ conn.commit()
165
+ return int(cur.lastrowid)
166
+
167
+
168
+ def get_max_milestone_for_week(
169
+ conn: sqlite3.Connection,
170
+ week_start_date: str,
171
+ *,
172
+ reset_event_id: int = 0,
173
+ ) -> int | None:
174
+ """Return the highest percent_threshold recorded for a week's segment,
175
+ or None.
176
+
177
+ ``reset_event_id`` (v1.7.2): default 0 (= pre-credit / no-event
178
+ sentinel) preserves legacy behavior on un-credited weeks. When an
179
+ in-place credit lifts a week into a new segment, callers pass the
180
+ segment id so the segment's threshold ledger is independent of the
181
+ pre-credit one — the post-credit 1% / 2% / 3% milestones fire even
182
+ if the pre-credit segment already crossed those thresholds.
183
+ """
184
+ row = conn.execute(
185
+ """
186
+ SELECT MAX(percent_threshold) AS max_pct
187
+ FROM percent_milestones
188
+ WHERE week_start_date = ?
189
+ AND reset_event_id = ?
190
+ """,
191
+ (week_start_date, reset_event_id),
192
+ ).fetchone()
193
+ if row and row["max_pct"] is not None:
194
+ return int(row["max_pct"])
195
+ return None
196
+
197
+
198
+ def get_milestone_cost_for_week(
199
+ conn: sqlite3.Connection,
200
+ week_start_date: str,
201
+ percent_threshold: int,
202
+ *,
203
+ reset_event_id: int = 0,
204
+ ) -> float | None:
205
+ """Return the cumulative_cost_usd for a specific (week, threshold,
206
+ segment), or None.
207
+
208
+ ``reset_event_id`` (v1.7.2): segment-aware lookup. Default 0 preserves
209
+ legacy behavior. Used by ``maybe_record_milestone`` to compute the
210
+ marginal cost between consecutive thresholds inside the SAME segment
211
+ — without the filter, the post-credit threshold-3 row would compute
212
+ its marginal against the pre-credit threshold-2 cost (wrong segment).
213
+ """
214
+ row = conn.execute(
215
+ """
216
+ SELECT cumulative_cost_usd
217
+ FROM percent_milestones
218
+ WHERE week_start_date = ?
219
+ AND percent_threshold = ?
220
+ AND reset_event_id = ?
221
+ """,
222
+ (week_start_date, percent_threshold, reset_event_id),
223
+ ).fetchone()
224
+ if row:
225
+ return float(row["cumulative_cost_usd"])
226
+ return None
227
+
228
+
229
+ def get_milestones_for_week(
230
+ conn: sqlite3.Connection,
231
+ week_start_date: str,
232
+ ) -> list[sqlite3.Row]:
233
+ """Return all milestones for a week, ordered by threshold ascending."""
234
+ return conn.execute(
235
+ """
236
+ SELECT *
237
+ FROM percent_milestones
238
+ WHERE week_start_date = ?
239
+ ORDER BY percent_threshold ASC
240
+ """,
241
+ (week_start_date,),
242
+ ).fetchall()
243
+
244
+
245
+ def insert_percent_milestone(
246
+ conn: sqlite3.Connection,
247
+ week_start_date: str,
248
+ week_end_date: str,
249
+ week_start_at: str | None,
250
+ week_end_at: str | None,
251
+ percent_threshold: int,
252
+ cumulative_cost_usd: float,
253
+ marginal_cost_usd: float | None,
254
+ usage_snapshot_id: int,
255
+ cost_snapshot_id: int,
256
+ five_hour_percent_at_crossing: float | None = None,
257
+ *,
258
+ commit: bool = True,
259
+ reset_event_id: int = 0,
260
+ ) -> int:
261
+ """Insert a percent_milestones row idempotently.
262
+
263
+ Returns the SQLite rowcount: 1 on a genuinely new crossing, 0 if a row
264
+ for (week_start_date, percent_threshold, reset_event_id) already exists.
265
+ Race-safe under concurrent record-usage instances — aligns with the
266
+ existing 5h-milestone INSERT OR IGNORE pattern (see five_hour_milestones
267
+ write path).
268
+
269
+ ``reset_event_id`` (v1.7.2 segment column, migration 005): defaults to
270
+ ``0`` (= pre-credit / no-event sentinel). When an in-place credit fires
271
+ for the current week, the caller (``maybe_record_milestone``) resolves
272
+ the active segment from ``week_reset_events`` and passes it in so
273
+ post-credit threshold crossings land as a SEPARATE row from any
274
+ pre-credit one at the same (week, threshold). The UNIQUE constraint
275
+ is on the 3-tuple, so (week=W, threshold=T, segment=0) and (W, T,
276
+ event_id) coexist.
277
+
278
+ Callers that need the row id MUST follow up with an explicit
279
+ `SELECT id FROM percent_milestones WHERE week_start_date=? AND
280
+ percent_threshold=? AND reset_event_id=?` query — `lastrowid` is
281
+ unreliable when the row is the silent-duplicate target.
282
+
283
+ ``commit=False`` skips the inner ``conn.commit()`` so the caller can
284
+ bundle the INSERT with a follow-up ``alerted_at`` UPDATE in a single
285
+ transaction (set-then-dispatch atomicity, spec §3.2). Used by
286
+ ``record_percent_milestone_if_crossed`` to mirror the 5h path's
287
+ single-commit pattern; without it, a crash between INSERT and UPDATE
288
+ permanently strands ``alerted_at`` NULL because the next call's
289
+ ``INSERT OR IGNORE`` returns rowcount==0 and the dispatch guard
290
+ ``if inserted == 1`` skips re-firing.
291
+ """
292
+ cur = conn.execute(
293
+ """
294
+ INSERT OR IGNORE INTO percent_milestones
295
+ (
296
+ captured_at_utc,
297
+ week_start_date,
298
+ week_end_date,
299
+ week_start_at,
300
+ week_end_at,
301
+ percent_threshold,
302
+ cumulative_cost_usd,
303
+ marginal_cost_usd,
304
+ usage_snapshot_id,
305
+ cost_snapshot_id,
306
+ five_hour_percent_at_crossing,
307
+ reset_event_id
308
+ )
309
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
310
+ """,
311
+ (
312
+ now_utc_iso(),
313
+ week_start_date,
314
+ week_end_date,
315
+ week_start_at,
316
+ week_end_at,
317
+ percent_threshold,
318
+ cumulative_cost_usd,
319
+ marginal_cost_usd,
320
+ usage_snapshot_id,
321
+ cost_snapshot_id,
322
+ five_hour_percent_at_crossing,
323
+ reset_event_id,
324
+ ),
325
+ )
326
+ if commit:
327
+ conn.commit()
328
+ return int(cur.rowcount)
329
+
330
+
331
+ def insert_budget_milestone(
332
+ conn: sqlite3.Connection,
333
+ *,
334
+ week_start_at: str,
335
+ threshold: int,
336
+ budget_usd: float,
337
+ spent_usd: float,
338
+ consumption_pct: float,
339
+ commit: bool = True,
340
+ ) -> int:
341
+ """INSERT OR IGNORE a budget threshold crossing. Returns ``cur.rowcount``
342
+ (1 = genuinely new crossing, 0 = INSERT OR IGNORE no-op on a pre-existing
343
+ ``(week_start_at, threshold)`` row).
344
+
345
+ Mirrors :func:`insert_percent_milestone`'s rowcount contract so the
346
+ alert-fire predicate (`if inserted == 1`) is race-safe without a
347
+ follow-up SELECT. ``alerted_at`` is left NULL — the caller stamps it in
348
+ the SAME transaction BEFORE dispatching (set-then-dispatch invariant,
349
+ CLAUDE.md Alerts gotcha). ``commit=False`` lets the caller bundle the
350
+ INSERT with the follow-up ``alerted_at`` UPDATE in one transaction so a
351
+ crash between them can't strand ``alerted_at`` NULL forever.
352
+ """
353
+ cur = conn.execute(
354
+ "INSERT OR IGNORE INTO budget_milestones "
355
+ "(week_start_at, threshold, budget_usd, spent_usd, consumption_pct, "
356
+ " crossed_at_utc) "
357
+ "VALUES (?, ?, ?, ?, ?, ?)",
358
+ (
359
+ week_start_at,
360
+ int(threshold),
361
+ float(budget_usd),
362
+ float(spent_usd),
363
+ float(consumption_pct),
364
+ now_utc_iso(),
365
+ ),
366
+ )
367
+ if commit:
368
+ conn.commit()
369
+ return int(cur.rowcount)
370
+
371
+
372
+ def _reconcile_budget_milestones_on_set(conn, *, target, thresholds, now_utc):
373
+ """Forward-only-from-set reconcile (spec §5): on `budget set`, every
374
+ threshold ALREADY crossed for the current week is recorded with
375
+ ``alerted_at`` SET but WITHOUT dispatch — so setting a budget when you're
376
+ already at 95% does NOT instant-popup. Thresholds not yet crossed get NO
377
+ row, so they fire later via :func:`maybe_record_budget_milestone`.
378
+
379
+ A mid-week target change re-runs this; thresholds already alerted stay
380
+ deduped via UNIQUE(week_start_at, threshold) + the ``alerted_at IS NULL``
381
+ guard on the UPDATE (so an existing alerted row is never re-stamped).
382
+ """
383
+ c = _cctally()
384
+ window = c._resolve_current_budget_window(conn, now_utc)
385
+ if window is None:
386
+ return
387
+ week_start_at, _week_end_at = window
388
+ week_key = week_start_at.isoformat(timespec="seconds")
389
+ spent = c._sum_cost_for_range(week_start_at, now_utc, mode="auto")
390
+ # target > 0 guaranteed by the caller (_cmd_budget_set passes the validated
391
+ # weekly_usd); the else is belt-and-suspenders.
392
+ consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
393
+ for t in sorted(thresholds):
394
+ if consumption_pct + 1e-9 >= t:
395
+ insert_budget_milestone(
396
+ conn,
397
+ week_start_at=week_key,
398
+ threshold=t,
399
+ budget_usd=target,
400
+ spent_usd=spent,
401
+ consumption_pct=consumption_pct,
402
+ commit=False,
403
+ )
404
+ conn.execute(
405
+ "UPDATE budget_milestones SET alerted_at = ? "
406
+ "WHERE week_start_at = ? AND threshold = ? AND alerted_at IS NULL",
407
+ (now_utc_iso(), week_key, t),
408
+ )
409
+ conn.commit()
410
+
411
+
412
+ def _reconcile_budget_on_config_write(validated_budget):
413
+ """Forward-only reconcile shared by all three budget-config write
414
+ paths (`budget set`, `config set budget.*`, dashboard POST
415
+ /api/settings). Gated + best-effort: a budget with alerts off or no
416
+ thresholds records nothing; a stats.db failure never fails the write.
417
+ Runs OUTSIDE any config_writer_lock (open_db has its own locking)."""
418
+ thresholds = validated_budget.get("alert_thresholds") or []
419
+ if not (_budget_alerts_active(validated_budget) and thresholds):
420
+ return
421
+ try:
422
+ conn = open_db()
423
+ try:
424
+ _reconcile_budget_milestones_on_set(
425
+ conn,
426
+ target=validated_budget["weekly_usd"],
427
+ thresholds=thresholds,
428
+ now_utc=_command_as_of(),
429
+ )
430
+ finally:
431
+ conn.close()
432
+ except Exception as exc: # best-effort; never fail the write
433
+ eprint(f"[budget-milestone] reconcile on set failed: {exc}")
@@ -0,0 +1,199 @@
1
+ """percent-breakdown command handler (cmd_percent_breakdown).
2
+
3
+ Eager I/O sibling: bin/cctally loads this at startup and re-exports
4
+ cmd_percent_breakdown onto the cctally namespace (parser dispatch in
5
+ _cctally_parser.py: pb.set_defaults(func=c.cmd_percent_breakdown)).
6
+
7
+ Accessor discipline (spec §2): _cctally_core kernel symbols are honest-imported;
8
+ everything else — load_config, resolve_display_tz, _format_ts_compact,
9
+ _boxed_table, _get_canonical_boundary_for_date, _apply_reset_events_to_weekrefs,
10
+ get_milestones_for_week (a C2 symbol, reached on the ns) — via the call-time
11
+ _cctally() accessor. No _lib_ kernel.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import datetime as dt
17
+ import json
18
+ import sys
19
+
20
+ from _cctally_core import (
21
+ _canonicalize_optional_iso,
22
+ compute_week_bounds,
23
+ get_week_start_name,
24
+ make_week_ref,
25
+ now_utc_iso,
26
+ open_db,
27
+ parse_date_str,
28
+ )
29
+
30
+
31
+ def _cctally():
32
+ """Call-time accessor to the cctally module namespace (ns-patchable)."""
33
+ return sys.modules["cctally"]
34
+
35
+
36
+ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
37
+ c = _cctally()
38
+ config = c.load_config()
39
+ tz = c.resolve_display_tz(args, config)
40
+ args._resolved_tz = tz
41
+ week_start_name = get_week_start_name(config, args.week_start_name)
42
+
43
+ conn = open_db()
44
+ try:
45
+ if args.week_start:
46
+ week_start = parse_date_str(args.week_start, "--week-start")
47
+ else:
48
+ latest_usage = conn.execute(
49
+ """
50
+ SELECT week_start_date
51
+ FROM weekly_usage_snapshots
52
+ ORDER BY captured_at_utc DESC, id DESC
53
+ LIMIT 1
54
+ """
55
+ ).fetchone()
56
+ if latest_usage is not None:
57
+ week_start = dt.date.fromisoformat(latest_usage["week_start_date"])
58
+ else:
59
+ # internal fallback: host-local intentional
60
+ now_local = dt.datetime.now().astimezone()
61
+ week_start, _ = compute_week_bounds(now_local, week_start_name)
62
+
63
+ week_start_date = week_start.isoformat()
64
+
65
+ # Get week_end from any snapshot for this week
66
+ end_row = conn.execute(
67
+ """
68
+ SELECT MAX(week_end_date) AS week_end_date
69
+ FROM (
70
+ SELECT week_end_date FROM weekly_usage_snapshots WHERE week_start_date = ?
71
+ UNION ALL
72
+ SELECT week_end_date FROM weekly_cost_snapshots WHERE week_start_date = ?
73
+ UNION ALL
74
+ SELECT week_end_date FROM percent_milestones WHERE week_start_date = ?
75
+ )
76
+ """,
77
+ (week_start_date, week_start_date, week_start_date),
78
+ ).fetchone()
79
+ week_end_date = end_row["week_end_date"] if end_row and end_row["week_end_date"] else (
80
+ (week_start + dt.timedelta(days=6)).isoformat()
81
+ )
82
+
83
+ # Apply reset-event boundary rewrites (same path get_recent_weeks
84
+ # uses) so the display header shows the effective window — e.g.
85
+ # a post-reset short week shows "2026-04-23..2026-04-25" rather
86
+ # than the backdated API-derived "2026-04-18..2026-04-25".
87
+ canon_start, canon_end = c._get_canonical_boundary_for_date(conn, week_start_date)
88
+ display_start_iso = canon_start
89
+ display_end_iso = canon_end
90
+ if canon_start and canon_end:
91
+ try:
92
+ base_ref = make_week_ref(
93
+ week_start_date=week_start_date,
94
+ week_end_date=week_end_date,
95
+ week_start_at=canon_start,
96
+ week_end_at=canon_end,
97
+ )
98
+ adjusted = c._apply_reset_events_to_weekrefs(conn, [base_ref])
99
+ if adjusted:
100
+ display_start_iso = adjusted[0].week_start_at
101
+ display_end_iso = adjusted[0].week_end_at
102
+ except ValueError:
103
+ pass
104
+
105
+ # v1.7.2 segment filter: when a week_reset_events row exists for
106
+ # the current ``week_end_at``, narrow the milestone listing to
107
+ # the active (latest) segment so a credited week's header (which
108
+ # already reflects the post-credit window via the canon-boundary
109
+ # rewrite above) is coherent with the body. Sentinel ``0`` covers
110
+ # pre-credit / no-event weeks; pre-005 DBs that didn't have the
111
+ # column also default to 0 via the migration's ALTER DEFAULT.
112
+ active_segment = 0
113
+ canon_end_for_lookup = None
114
+ latest_end_row = conn.execute(
115
+ "SELECT week_end_at FROM weekly_usage_snapshots "
116
+ "WHERE week_start_date = ? AND week_end_at IS NOT NULL "
117
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
118
+ (week_start_date,),
119
+ ).fetchone()
120
+ if latest_end_row is not None:
121
+ canon_end_for_lookup = _canonicalize_optional_iso(
122
+ latest_end_row["week_end_at"], "pb.cur"
123
+ )
124
+ if canon_end_for_lookup:
125
+ seg_row = conn.execute(
126
+ "SELECT id FROM week_reset_events "
127
+ "WHERE new_week_end_at = ? "
128
+ "ORDER BY id DESC LIMIT 1",
129
+ (canon_end_for_lookup,),
130
+ ).fetchone()
131
+ if seg_row is not None:
132
+ active_segment = int(seg_row["id"])
133
+
134
+ milestones = [
135
+ m for m in c.get_milestones_for_week(conn, week_start_date)
136
+ if int(m["reset_event_id"] or 0) == active_segment
137
+ ]
138
+
139
+ milestone_list = []
140
+ for m in milestones:
141
+ milestone_list.append({
142
+ "percentThreshold": int(m["percent_threshold"]),
143
+ "cumulativeCostUSD": round(float(m["cumulative_cost_usd"]), 9),
144
+ "marginalCostUSD": round(float(m["marginal_cost_usd"]), 9) if m["marginal_cost_usd"] is not None else None,
145
+ "capturedAt": m["captured_at_utc"],
146
+ "fiveHourPercentAtCrossing": round(float(m["five_hour_percent_at_crossing"]), 1) if m["five_hour_percent_at_crossing"] is not None else None,
147
+ })
148
+
149
+ output = {
150
+ "weekStartDate": week_start_date,
151
+ "weekEndDate": week_end_date,
152
+ "weekStartAt": display_start_iso,
153
+ "weekEndAt": display_end_iso,
154
+ "milestones": milestone_list,
155
+ "generatedAt": now_utc_iso(),
156
+ }
157
+
158
+ if args.json:
159
+ print(json.dumps(output, indent=2))
160
+ return 0
161
+
162
+ # Prefer the reset-adjusted ISO timestamps in the terminal header
163
+ # when available; fall back to the raw dates for legacy installs.
164
+ if display_start_iso and display_end_iso:
165
+ print(
166
+ f"Week: {c._format_ts_compact(display_start_iso, tz=tz)} -> "
167
+ f"{c._format_ts_compact(display_end_iso, tz=tz)}"
168
+ )
169
+ else:
170
+ print(f"Week: {week_start_date}..{week_end_date}")
171
+ if not milestone_list:
172
+ if active_segment > 0:
173
+ # v1.7.2: distinguish post-credit empty (just got
174
+ # credited, no crossings yet) from genuinely-empty week.
175
+ # The pre-credit ledger still exists in the DB — just
176
+ # filtered out of the body — so the user shouldn't see
177
+ # "No milestones" and assume the data is gone.
178
+ print(
179
+ "(post-credit segment, no milestones crossed yet)"
180
+ )
181
+ else:
182
+ print("No percent milestones recorded for this week.")
183
+ return 0
184
+
185
+ print("Percent breakdown:\n")
186
+ headers = ["#", "Threshold", "Cumulative Cost", "Marginal Cost", "5h at crossing"]
187
+ rows: list[list[str]] = []
188
+ for idx, m in enumerate(milestone_list, start=1):
189
+ pct = f"{m['percentThreshold']}%"
190
+ cum = f"${m['cumulativeCostUSD']:.6f}"
191
+ marg = f"${m['marginalCostUSD']:.6f}" if m["marginalCostUSD"] is not None else "n/a"
192
+ fh = f"{m['fiveHourPercentAtCrossing']:.0f}%" if m["fiveHourPercentAtCrossing"] is not None else "n/a"
193
+ rows.append([str(idx), pct, cum, marg, fh])
194
+
195
+ print(c._boxed_table(headers, rows, ["right", "right", "right", "right", "right"]))
196
+
197
+ return 0
198
+ finally:
199
+ conn.close()