cctally 1.7.1 → 1.7.2

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.
@@ -398,7 +398,31 @@ def maybe_record_milestone(
398
398
 
399
399
  conn = open_db()
400
400
  try:
401
- max_existing = get_max_milestone_for_week(conn, week_start_date)
401
+ # Resolve the active segment for THIS captured moment. The segment
402
+ # is the latest week_reset_events row keyed on week_end_at whose
403
+ # effective_reset_at_utc <= captured_at; 0 = pre-credit / no-event
404
+ # sentinel. ``unixepoch()`` normalizes the comparison across mixed
405
+ # +00:00 / Z offsets (see precedent at bin/cctally:_compute_block_totals
406
+ # cross-reset detection; also project gotcha
407
+ # ``unixepoch_for_cross_offset_compare``).
408
+ captured_at_iso = saved.get("capturedAt") or now_utc_iso()
409
+ reset_event_id = 0
410
+ if week_end_at:
411
+ seg_row = conn.execute(
412
+ """
413
+ SELECT id FROM week_reset_events
414
+ WHERE new_week_end_at = ?
415
+ AND unixepoch(effective_reset_at_utc) <= unixepoch(?)
416
+ ORDER BY id DESC LIMIT 1
417
+ """,
418
+ (week_end_at, captured_at_iso),
419
+ ).fetchone()
420
+ if seg_row is not None:
421
+ reset_event_id = int(seg_row["id"])
422
+
423
+ max_existing = get_max_milestone_for_week(
424
+ conn, week_start_date, reset_event_id=reset_event_id,
425
+ )
402
426
  if max_existing is not None and current_floor <= max_existing:
403
427
  return
404
428
 
@@ -482,7 +506,10 @@ def maybe_record_milestone(
482
506
  pending_alerts: list[dict[str, Any]] = []
483
507
  for pct in range(start_threshold, current_floor + 1):
484
508
  if pct == start_threshold and max_existing is not None:
485
- prev_cost = get_milestone_cost_for_week(conn, week_start_date, max_existing)
509
+ prev_cost = get_milestone_cost_for_week(
510
+ conn, week_start_date, max_existing,
511
+ reset_event_id=reset_event_id,
512
+ )
486
513
  marginal = (cumulative_cost - prev_cost) if prev_cost is not None else None
487
514
  else:
488
515
  marginal = None
@@ -499,6 +526,7 @@ def maybe_record_milestone(
499
526
  cost_snapshot_id=cost_snapshot_id,
500
527
  five_hour_percent_at_crossing=five_hour_percent,
501
528
  commit=False,
529
+ reset_event_id=reset_event_id,
502
530
  )
503
531
  # ── Threshold-actions dispatch (set-then-dispatch, spec §3.2) ──
504
532
  # Only the genuine-new-crossing winner (rowcount==1) reaches this
@@ -523,17 +551,22 @@ def maybe_record_milestone(
523
551
  conn.execute(
524
552
  "UPDATE percent_milestones SET alerted_at = ? "
525
553
  "WHERE week_start_date = ? AND percent_threshold = ? "
526
- "AND alerted_at IS NULL",
527
- (crossed_at, week_start_date, pct),
554
+ " AND reset_event_id = ? "
555
+ " AND alerted_at IS NULL",
556
+ (crossed_at, week_start_date, pct, reset_event_id),
528
557
  )
529
558
  # Cheap re-read for payload context (cumulative_cost_usd
530
559
  # reflects the value persisted on insert, immune to any
531
560
  # subsequent recompute drift). SELECT inside the open
532
561
  # transaction is fine; values reflect post-INSERT state.
562
+ # Filter by reset_event_id so a credited week's
563
+ # alert payload reads the post-credit row, not a
564
+ # stale pre-credit row at the same (week, threshold).
533
565
  row = conn.execute(
534
566
  "SELECT cumulative_cost_usd FROM percent_milestones "
535
- "WHERE week_start_date = ? AND percent_threshold = ?",
536
- (week_start_date, pct),
567
+ "WHERE week_start_date = ? AND percent_threshold = ? "
568
+ " AND reset_event_id = ?",
569
+ (week_start_date, pct, reset_event_id),
537
570
  ).fetchone()
538
571
  if row is not None:
539
572
  cum = float(row["cumulative_cost_usd"])
@@ -1305,9 +1338,9 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1305
1338
  prior["week_end_at"], "record.prior"
1306
1339
  )
1307
1340
  prior_pct = prior["weekly_percent"]
1341
+ now_utc = dt.datetime.now(dt.timezone.utc)
1308
1342
  if prior_end_canon and prior_end_canon != cur_end_canon:
1309
1343
  prior_end_dt = parse_iso_datetime(prior_end_canon, "prior.week_end_at")
1310
- now_utc = dt.datetime.now(dt.timezone.utc)
1311
1344
  # Fire only when (a) prior window was still in the FUTURE
1312
1345
  # (Anthropic shifted the boundary before natural expiration),
1313
1346
  # AND (b) weekly_percent dropped by RESET_PCT_DROP_THRESHOLD
@@ -1331,15 +1364,149 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1331
1364
  effective_iso),
1332
1365
  )
1333
1366
  conn.commit()
1367
+ elif prior_end_canon and prior_end_canon == cur_end_canon:
1368
+ # In-place credit branch (v1.7.2). When `resets_at` stays
1369
+ # unchanged but `weekly_percent` drops by RESET_PCT_DROP_THRESHOLD
1370
+ # or more, Anthropic has issued a goodwill in-place weekly
1371
+ # credit. Emit one week_reset_events row keyed on the
1372
+ # current end_at (old == new) so the reset-aware clamp
1373
+ # above and the milestone segment writer can pivot to
1374
+ # the post-credit segment. The seed snapshot lands via
1375
+ # the now-reset-aware clamp on this same call.
1376
+ prior_end_dt = parse_iso_datetime(prior_end_canon, "prior.week_end_at")
1377
+ if (
1378
+ prior_end_dt > now_utc
1379
+ and prior_pct is not None
1380
+ and (float(prior_pct) - float(weekly_percent)) >= c._RESET_PCT_DROP_THRESHOLD
1381
+ ):
1382
+ # Pre-check (Q5 belt-and-suspenders): suppress duplicate
1383
+ # event rows for the same new_week_end_at across
1384
+ # consecutive ticks. UNIQUE(old, new) at the DDL
1385
+ # also catches the duplicate in the (old == new) case,
1386
+ # but the pre-check avoids a useless write attempt
1387
+ # and keeps the log clean. After the seed lands at
1388
+ # post-credit %, the next tick's `prior_pct` will be
1389
+ # the post-credit value so the drop predicate alone
1390
+ # also suffices — pre-check is belt-and-suspenders.
1391
+ already = conn.execute(
1392
+ "SELECT 1 FROM week_reset_events "
1393
+ "WHERE new_week_end_at = ? LIMIT 1",
1394
+ (cur_end_canon,),
1395
+ ).fetchone()
1396
+ if already is None:
1397
+ effective_dt = _floor_to_hour(now_utc)
1398
+ effective_iso = effective_dt.isoformat(timespec="seconds")
1399
+ # Row shape: old=effective_iso, new=cur_end_canon
1400
+ # (distinct values). The previous shape stored
1401
+ # old==new==cur_end_canon, which let BOTH
1402
+ # _apply_reset_events_to_weekrefs maps
1403
+ # (pre_map[old] and post_map[new]) fire on the
1404
+ # SAME WeekRef — pre_map rewrote week_end_at to
1405
+ # effective, post_map rewrote week_start_at to
1406
+ # effective, collapsing the credited week to a
1407
+ # zero-width window in downstream renders. With
1408
+ # old==effective and new==cur_end_canon, only
1409
+ # post_map fires on the credited week (setting
1410
+ # week_start_at = effective, the intended
1411
+ # behavior); pre_map keys on effective_iso and
1412
+ # finds no matching WeekRef in practice. The
1413
+ # UNIQUE(old, new) constraint permits this
1414
+ # row, and the pre-check above keys on
1415
+ # new_week_end_at so dedup still works.
1416
+ conn.execute(
1417
+ "INSERT OR IGNORE INTO week_reset_events "
1418
+ "(detected_at_utc, old_week_end_at, new_week_end_at, "
1419
+ " effective_reset_at_utc) VALUES (?, ?, ?, ?)",
1420
+ (now_utc_iso(), effective_iso, cur_end_canon,
1421
+ effective_iso),
1422
+ )
1423
+ conn.commit()
1424
+ # Force-write hwm-7d so the next status-line
1425
+ # render reflects the post-credit value. The
1426
+ # monotonic guard at the normal write site (below)
1427
+ # would refuse to decrease the file; this write
1428
+ # is the credit-only escape hatch. Lands AFTER
1429
+ # the conn.commit() so a concurrent record-usage
1430
+ # reader doesn't see the new HWM before the
1431
+ # event row is durable.
1432
+ try:
1433
+ (c.APP_DIR / "hwm-7d").write_text(
1434
+ f"{week_start_date} {weekly_percent}\n"
1435
+ )
1436
+ except OSError:
1437
+ pass
1438
+
1439
+ # Race-defensive cleanup. Between the moment
1440
+ # Anthropic credited the user (effective_iso)
1441
+ # and this code firing, the EXTERNAL
1442
+ # claude-statusline tool can replay stale
1443
+ # pre-credit `--percent` values (it has its
1444
+ # own in-memory HWM cache and re-runs us once
1445
+ # per status-line tick). Those replays land
1446
+ # captured_at_utc >= effective_iso with
1447
+ # weekly_percent == prior_pct (the pre-credit
1448
+ # value), and they dominate the reset-aware
1449
+ # clamp's MAX over the post-credit segment so
1450
+ # legitimate fresh OAuth values are rejected.
1451
+ # Strict equality (round(.,1)) keeps this
1452
+ # narrow: we only delete rows whose percent
1453
+ # exactly matches the pre-credit value we just
1454
+ # observed — legitimate post-credit climbs
1455
+ # past `prior_pct` (rare, but possible if the
1456
+ # credit is small + activity is heavy) stay.
1457
+ try:
1458
+ conn.execute(
1459
+ "DELETE FROM weekly_usage_snapshots "
1460
+ "WHERE week_start_date = ? "
1461
+ " AND unixepoch(captured_at_utc) >= "
1462
+ " unixepoch(?) "
1463
+ " AND round(weekly_percent, 1) = "
1464
+ " round(?, 1)",
1465
+ (week_start_date, effective_iso,
1466
+ float(prior_pct)),
1467
+ )
1468
+ conn.commit()
1469
+ except sqlite3.DatabaseError as exc:
1470
+ eprint(
1471
+ "[record-usage] post-credit cleanup "
1472
+ f"failed: {exc}"
1473
+ )
1334
1474
  except (sqlite3.DatabaseError, ValueError) as exc:
1335
1475
  eprint(f"[record-usage] reset-event detection failed: {exc}")
1336
1476
 
1337
- # 7-day usage is monotonically non-decreasing within a billing week.
1338
- # A lower value means stale rate-limit data from a previous API call;
1339
- # skip the insert to avoid regressing the reported usage.
1477
+ # 7-day usage is monotonically non-decreasing within a billing week
1478
+ # UNTIL Anthropic issues an in-place weekly credit. When a
1479
+ # week_reset_events row exists for THIS week_end_at, the MAX query
1480
+ # filters to samples captured at-or-after the segment's
1481
+ # effective_reset_at_utc so a fresh post-credit OAuth value (e.g.
1482
+ # 2%) lands instead of being held back by stale pre-credit history
1483
+ # (e.g. 67%). When no event row exists, COALESCE defaults to
1484
+ # epoch-zero so the filter is a no-op and legacy clamp behavior
1485
+ # is preserved byte-identically.
1486
+ # NB: comparison wrapped with ``unixepoch()`` on BOTH sides.
1487
+ # ``captured_at_utc`` is stored with `Z` suffix, but
1488
+ # ``effective_reset_at_utc`` may have a non-UTC offset on
1489
+ # historical backfill rows written before Bug 3 was fixed
1490
+ # (parse_iso_datetime returned host-local). Lex string compare
1491
+ # on mixed offsets silently mis-orders moments for non-UTC
1492
+ # hosts (CLAUDE.md gotcha: 5h-block cross-reset flag — "all
1493
+ # comparisons go through unixepoch(), NOT lex
1494
+ # BETWEEN/`<`/`>`"). Same rule applies here.
1340
1495
  max_row = conn.execute(
1341
- "SELECT MAX(weekly_percent) AS v FROM weekly_usage_snapshots WHERE week_start_date = ?",
1342
- (week_start_date,),
1496
+ """
1497
+ SELECT MAX(weekly_percent) AS v
1498
+ FROM weekly_usage_snapshots
1499
+ WHERE week_start_date = ?
1500
+ AND unixepoch(captured_at_utc) >= unixepoch(COALESCE(
1501
+ (SELECT effective_reset_at_utc
1502
+ FROM week_reset_events
1503
+ WHERE new_week_end_at = ?
1504
+ ORDER BY id DESC
1505
+ LIMIT 1),
1506
+ '1970-01-01T00:00:00Z'
1507
+ ))
1508
+ """,
1509
+ (week_start_date, week_end_at),
1343
1510
  ).fetchone()
1344
1511
  if max_row and max_row["v"] is not None and round(weekly_percent, 1) < round(float(max_row["v"]), 1):
1345
1512
  should_insert = False
@@ -1426,11 +1593,29 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
1426
1593
  )
1427
1594
  need_milestone_heal = False
1428
1595
  if latest_floor >= 1:
1596
+ # v1.7.2: scope the heal probe to the ACTIVE segment.
1597
+ # Without this, a credited week's MAX over the whole
1598
+ # ledger would still read the pre-credit ceiling
1599
+ # (e.g. 67%) and silently suppress the post-credit
1600
+ # ledger's heal even though it has zero rows.
1601
+ captured_at_for_probe = latest_row["captured_at_utc"]
1602
+ week_end_at_for_probe = latest_row["week_end_at"]
1603
+ heal_segment = 0
1604
+ if week_end_at_for_probe and captured_at_for_probe:
1605
+ seg = heal_conn.execute(
1606
+ "SELECT id FROM week_reset_events "
1607
+ "WHERE new_week_end_at = ? "
1608
+ " AND unixepoch(effective_reset_at_utc) <= unixepoch(?) "
1609
+ "ORDER BY id DESC LIMIT 1",
1610
+ (week_end_at_for_probe, captured_at_for_probe),
1611
+ ).fetchone()
1612
+ if seg is not None:
1613
+ heal_segment = int(seg["id"])
1429
1614
  max_existing = heal_conn.execute(
1430
1615
  "SELECT MAX(percent_threshold) AS m "
1431
1616
  "FROM percent_milestones "
1432
- "WHERE week_start_date = ?",
1433
- (week_start_date,),
1617
+ "WHERE week_start_date = ? AND reset_event_id = ?",
1618
+ (week_start_date, heal_segment),
1434
1619
  ).fetchone()
1435
1620
  if max_existing is None or max_existing["m"] is None:
1436
1621
  need_milestone_heal = True
@@ -317,6 +317,10 @@ def get_milestones_for_week(*args, **kwargs):
317
317
  return sys.modules["cctally"].get_milestones_for_week(*args, **kwargs)
318
318
 
319
319
 
320
+ def _canonicalize_optional_iso(*args, **kwargs):
321
+ return sys.modules["cctally"]._canonicalize_optional_iso(*args, **kwargs)
322
+
323
+
320
324
  def get_recent_weeks(*args, **kwargs):
321
325
  return sys.modules["cctally"].get_recent_weeks(*args, **kwargs)
322
326
 
@@ -949,7 +953,8 @@ class TuiPercentMilestone:
949
953
  def _tui_build_percent_milestones(
950
954
  conn: sqlite3.Connection,
951
955
  ) -> list[TuiPercentMilestone]:
952
- """Return per-percent crossings for the current week, ascending by percent.
956
+ """Return per-percent crossings for the current week's ACTIVE
957
+ segment, ascending by percent.
953
958
 
954
959
  Resolves `week_start_date` from the latest `weekly_usage_snapshots` row
955
960
  — the same path `cmd_percent_breakdown` takes. The post-override
@@ -957,15 +962,55 @@ def _tui_build_percent_milestones(
957
962
  reset, `_apply_midweek_reset_override` shifts that datetime forward to
958
963
  the reset instant, whose `.date()` no longer matches the `week_start_date`
959
964
  under which milestones were recorded.
960
- Returns [] if no usage snapshot (or no milestone) exists.
965
+
966
+ v1.7.2: when a `week_reset_events` row exists for the snapshot's
967
+ `week_end_at`, narrow to the active segment so the dashboard /
968
+ TUI milestone panel stays coherent with the already-credit-aware
969
+ header. ``active_segment = 0`` (sentinel) preserves legacy
970
+ behavior on un-credited weeks.
971
+
972
+ Returns [] if no usage snapshot exists, OR if the active segment
973
+ has no milestone rows yet (post-credit "fresh" state).
961
974
  """
962
975
  latest = conn.execute(
963
- "SELECT week_start_date FROM weekly_usage_snapshots "
976
+ "SELECT week_start_date, week_end_at FROM weekly_usage_snapshots "
977
+ "WHERE week_end_at IS NOT NULL "
964
978
  "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
965
979
  ).fetchone()
966
980
  if latest is None:
967
- return []
968
- rows = get_milestones_for_week(conn, latest["week_start_date"])
981
+ # Legacy fallback: a snapshot without week_end_at can still have
982
+ # milestones keep the prior behavior in that path.
983
+ latest = conn.execute(
984
+ "SELECT week_start_date, NULL AS week_end_at "
985
+ "FROM weekly_usage_snapshots "
986
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
987
+ ).fetchone()
988
+ if latest is None:
989
+ return []
990
+
991
+ # Resolve active segment via the canonical end_at.
992
+ active_segment = 0
993
+ if latest["week_end_at"]:
994
+ try:
995
+ canon_end = _canonicalize_optional_iso(
996
+ latest["week_end_at"], "tui.pm.cur"
997
+ )
998
+ except (AttributeError, ValueError):
999
+ canon_end = None
1000
+ if canon_end:
1001
+ seg_row = conn.execute(
1002
+ "SELECT id FROM week_reset_events "
1003
+ "WHERE new_week_end_at = ? "
1004
+ "ORDER BY id DESC LIMIT 1",
1005
+ (canon_end,),
1006
+ ).fetchone()
1007
+ if seg_row is not None:
1008
+ active_segment = int(seg_row["id"])
1009
+
1010
+ rows = [
1011
+ r for r in get_milestones_for_week(conn, latest["week_start_date"])
1012
+ if int(r["reset_event_id"] or 0) == active_segment
1013
+ ]
969
1014
  out: list[TuiPercentMilestone] = []
970
1015
  for r in rows:
971
1016
  try:
@@ -1423,27 +1468,71 @@ def _tui_build_trend(
1423
1468
  columns (`week_start_at`, `used_pct`, `dollars_per_percent`) matches
1424
1469
  `cmd_report` byte-for-byte — verified in the bundle regression diff.
1425
1470
  """
1426
- # `get_recent_weeks` returns WeekRef rows DESC by week_start_date.
1471
+ # `get_recent_weeks` returns WeekRef rows DESC by week_start_date,
1472
+ # already routed through `_apply_reset_events_to_weekrefs` so credited
1473
+ # weeks come back as TWO refs (pre-credit + post-credit) sharing the
1474
+ # same `WeekRef.key`.
1427
1475
  week_refs = get_recent_weeks(conn, max(1, count))
1428
1476
 
1429
1477
  # Figure out which week_ref corresponds to the current subscription week.
1430
- # Uses the same key derivation `cmd_report` does latest usage snapshot's
1431
- # week_start_date, canonicalized through `_get_canonical_boundary_for_date`.
1478
+ # Mirrors `cmd_report`'s Bug D pattern: build a current_ref from the
1479
+ # latest usage snapshot, route it through `_apply_reset_events_to_weekrefs`
1480
+ # so its `week_start_at` reflects the post-credit segment (or the
1481
+ # original start for non-credit weeks), then disambiguate the
1482
+ # synthesized pre-credit ref from the live post-credit ref via BOTH
1483
+ # `key` AND `week_start_at`. Key-only equality marks both segments
1484
+ # as current, which is why the dashboard's trend panel previously
1485
+ # showed two adjacent rows both highlighted as "current" with
1486
+ # identical 4.0% values on the user's live in-place credit data.
1432
1487
  latest_usage = conn.execute(
1433
1488
  "SELECT week_start_date, week_end_date "
1434
1489
  "FROM weekly_usage_snapshots "
1435
1490
  "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
1436
1491
  ).fetchone()
1437
1492
  current_key: str | None = None
1438
- if latest_usage is not None:
1493
+ current_week_start_at: str | None = None
1494
+ if latest_usage is not None and latest_usage["week_start_date"] is not None:
1439
1495
  current_key = latest_usage["week_start_date"]
1496
+ try:
1497
+ c = _cctally()
1498
+ canon_start, canon_end = c._get_canonical_boundary_for_date(
1499
+ conn, latest_usage["week_start_date"]
1500
+ )
1501
+ current_ref = c.make_week_ref(
1502
+ week_start_date=latest_usage["week_start_date"],
1503
+ week_end_date=latest_usage["week_end_date"],
1504
+ week_start_at=canon_start,
1505
+ week_end_at=canon_end,
1506
+ )
1507
+ _adjusted = c._apply_reset_events_to_weekrefs(conn, [current_ref])
1508
+ if _adjusted:
1509
+ current_week_start_at = _adjusted[0].week_start_at
1510
+ except (ValueError, sqlite3.DatabaseError, AttributeError):
1511
+ current_week_start_at = None
1440
1512
 
1441
1513
  # Build an intermediate list of (week_ref, used_pct, dpp) in oldest-first
1442
1514
  # chronological order.
1443
1515
  chrono = list(reversed(week_refs))
1516
+ # Split-key set (Bug D): credited weeks appear twice in `week_refs`
1517
+ # with identical `WeekRef.key`. For those keys ONLY, pin
1518
+ # `as_of_utc=week_ref.week_end_at` so each segment finds its own
1519
+ # latest snapshot — without this both segments collapse to the
1520
+ # post-credit snapshot's weekly_percent. Non-credit weeks (single
1521
+ # ref per key) keep the legacy unfiltered lookup.
1522
+ _split_keys = {
1523
+ r.key
1524
+ for r in week_refs
1525
+ if sum(1 for x in week_refs if x.key == r.key) > 1
1526
+ }
1444
1527
  intermediate: list[tuple[Any, float | None, float | None]] = []
1445
1528
  for week_ref in chrono:
1446
- usage = get_latest_usage_for_week(conn, week_ref)
1529
+ usage = get_latest_usage_for_week(
1530
+ conn,
1531
+ week_ref,
1532
+ as_of_utc=(
1533
+ week_ref.week_end_at if week_ref.key in _split_keys else None
1534
+ ),
1535
+ )
1447
1536
  # See cmd_report for why reset-affected weeks skip the cost cache
1448
1537
  # and live-compute from session_entries over the effective range.
1449
1538
  if _week_ref_has_reset_event(conn, week_ref):
@@ -1491,6 +1580,23 @@ def _tui_build_trend(
1491
1580
  # localizing midnight-UTC doesn't shift it to the prior day in
1492
1581
  # zones west of UTC (e.g. 2026-04-14 → "Apr 13" in America/New_York).
1493
1582
  week_label = week_ref.week_start.strftime("%b %d")
1583
+ # Bug G (v1.7.2 round-5): match on BOTH `key` AND `week_start_at`
1584
+ # for credited weeks so the pre-credit synthesized ref doesn't
1585
+ # also light up as "current" — both refs share `key`, only their
1586
+ # `week_start_at` differs (post-credit = effective reset moment,
1587
+ # pre-credit = original API-derived start). Non-credit weeks
1588
+ # have only one ref per key so `week_start_at` matching is
1589
+ # automatic. When `current_week_start_at` is None (no reset
1590
+ # event for the current week, or the resolution above failed),
1591
+ # falls back to legacy key-only matching.
1592
+ is_cur = (
1593
+ current_key is not None
1594
+ and week_ref.key == current_key
1595
+ and (
1596
+ current_week_start_at is None
1597
+ or week_ref.week_start_at == current_week_start_at
1598
+ )
1599
+ )
1494
1600
  out.append(TuiTrendRow(
1495
1601
  week_label=week_label,
1496
1602
  week_start_at=week_start_dt,
@@ -1502,7 +1608,7 @@ def _tui_build_trend(
1502
1608
  dollars_per_percent=dpp,
1503
1609
  delta_dpp=delta,
1504
1610
  spark_height=spark,
1505
- is_current=(current_key is not None and week_ref.key == current_key),
1611
+ is_current=is_cur,
1506
1612
  ))
1507
1613
  if dpp is not None:
1508
1614
  prev_dpp = dpp
@@ -94,6 +94,7 @@ def _group_entries_into_blocks(
94
94
  mode: str = "auto",
95
95
  *,
96
96
  recorded_windows: list[dt.datetime] | None = None,
97
+ block_start_overrides: dict[dt.datetime, dt.datetime] | None = None,
97
98
  now: dt.datetime | None = None,
98
99
  ) -> list[Block]:
99
100
  """Group sorted UsageEntry objects into 5-hour blocks with gap detection.
@@ -106,6 +107,18 @@ def _group_entries_into_blocks(
106
107
  into per-R buckets and built as 'recorded' blocks. Leftover entries
107
108
  run through the existing gap-detection heuristic (anchor='heuristic').
108
109
 
110
+ `block_start_overrides` (v1.7.2 round-5 / Bug J): an optional
111
+ `{R → block_start_at}` map. When present for a given R, the
112
+ recorded block's displayed ``start_time`` becomes the override
113
+ instead of the default ``R - BLOCK_DURATION``. Used by
114
+ ``_load_recorded_five_hour_windows`` to preserve the real
115
+ ``five_hour_blocks.block_start_at`` for credit-truncated windows
116
+ (an in-place credit shortens the prior 5h block's effective end
117
+ to the credit moment, but the block's API-derived START is
118
+ unchanged — without an override the renderer would compute
119
+ ``start = truncated_R - 5h`` which is hours before the real start
120
+ and confuses the user with an off-by-hours window header).
121
+
109
122
  `now` pins the current instant (typically via `_command_as_of()`). When
110
123
  omitted, falls back to wall clock so existing callers are unaffected.
111
124
  """
@@ -117,13 +130,22 @@ def _group_entries_into_blocks(
117
130
  now = dt.datetime.now(dt.timezone.utc)
118
131
 
119
132
  recorded_windows = sorted(recorded_windows or [])
133
+ block_start_overrides = block_start_overrides or {}
120
134
 
121
135
  # ── Partition entries by recorded windows ──────────────────────────
122
136
  # For each R in recorded_windows, entries whose timestamp falls in
123
- # [R - BLOCK_DURATION, R) go into recorded_buckets[R]. Everything else
124
- # (gaps between recorded windows, or fully outside any window) drops
125
- # into `leftover` and runs through the existing heuristic grouper.
126
- # Task 5 will consume recorded_buckets; for now it is built but unused.
137
+ # [override_start_or_R-5h, R) go into recorded_buckets[R]. Everything
138
+ # else (gaps between recorded windows, or fully outside any window)
139
+ # drops into `leftover` and runs through the existing heuristic
140
+ # grouper.
141
+ #
142
+ # Why override_start_or_R-5h, not always R-5h: a credit-truncated
143
+ # canonical block has R = effective_reset_at_utc (e.g. 17:58Z) but
144
+ # its real ``block_start_at`` is unchanged (e.g. 15:50Z). Using
145
+ # `R - 5h` as the partition floor would pull entries from earlier
146
+ # blocks (e.g. 12:58-15:50Z range) into the truncated bucket. The
147
+ # override keeps the real start so each entry lands in the bucket
148
+ # whose API-defined interval actually contains it.
127
149
  recorded_buckets: dict[dt.datetime, list[UsageEntry]] = {
128
150
  R: [] for R in recorded_windows
129
151
  }
@@ -132,7 +154,8 @@ def _group_entries_into_blocks(
132
154
  idx = bisect.bisect_right(recorded_windows, entry.timestamp)
133
155
  if idx < len(recorded_windows):
134
156
  R = recorded_windows[idx]
135
- if R - BLOCK_DURATION <= entry.timestamp:
157
+ bucket_start = block_start_overrides.get(R, R - BLOCK_DURATION)
158
+ if bucket_start <= entry.timestamp:
136
159
  recorded_buckets[R].append(entry)
137
160
  continue
138
161
  leftover.append(entry)
@@ -193,7 +216,11 @@ def _group_entries_into_blocks(
193
216
  bucket = recorded_buckets[R]
194
217
  if not bucket:
195
218
  continue
196
- start_time = R - BLOCK_DURATION
219
+ # Display start: override when present (credit-truncated
220
+ # canonical blocks need their real block_start_at so the
221
+ # rendered window header matches Anthropic's actual interval);
222
+ # default to R - BLOCK_DURATION for normal canonical anchors.
223
+ start_time = block_start_overrides.get(R, R - BLOCK_DURATION)
197
224
  end_time = R
198
225
  bucket_sorted = sorted(bucket, key=lambda e: e.timestamp)
199
226
  blk = _build_activity_block(
@@ -60,6 +60,19 @@ class DoctorState:
60
60
  # skipped/failed/pending or (b) a buggy writer slipped through
61
61
  # after the migration ran.
62
62
  forked_bucket_counts: Optional[dict]
63
+ # v1.7.2 credited-week tracking. Each entry is a dict with:
64
+ # * ``week_start_date`` — the credited week's bucket key
65
+ # * ``latest_weekly_percent`` — most recent weekly_percent for that
66
+ # week (used to gate the WARN — a credit + 0% means the user
67
+ # hasn't started the new segment yet, which is the EXPECTED
68
+ # state and shouldn't warn)
69
+ # * ``post_credit_milestone_count`` — count of percent_milestones
70
+ # rows with ``reset_event_id`` matching the credit event for
71
+ # this week
72
+ # None means the stats.db couldn't be opened to gather; check
73
+ # degrades to OK rather than FAIL (consistent with the rest of
74
+ # the doctor kernel's degradation posture).
75
+ credited_weeks: Optional[list[dict]]
63
76
  codex_entries_count: Optional[int]
64
77
  codex_last_entry_at: Optional[dt.datetime]
65
78
  codex_jsonl_present: bool
@@ -559,6 +572,71 @@ def _check_data_forked_buckets(s: DoctorState) -> CheckResult:
559
572
 
560
573
 
561
574
 
575
+ def _check_data_post_credit_milestones(s: DoctorState) -> CheckResult:
576
+ """Invariant: for every week with a ``week_reset_events`` row whose
577
+ ``effective_reset_at_utc`` is in the past AND latest_weekly_percent
578
+ >= 1.0, the percent_milestones ledger should have at least one row
579
+ in the credit's segment.
580
+
581
+ Pre-v1.7.2 the milestone writer didn't know about segments, so a
582
+ credited week could have a non-empty pre-credit ledger but zero
583
+ post-credit rows even after the user's usage climbed past 1%. This
584
+ check surfaces that drift as a WARN (informational; no remediation
585
+ — the next ``record-usage`` tick at >=1% will self-heal via the
586
+ segment-aware probe).
587
+
588
+ OK (silent) when:
589
+ * No credited weeks exist (state.credited_weeks is empty/None).
590
+ * Every credited week has at least one post-credit milestone row
591
+ OR latest_weekly_percent < 1.0 (= "new segment not started yet,
592
+ which is expected on a fresh credit").
593
+ """
594
+ weeks = s.credited_weeks
595
+ if weeks is None:
596
+ # Gather failed (stats.db open error). Don't double-warn; the
597
+ # db.stats.file check already covers DB-open issues.
598
+ return CheckResult(
599
+ id="data.post_credit_milestones",
600
+ title="Post-credit milestones",
601
+ severity="ok",
602
+ summary="no data",
603
+ remediation=None,
604
+ details={"reason": "credited_weeks gather returned None"},
605
+ )
606
+ stuck = [
607
+ w for w in weeks
608
+ if float(w.get("latest_weekly_percent") or 0.0) >= 1.0
609
+ and int(w.get("post_credit_milestone_count") or 0) == 0
610
+ ]
611
+ if not stuck:
612
+ return CheckResult(
613
+ id="data.post_credit_milestones",
614
+ title="Post-credit milestones",
615
+ severity="ok",
616
+ summary=(
617
+ f"{len(weeks)} credited week(s); all tracked"
618
+ if weeks else "no credited weeks"
619
+ ),
620
+ remediation=None,
621
+ details={"credited_weeks": len(weeks)},
622
+ )
623
+ starts = ", ".join(sorted(w["week_start_date"] for w in stuck))
624
+ return CheckResult(
625
+ id="data.post_credit_milestones",
626
+ title="Post-credit milestones",
627
+ severity="warn",
628
+ summary=(
629
+ f"{len(stuck)} credited week(s) with no post-credit milestone "
630
+ f"crossings yet: {starts}"
631
+ ),
632
+ remediation=None,
633
+ details={
634
+ "stuck_week_count": len(stuck),
635
+ "stuck_week_starts": [w["week_start_date"] for w in stuck],
636
+ },
637
+ )
638
+
639
+
562
640
  _LOOPBACK_HOSTS = frozenset({"loopback", "127.0.0.1", "::1", "localhost"})
563
641
 
564
642
 
@@ -786,6 +864,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
786
864
  ("data.cache_sync_state", "_check_data_cache_sync_state"),
787
865
  ("data.codex_cache", "_check_data_codex_cache"),
788
866
  ("data.forked_buckets", "_check_data_forked_buckets"),
867
+ ("data.post_credit_milestones", "_check_data_post_credit_milestones"),
789
868
  )),
790
869
  ("safety", "Safety", (
791
870
  ("safety.dashboard_bind", "_check_safety_dashboard_bind"),