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.
package/bin/cctally CHANGED
@@ -3817,12 +3817,20 @@ def open_db() -> sqlite3.Connection:
3817
3817
  marginal_cost_usd REAL,
3818
3818
  usage_snapshot_id INTEGER NOT NULL,
3819
3819
  cost_snapshot_id INTEGER NOT NULL,
3820
- UNIQUE(week_start_date, percent_threshold)
3820
+ reset_event_id INTEGER NOT NULL DEFAULT 0,
3821
+ UNIQUE(week_start_date, percent_threshold, reset_event_id)
3821
3822
  )
3822
3823
  """
3823
3824
  )
3824
3825
 
3825
3826
  add_column_if_missing(conn, "percent_milestones", "five_hour_percent_at_crossing", "REAL")
3827
+ # reset_event_id: segment column added by migration 005. Fresh-install
3828
+ # DBs get it via the live CREATE TABLE above + the dispatcher
3829
+ # fast-stamps the migration. Existing pre-005 DBs trip the migration's
3830
+ # rename-recreate-copy idiom (handler in _cctally_db.py); the handler's
3831
+ # fast-path probe stamps the marker when the column is already present
3832
+ # (covers the corner case where a partially-upgraded DB has the column
3833
+ # but not the new UNIQUE — re-run is safe).
3826
3834
 
3827
3835
  # alerted_at: populated by the alert-dispatch path when a milestone-INSERT
3828
3836
  # row's threshold matches the user's configured alerts.weekly_thresholds /
@@ -4717,15 +4725,27 @@ def insert_cost_snapshot(
4717
4725
  def get_max_milestone_for_week(
4718
4726
  conn: sqlite3.Connection,
4719
4727
  week_start_date: str,
4728
+ *,
4729
+ reset_event_id: int = 0,
4720
4730
  ) -> int | None:
4721
- """Return the highest percent_threshold recorded for a week, or None."""
4731
+ """Return the highest percent_threshold recorded for a week's segment,
4732
+ or None.
4733
+
4734
+ ``reset_event_id`` (v1.7.2): default 0 (= pre-credit / no-event
4735
+ sentinel) preserves legacy behavior on un-credited weeks. When an
4736
+ in-place credit lifts a week into a new segment, callers pass the
4737
+ segment id so the segment's threshold ledger is independent of the
4738
+ pre-credit one — the post-credit 1% / 2% / 3% milestones fire even
4739
+ if the pre-credit segment already crossed those thresholds.
4740
+ """
4722
4741
  row = conn.execute(
4723
4742
  """
4724
4743
  SELECT MAX(percent_threshold) AS max_pct
4725
4744
  FROM percent_milestones
4726
4745
  WHERE week_start_date = ?
4746
+ AND reset_event_id = ?
4727
4747
  """,
4728
- (week_start_date,),
4748
+ (week_start_date, reset_event_id),
4729
4749
  ).fetchone()
4730
4750
  if row and row["max_pct"] is not None:
4731
4751
  return int(row["max_pct"])
@@ -4736,15 +4756,27 @@ def get_milestone_cost_for_week(
4736
4756
  conn: sqlite3.Connection,
4737
4757
  week_start_date: str,
4738
4758
  percent_threshold: int,
4759
+ *,
4760
+ reset_event_id: int = 0,
4739
4761
  ) -> float | None:
4740
- """Return the cumulative_cost_usd for a specific threshold, or None."""
4762
+ """Return the cumulative_cost_usd for a specific (week, threshold,
4763
+ segment), or None.
4764
+
4765
+ ``reset_event_id`` (v1.7.2): segment-aware lookup. Default 0 preserves
4766
+ legacy behavior. Used by ``maybe_record_milestone`` to compute the
4767
+ marginal cost between consecutive thresholds inside the SAME segment
4768
+ — without the filter, the post-credit threshold-3 row would compute
4769
+ its marginal against the pre-credit threshold-2 cost (wrong segment).
4770
+ """
4741
4771
  row = conn.execute(
4742
4772
  """
4743
4773
  SELECT cumulative_cost_usd
4744
4774
  FROM percent_milestones
4745
- WHERE week_start_date = ? AND percent_threshold = ?
4775
+ WHERE week_start_date = ?
4776
+ AND percent_threshold = ?
4777
+ AND reset_event_id = ?
4746
4778
  """,
4747
- (week_start_date, percent_threshold),
4779
+ (week_start_date, percent_threshold, reset_event_id),
4748
4780
  ).fetchone()
4749
4781
  if row:
4750
4782
  return float(row["cumulative_cost_usd"])
@@ -4781,18 +4813,29 @@ def insert_percent_milestone(
4781
4813
  five_hour_percent_at_crossing: float | None = None,
4782
4814
  *,
4783
4815
  commit: bool = True,
4816
+ reset_event_id: int = 0,
4784
4817
  ) -> int:
4785
4818
  """Insert a percent_milestones row idempotently.
4786
4819
 
4787
4820
  Returns the SQLite rowcount: 1 on a genuinely new crossing, 0 if a row
4788
- for (week_start_date, percent_threshold) already exists. Race-safe under
4789
- concurrent record-usage instances — aligns with the existing 5h-milestone
4790
- INSERT OR IGNORE pattern (see five_hour_milestones write path).
4821
+ for (week_start_date, percent_threshold, reset_event_id) already exists.
4822
+ Race-safe under concurrent record-usage instances — aligns with the
4823
+ existing 5h-milestone INSERT OR IGNORE pattern (see five_hour_milestones
4824
+ write path).
4825
+
4826
+ ``reset_event_id`` (v1.7.2 segment column, migration 005): defaults to
4827
+ ``0`` (= pre-credit / no-event sentinel). When an in-place credit fires
4828
+ for the current week, the caller (``maybe_record_milestone``) resolves
4829
+ the active segment from ``week_reset_events`` and passes it in so
4830
+ post-credit threshold crossings land as a SEPARATE row from any
4831
+ pre-credit one at the same (week, threshold). The UNIQUE constraint
4832
+ is on the 3-tuple, so (week=W, threshold=T, segment=0) and (W, T,
4833
+ event_id) coexist.
4791
4834
 
4792
4835
  Callers that need the row id MUST follow up with an explicit
4793
4836
  `SELECT id FROM percent_milestones WHERE week_start_date=? AND
4794
- percent_threshold=?` query — `lastrowid` is unreliable when the row
4795
- is the silent-duplicate target.
4837
+ percent_threshold=? AND reset_event_id=?` query — `lastrowid` is
4838
+ unreliable when the row is the silent-duplicate target.
4796
4839
 
4797
4840
  ``commit=False`` skips the inner ``conn.commit()`` so the caller can
4798
4841
  bundle the INSERT with a follow-up ``alerted_at`` UPDATE in a single
@@ -4817,9 +4860,10 @@ def insert_percent_milestone(
4817
4860
  marginal_cost_usd,
4818
4861
  usage_snapshot_id,
4819
4862
  cost_snapshot_id,
4820
- five_hour_percent_at_crossing
4863
+ five_hour_percent_at_crossing,
4864
+ reset_event_id
4821
4865
  )
4822
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4866
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
4823
4867
  """,
4824
4868
  (
4825
4869
  now_utc_iso(),
@@ -4833,6 +4877,7 @@ def insert_percent_milestone(
4833
4877
  usage_snapshot_id,
4834
4878
  cost_snapshot_id,
4835
4879
  five_hour_percent_at_crossing,
4880
+ reset_event_id,
4836
4881
  ),
4837
4882
  )
4838
4883
  if commit:
@@ -5343,10 +5388,31 @@ def _select_non_overlapping_recorded_windows(
5343
5388
  def _load_recorded_five_hour_windows(
5344
5389
  range_start: dt.datetime,
5345
5390
  range_end: dt.datetime,
5346
- ) -> list[dt.datetime]:
5391
+ ) -> tuple[list[dt.datetime], dict[dt.datetime, dt.datetime]]:
5347
5392
  """Return sorted, UTC-aware recorded ``five_hour_resets_at`` values
5348
5393
  that anchor real 5h windows in ``[range_start, range_end]``.
5349
5394
 
5395
+ Two sources contribute to the merged anchor set:
5396
+
5397
+ 1. ``weekly_usage_snapshots.five_hour_resets_at`` — every
5398
+ record-usage tick stores the API-derived reset moment here. The
5399
+ count of supporting rows weights each anchor (low-count anchors
5400
+ are downvoted in ``_select_non_overlapping_recorded_windows``).
5401
+
5402
+ 2. ``five_hour_blocks.five_hour_resets_at`` — the canonical
5403
+ API-anchored rollup table. Each row represents ONE accepted 5h
5404
+ window after ``maybe_update_five_hour_block`` has merged jittered
5405
+ reset values via ``_canonical_5h_window_key``. These are the
5406
+ authoritative anchors; we count them with a heavy weight (1000)
5407
+ so they always dominate over jittered raw snapshot values when
5408
+ both sources see the same physical window. Without this source,
5409
+ ``cctally blocks`` falls back to the heuristic anchor for the
5410
+ ACTIVE row whenever the most recent
5411
+ ``weekly_usage_snapshots.five_hour_resets_at`` value disagrees
5412
+ with the canonical anchor — Bug C in v1.7.2 round 3. Tied
5413
+ windows (jitter within 10-minute floor) collapse to the same
5414
+ key and the canonical weight dominates.
5415
+
5350
5416
  Each value is parsed as ISO-8601 (the storage format produced by
5351
5417
  ``cmd_record_usage``) and normalized to UTC. Naive datetimes are
5352
5418
  treated as already-UTC. Values are floored to the previous
@@ -5373,11 +5439,58 @@ def _load_recorded_five_hour_windows(
5373
5439
  " AND five_hour_resets_at <= ?",
5374
5440
  (range_start.isoformat(), range_end.isoformat()),
5375
5441
  ).fetchall()
5442
+ # Canonical API-anchored windows from the rollup table.
5443
+ # Heavy-weight (1000 per row) so they always dominate over
5444
+ # any jittered raw-snapshot value sharing the same floored
5445
+ # 10-minute bucket. Wrapped in a defensive try in case the
5446
+ # five_hour_blocks table doesn't exist yet (very-old DB on
5447
+ # first open before the bootstrap migration ran).
5448
+ # Pull ``block_start_at`` alongside ``five_hour_resets_at``
5449
+ # so Bug J's overlap-truncation step (below) can preserve
5450
+ # the real display start for credit-truncated blocks.
5451
+ canonical_rows: list[Any] = []
5452
+ try:
5453
+ canonical_rows = conn.execute(
5454
+ "SELECT five_hour_resets_at, block_start_at "
5455
+ "FROM five_hour_blocks "
5456
+ "WHERE five_hour_resets_at IS NOT NULL "
5457
+ " AND five_hour_resets_at >= ? "
5458
+ " AND five_hour_resets_at <= ?",
5459
+ (range_start.isoformat(), range_end.isoformat()),
5460
+ ).fetchall()
5461
+ except sqlite3.DatabaseError:
5462
+ canonical_rows = []
5463
+ # In-place credit events — used by Bug J to detect canonical
5464
+ # block overlaps that should be resolved by truncating the
5465
+ # earlier block at the credit moment (rather than dropping
5466
+ # one via _select_non_overlapping_recorded_windows, which
5467
+ # leaves the dropped block's entries unanchored and
5468
+ # rendered as a phantom heuristic "~" row).
5469
+ credit_moments: list[dt.datetime] = []
5470
+ try:
5471
+ credit_rows = conn.execute(
5472
+ "SELECT effective_reset_at_utc "
5473
+ "FROM week_reset_events "
5474
+ "WHERE old_week_end_at = effective_reset_at_utc"
5475
+ ).fetchall()
5476
+ for c in credit_rows:
5477
+ raw = c["effective_reset_at_utc"]
5478
+ try:
5479
+ d = dt.datetime.fromisoformat(str(raw))
5480
+ except ValueError:
5481
+ continue
5482
+ if d.tzinfo is None:
5483
+ d = d.replace(tzinfo=dt.timezone.utc)
5484
+ else:
5485
+ d = d.astimezone(dt.timezone.utc)
5486
+ credit_moments.append(d)
5487
+ except sqlite3.DatabaseError:
5488
+ credit_moments = []
5376
5489
  except (sqlite3.DatabaseError, OSError):
5377
5490
  # OSError covers ensure_dirs() failures (read-only FS, permission
5378
5491
  # denied on parent dir) that propagate from open_db() before any
5379
5492
  # SQL runs. Either way, fall back to the heuristic anchor path.
5380
- return []
5493
+ return [], {}
5381
5494
  counts: dict[dt.datetime, int] = {}
5382
5495
  for row in rows:
5383
5496
  raw = row["five_hour_resets_at"] if hasattr(row, "keys") else row[0]
@@ -5393,7 +5506,110 @@ def _load_recorded_five_hour_windows(
5393
5506
  d = d.astimezone(dt.timezone.utc)
5394
5507
  snapped = _floor_to_ten_minutes(d)
5395
5508
  counts[snapped] = counts.get(snapped, 0) + 1
5396
- return _select_non_overlapping_recorded_windows(list(counts.items()))
5509
+ # Overlay canonical rollup anchors at heavy weight. Same flooring
5510
+ # rule so a jittered raw value (e.g. 17:48Z) and its canonicalized
5511
+ # rollup (e.g. 17:50Z) collapse into the same bucket; without that
5512
+ # the high-weight canonical entry would create a NEW bucket and
5513
+ # both would be reported as separate windows, then
5514
+ # `_select_non_overlapping_recorded_windows` (5h-disjoint
5515
+ # invariant) would drop the lower-weight one — but the wrong
5516
+ # one would win when jitter exceeds 10 minutes.
5517
+ #
5518
+ # Bug J (v1.7.2 round-5): collect canonical (block_start, R) pairs
5519
+ # so we can detect in-place-credit overlaps before flattening into
5520
+ # the weighted scheduler. When two canonical 5h blocks overlap AND
5521
+ # an in-place credit event falls inside the overlap, truncate the
5522
+ # EARLIER block's R to the credit moment (floored to 10 min so it
5523
+ # collapses with any same-bucket raw-snapshot value). The
5524
+ # truncated R keeps both blocks visible — without this fix the
5525
+ # earlier block's entries are silently rendered as a phantom
5526
+ # heuristic "~" row by `_group_entries_into_blocks`.
5527
+ canonical_pairs: list[tuple[dt.datetime, dt.datetime]] = []
5528
+ for row in canonical_rows:
5529
+ rs_raw = row["five_hour_resets_at"] if hasattr(row, "keys") else row[0]
5530
+ bs_raw = row["block_start_at"] if hasattr(row, "keys") else row[1]
5531
+ if rs_raw is None or bs_raw is None:
5532
+ continue
5533
+ try:
5534
+ rs = dt.datetime.fromisoformat(str(rs_raw))
5535
+ bs = dt.datetime.fromisoformat(str(bs_raw))
5536
+ except ValueError:
5537
+ continue
5538
+ if rs.tzinfo is None:
5539
+ rs = rs.replace(tzinfo=dt.timezone.utc)
5540
+ else:
5541
+ rs = rs.astimezone(dt.timezone.utc)
5542
+ if bs.tzinfo is None:
5543
+ bs = bs.replace(tzinfo=dt.timezone.utc)
5544
+ else:
5545
+ bs = bs.astimezone(dt.timezone.utc)
5546
+ canonical_pairs.append((bs, rs))
5547
+ canonical_pairs.sort(key=lambda p: p[0])
5548
+
5549
+ # Detect overlap-with-credit and replace the earlier R with a
5550
+ # credit-truncated anchor. The (anchor → real_block_start) map is
5551
+ # returned alongside the anchor list so the renderer can show the
5552
+ # real block_start_at on the display row (instead of the default
5553
+ # R - 5h, which would be hours earlier for a 2h-truncated block).
5554
+ block_start_overrides: dict[dt.datetime, dt.datetime] = {}
5555
+ truncated_pairs: list[tuple[dt.datetime, dt.datetime]] = []
5556
+ for i, (bs, rs) in enumerate(canonical_pairs):
5557
+ truncated_R = rs
5558
+ if i + 1 < len(canonical_pairs):
5559
+ next_bs, _next_rs = canonical_pairs[i + 1]
5560
+ if rs > next_bs: # overlap with next block
5561
+ # Look for a credit moment inside [next_bs, rs] — the
5562
+ # part of the earlier block that overlaps the next.
5563
+ for cm in credit_moments:
5564
+ if next_bs <= cm <= rs:
5565
+ cm_floored = _floor_to_ten_minutes(cm)
5566
+ # Only truncate if cm is strictly inside the
5567
+ # earlier block; otherwise leave R alone and
5568
+ # let `_select_non_overlapping_recorded_windows`
5569
+ # drop one via its weight-tiebreaker.
5570
+ if bs < cm_floored < rs:
5571
+ truncated_R = cm_floored
5572
+ block_start_overrides[cm_floored] = bs
5573
+ break
5574
+ truncated_pairs.append((bs, truncated_R))
5575
+
5576
+ # Truncated anchors are credit-adjusted and known-good; bypass the
5577
+ # `_select_non_overlapping_recorded_windows` weighted scheduler for
5578
+ # them (the scheduler treats every R as the END of a fixed 5h
5579
+ # window and would see a truncated R conflicting with the adjacent
5580
+ # canonical block one slot earlier — e.g. truncated R=17:50 would
5581
+ # collide with the prior block's R=15:50 even though their REAL
5582
+ # intervals are [15:50, 17:50] and [10:50, 15:50] respectively —
5583
+ # adjacent, not overlapping). Add their R directly to the selector
5584
+ # input weight (so jittered same-bucket raw values still collapse)
5585
+ # but skip them when computing the overlap-safe subset.
5586
+ truncated_anchors: set[dt.datetime] = set()
5587
+ for bs, rs in truncated_pairs:
5588
+ snapped = _floor_to_ten_minutes(rs)
5589
+ if rs != _floor_to_ten_minutes(rs):
5590
+ if rs in block_start_overrides:
5591
+ block_start_overrides[snapped] = block_start_overrides.pop(rs)
5592
+ # Identify truncated anchors by membership in the override map
5593
+ # (only credit-truncated entries land there).
5594
+ if snapped in block_start_overrides:
5595
+ truncated_anchors.add(snapped)
5596
+ counts[snapped] = counts.get(snapped, 0) + 1000
5597
+
5598
+ non_truncated_items = [
5599
+ (a, w) for a, w in counts.items() if a not in truncated_anchors
5600
+ ]
5601
+ selected_non_truncated = _select_non_overlapping_recorded_windows(
5602
+ non_truncated_items
5603
+ )
5604
+ # Merge truncated anchors back in, sorted ascending. Their non-
5605
+ # overlap with the surrounding canonical blocks is guaranteed by
5606
+ # the credit-moment truncation: a truncated R sits strictly
5607
+ # between its real block_start (which equals the prior block's R)
5608
+ # and the next block's R.
5609
+ selected = sorted(
5610
+ list(selected_non_truncated) + list(truncated_anchors)
5611
+ )
5612
+ return selected, block_start_overrides
5397
5613
 
5398
5614
 
5399
5615
  def cmd_blocks(args: argparse.Namespace) -> int:
@@ -5438,15 +5654,39 @@ def cmd_blocks(args: argparse.Namespace) -> int:
5438
5654
  # reset R just after ``range_end`` (e.g. the active window when
5439
5655
  # range_end is wall-clock "now") can still anchor entries that fall
5440
5656
  # inside [range_start, range_end].
5441
- recorded_windows = _load_recorded_five_hour_windows(
5657
+ recorded_windows, block_start_overrides = _load_recorded_five_hour_windows(
5442
5658
  range_start - BLOCK_DURATION, range_end + BLOCK_DURATION,
5443
5659
  )
5444
5660
 
5445
5661
  # Group into blocks
5446
5662
  blocks = _group_entries_into_blocks(
5447
5663
  all_entries, mode="auto",
5448
- recorded_windows=recorded_windows, now=now_utc,
5449
- )
5664
+ recorded_windows=recorded_windows,
5665
+ block_start_overrides=block_start_overrides,
5666
+ now=now_utc,
5667
+ )
5668
+
5669
+ # Bug E (v1.7.2 round-4): when the ACTIVE block is heuristic-anchored
5670
+ # but a canonical ``five_hour_blocks`` row exists for the current 5h
5671
+ # window key, swap the active block's times to the API-anchored
5672
+ # ``block_start_at`` / ``five_hour_resets_at`` and flip its anchor to
5673
+ # ``"recorded"`` so the renderer drops the ``~`` prefix. The
5674
+ # heuristic anchor can sit in a different 10-minute floor bucket
5675
+ # than the canonical anchor (e.g. 23:00 IDT vs 20:50 IDT — 130 min
5676
+ # apart), so round-3's anchor-overlay in
5677
+ # ``_load_recorded_five_hour_windows`` doesn't catch this case.
5678
+ # Match by the live 5h window key (the same key
5679
+ # ``cmd_five_hour_blocks`` would surface for the ACTIVE row) — falls
5680
+ # back to heuristic behavior whenever the canonical row is missing.
5681
+ #
5682
+ # Bug F (v1.7.2 round-5): pass ``all_entries`` so the swap also
5683
+ # re-aggregates token / cost totals over the canonical interval. The
5684
+ # heuristic block holds only entries from the heuristic anchor
5685
+ # onwards; the canonical block may start earlier and include 1-2h of
5686
+ # additional entries. Without re-aggregation the displayed window
5687
+ # said one thing and the cost said another (live data: window
5688
+ # 20:50→01:50 with $45 cost vs the real $128).
5689
+ _maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc)
5450
5690
 
5451
5691
  if args.json:
5452
5692
  print(_blocks_to_json(blocks))
@@ -5457,6 +5697,105 @@ def cmd_blocks(args: argparse.Namespace) -> int:
5457
5697
  return 0
5458
5698
 
5459
5699
 
5700
+ def _maybe_swap_active_block_to_canonical(
5701
+ blocks: list[Any],
5702
+ all_entries: list[Any],
5703
+ *,
5704
+ now: dt.datetime,
5705
+ ) -> None:
5706
+ """In-place swap of an ACTIVE heuristic block to its API-anchored
5707
+ canonical window — timestamps AND token/cost totals.
5708
+
5709
+ Looks up the live ``five_hour_window_key`` from the most recent
5710
+ ``weekly_usage_snapshots`` row, then joins to ``five_hour_blocks``
5711
+ for that key. If found AND the canonical window still contains
5712
+ ``now`` (resets_at > now), rewrites the active block to span the
5713
+ canonical ``[block_start_at, five_hour_resets_at)`` interval and
5714
+ flips ``anchor`` to ``"recorded"``. Token / cost totals are
5715
+ re-aggregated from ``all_entries`` filtered to that interval via
5716
+ ``_aggregate_block`` — the canonical window may contain 1-2h more
5717
+ activity than the heuristic grouping did, so the cost shown next
5718
+ to the swapped timestamps stays consistent with them (Bug F).
5719
+
5720
+ No-op when:
5721
+ - No block is active (no ``is_active`` and not gap).
5722
+ - The active block's anchor is already ``"recorded"``.
5723
+ - No live snapshot exists, or the snapshot's ``five_hour_window_key``
5724
+ is NULL.
5725
+ - No canonical ``five_hour_blocks`` row matches the live key.
5726
+ - The canonical window's ``five_hour_resets_at`` is already in
5727
+ the past relative to ``now`` (canonical block is closed; the
5728
+ heuristic block is genuinely the current activity).
5729
+
5730
+ Surgical helper called once from ``cmd_blocks`` after grouping.
5731
+ """
5732
+ # Find the active (non-gap, heuristic) block — there's at most one.
5733
+ active_idx = None
5734
+ for i, b in enumerate(blocks):
5735
+ if not b.is_gap and b.is_active:
5736
+ active_idx = i
5737
+ break
5738
+ if active_idx is None or blocks[active_idx].anchor != "heuristic":
5739
+ return
5740
+ active = blocks[active_idx]
5741
+ try:
5742
+ with open_db() as conn:
5743
+ snap = conn.execute(
5744
+ "SELECT five_hour_window_key FROM weekly_usage_snapshots "
5745
+ "WHERE five_hour_window_key IS NOT NULL "
5746
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
5747
+ ).fetchone()
5748
+ if snap is None or snap["five_hour_window_key"] is None:
5749
+ return
5750
+ key = int(snap["five_hour_window_key"])
5751
+ row = conn.execute(
5752
+ "SELECT block_start_at, five_hour_resets_at "
5753
+ "FROM five_hour_blocks WHERE five_hour_window_key = ? "
5754
+ "LIMIT 1",
5755
+ (key,),
5756
+ ).fetchone()
5757
+ except (sqlite3.DatabaseError, OSError):
5758
+ return
5759
+ if row is None:
5760
+ return
5761
+ try:
5762
+ block_start = parse_iso_datetime(
5763
+ row["block_start_at"], "five_hour_blocks.block_start_at"
5764
+ )
5765
+ block_end = parse_iso_datetime(
5766
+ row["five_hour_resets_at"], "five_hour_blocks.five_hour_resets_at"
5767
+ )
5768
+ except ValueError:
5769
+ return
5770
+ # Normalize to UTC for stable comparisons (block_start_at can carry
5771
+ # the host-local offset; five_hour_resets_at is UTC).
5772
+ block_start_utc = block_start.astimezone(dt.timezone.utc)
5773
+ block_end_utc = block_end.astimezone(dt.timezone.utc)
5774
+ # If the canonical window has already ended, don't displace the
5775
+ # heuristic active block — the canonical block is closed and the
5776
+ # heuristic anchor reflects real ongoing activity in a later window.
5777
+ if block_end_utc <= now.astimezone(dt.timezone.utc):
5778
+ return
5779
+ # Re-aggregate entries over the canonical interval. Build a fresh
5780
+ # Block via ``_build_activity_block`` (mode="auto" matches
5781
+ # ``_group_entries_into_blocks``'s default) so every total stays in
5782
+ # one code path — no field-by-field assignment that could drift if
5783
+ # the dataclass grows new fields.
5784
+ canonical_entries = [
5785
+ e for e in all_entries
5786
+ if block_start_utc <= e.timestamp < block_end_utc
5787
+ ]
5788
+ rebuilt = _build_activity_block(
5789
+ canonical_entries,
5790
+ block_start_utc,
5791
+ block_end_utc,
5792
+ now.astimezone(dt.timezone.utc),
5793
+ "auto",
5794
+ anchor="recorded",
5795
+ )
5796
+ blocks[active_idx] = rebuilt
5797
+
5798
+
5460
5799
  def _parse_cli_date_range(
5461
5800
  args: argparse.Namespace,
5462
5801
  *,
@@ -7048,11 +7387,29 @@ def _apply_reset_events_to_weekrefs(
7048
7387
  week: its API-derived start (= new resets_at - 7d) backdates into
7049
7388
  the pre-reset week. Override ref.week_start_at = effective_reset_at_utc
7050
7389
  so the new week starts at the actual reset moment.
7390
+ - **In-place credit (v1.7.2 round-3, Bug B).** Detected via the row
7391
+ shape ``old_week_end_at == effective_reset_at_utc`` (the live and
7392
+ backfill detection paths both write this shape — see
7393
+ ``test_event_row_old_is_effective_not_cur_end``). For these events,
7394
+ the credited week's ref matches ``new_week_end_at`` (the original
7395
+ resets_at is unchanged), so the post-credit override above
7396
+ rewrites ``week_start_at`` to ``effective``. But the pre-credit
7397
+ segment of the SAME week — where the user spent the bulk of their
7398
+ usage before the credit — is dropped, because no other ref in
7399
+ ``refs`` carries ``week_end_at == effective``. Synthesize a
7400
+ pre-credit ref alongside the post-credit one: its
7401
+ ``week_start_at`` stays at the ref's original API-derived value,
7402
+ its ``week_end_at`` becomes ``effective`` (closes the pre-credit
7403
+ segment). Credited weeks render as TWO trend rows downstream.
7051
7404
 
7052
7405
  The ref's `week_start` (date) and `key` fields are intentionally left at
7053
7406
  the API-derived values — they're the lookup keys for
7054
7407
  weekly_usage_snapshots / weekly_cost_snapshots. Only the display-facing
7055
7408
  `week_start_at` / `week_end_at` (and the derived `week_end` date) shift.
7409
+ Both the pre-credit and post-credit synthesized refs share the same
7410
+ `key` so downstream per-segment readers
7411
+ (``cmd_percent_breakdown`` / dashboard milestone panel) can still
7412
+ filter milestones by ``reset_event_id`` against the same lookup keys.
7056
7413
  """
7057
7414
  events = conn.execute(
7058
7415
  "SELECT old_week_end_at, new_week_end_at, effective_reset_at_utc "
@@ -7062,6 +7419,15 @@ def _apply_reset_events_to_weekrefs(
7062
7419
  return refs
7063
7420
  pre_map = {e["old_week_end_at"]: e["effective_reset_at_utc"] for e in events}
7064
7421
  post_map = {e["new_week_end_at"]: e["effective_reset_at_utc"] for e in events}
7422
+ # In-place credit events have `old == effective` (the row shape the
7423
+ # live + backfill detection paths agree on). Project the set of
7424
+ # `new_week_end_at` values for those events so we can detect them
7425
+ # while iterating refs and split the credited week into TWO refs.
7426
+ in_place_credit_new_ends: set[str] = {
7427
+ e["new_week_end_at"]
7428
+ for e in events
7429
+ if e["old_week_end_at"] == e["effective_reset_at_utc"]
7430
+ }
7065
7431
  out: list[WeekRef] = []
7066
7432
  for ref in refs:
7067
7433
  new_ref = ref
@@ -7076,7 +7442,43 @@ def _apply_reset_events_to_weekrefs(
7076
7442
  pass
7077
7443
  if ref.week_end_at and ref.week_end_at in post_map:
7078
7444
  reset_at = post_map[ref.week_end_at]
7445
+ # In-place credit: synthesize a pre-credit ref FIRST so it
7446
+ # lands in `out` before the post-credit ref. The pre-credit
7447
+ # ref keeps the ORIGINAL API-derived week_start_at; only its
7448
+ # week_end_at shifts to `effective`. The post-credit ref
7449
+ # (constructed below via the standard `replace`) carries
7450
+ # week_start_at = effective, week_end_at = original.
7451
+ # Order: pre-credit BEFORE post-credit so chronological
7452
+ # iteration in cmd_report's trend table renders them
7453
+ # naturally (older segment above the newer one in DESC
7454
+ # ordering: post-credit is "more recent" so the post-credit
7455
+ # row should come FIRST in the DESC list — but the original
7456
+ # ref was already in DESC position, and we insert pre-credit
7457
+ # AFTER the post-credit. Concretely: post-credit takes the
7458
+ # ref's original slot; pre-credit goes one slot later).
7459
+ if ref.week_end_at in in_place_credit_new_ends:
7460
+ try:
7461
+ reset_dt = parse_iso_datetime(
7462
+ reset_at, "reset_event.effective"
7463
+ )
7464
+ pre_end_date = (
7465
+ # internal fallback: host-local intentional
7466
+ reset_dt - dt.timedelta(seconds=1)
7467
+ ).astimezone().date()
7468
+ pre_credit_ref = replace(
7469
+ ref,
7470
+ week_end_at=reset_at,
7471
+ week_end=pre_end_date,
7472
+ )
7473
+ except ValueError:
7474
+ pre_credit_ref = None
7475
+ else:
7476
+ pre_credit_ref = None
7079
7477
  new_ref = replace(new_ref, week_start_at=reset_at)
7478
+ out.append(new_ref)
7479
+ if pre_credit_ref is not None:
7480
+ out.append(pre_credit_ref)
7481
+ continue
7080
7482
  out.append(new_ref)
7081
7483
  return out
7082
7484
 
@@ -7151,6 +7553,71 @@ def _backfill_week_reset_events(conn: sqlite3.Connection) -> None:
7151
7553
  " effective_reset_at_utc) VALUES (?, ?, ?, ?)",
7152
7554
  (row["captured_at_utc"], prior_end, cur_end, effective_iso),
7153
7555
  )
7556
+ elif prior_end and cur_end == prior_end:
7557
+ # In-place credit branch (v1.7.2). Mirrors the live detection
7558
+ # in cmd_record_usage: same end_at across two captures + ≥25pp
7559
+ # drop in weekly_percent + prior end still in the future at
7560
+ # captured_dt → Anthropic-issued goodwill credit. One event
7561
+ # row with old == new == cur_end, effective = floor_to_hour
7562
+ # of the captured_at when the drop was first observed.
7563
+ try:
7564
+ prior_end_dt = parse_iso_datetime(prior_end, "backfill.prior")
7565
+ captured_dt = parse_iso_datetime(row["captured_at_utc"], "backfill.cap")
7566
+ except ValueError:
7567
+ prior_end = cur_end
7568
+ prior_pct = cur_pct
7569
+ continue
7570
+ if (
7571
+ captured_dt < prior_end_dt
7572
+ and prior_pct is not None and cur_pct is not None
7573
+ and (float(prior_pct) - float(cur_pct)) >= _RESET_PCT_DROP_THRESHOLD
7574
+ ):
7575
+ # Pre-check on ``new_week_end_at`` (mirrors the live
7576
+ # detection path's pre-check). Necessary because the
7577
+ # UNIQUE(old, new) constraint alone WON'T dedup against
7578
+ # legacy/broken-shape rows: pre-fix DBs may have
7579
+ # ``(cur_end, cur_end)`` rows for the same credit that
7580
+ # the new shape writes as ``(effective_iso, cur_end)``.
7581
+ # Without this pre-check, the backfill writes a second
7582
+ # row for the same credit on every open_db() call after
7583
+ # upgrade. (See round-2 review Bug 1.)
7584
+ already = conn.execute(
7585
+ "SELECT 1 FROM week_reset_events "
7586
+ "WHERE new_week_end_at = ? LIMIT 1",
7587
+ (cur_end,),
7588
+ ).fetchone()
7589
+ if already is not None:
7590
+ prior_end = cur_end
7591
+ prior_pct = cur_pct
7592
+ continue
7593
+ # Canonicalize to UTC before isoformat so the stored
7594
+ # offset is `+00:00`, matching the live detection path
7595
+ # (cmd_record_usage uses now_utc which is already UTC).
7596
+ # parse_iso_datetime returns .astimezone() (host-local
7597
+ # fallback at bin/cctally:_local_tz_name gate); without
7598
+ # this normalization, non-UTC hosts would store the
7599
+ # column as e.g. `+03:00`, breaking lex comparisons
7600
+ # downstream (CLAUDE.md gotcha: 5h-block cross-reset
7601
+ # comparisons go through unixepoch(), NOT lex
7602
+ # BETWEEN/</>; the reset-aware DB clamp here applies
7603
+ # the same rule). The reset-aware clamp now wraps both
7604
+ # sides with unixepoch() (Bug 2 fix), but a canonical
7605
+ # UTC offset on write is the right defense-in-depth.
7606
+ effective_iso = (
7607
+ _floor_to_hour(captured_dt.astimezone(dt.timezone.utc))
7608
+ .isoformat(timespec="seconds")
7609
+ )
7610
+ # Row shape: old=effective_iso, new=cur_end (distinct
7611
+ # values). See the live-detection site in
7612
+ # bin/_cctally_record.py for the full rationale; in
7613
+ # short, old==new collapses the credited week to a
7614
+ # zero-width window in _apply_reset_events_to_weekrefs.
7615
+ conn.execute(
7616
+ "INSERT OR IGNORE INTO week_reset_events "
7617
+ "(detected_at_utc, old_week_end_at, new_week_end_at, "
7618
+ " effective_reset_at_utc) VALUES (?, ?, ?, ?)",
7619
+ (row["captured_at_utc"], effective_iso, cur_end, effective_iso),
7620
+ )
7154
7621
  prior_end = cur_end
7155
7622
  prior_pct = cur_pct
7156
7623
  # Flush implicit transaction so callers using explicit BEGIN
@@ -8080,6 +8547,24 @@ def cmd_report(args: argparse.Namespace) -> int:
8080
8547
  week_end_date=current_end.isoformat(),
8081
8548
  )
8082
8549
 
8550
+ # Bug D (v1.7.2 round-4): when an in-place credit event exists for
8551
+ # the current subscription week, `_apply_reset_events_to_weekrefs`
8552
+ # synthesizes a pre-credit ref alongside the post-credit one (both
8553
+ # share `WeekRef.key`). The live "current week" segment is the
8554
+ # POST-credit one (its `week_start_at` was shifted to the
8555
+ # effective reset moment). Route `current_ref` through the same
8556
+ # override so its `week_start_at` reflects the post-credit start;
8557
+ # this lets the per-row match below disambiguate the synthesized
8558
+ # pre-credit ref from the live post-credit ref via both
8559
+ # `key` AND `week_start_at`. Order contract from
8560
+ # `_apply_reset_events_to_weekrefs`: post-credit ref lands at
8561
+ # index 0, pre-credit at index 1. Non-credit weeks return the
8562
+ # single input ref unchanged, so this is a no-op on the common
8563
+ # path.
8564
+ _adjusted_current = _apply_reset_events_to_weekrefs(conn, [current_ref])
8565
+ if _adjusted_current:
8566
+ current_ref = _adjusted_current[0]
8567
+
8083
8568
  weeks = get_recent_weeks(conn, max(1, args.weeks))
8084
8569
  if not weeks:
8085
8570
  # Format-aware empty path mirrors cmd_forecast:18578-18629 — a
@@ -8132,8 +8617,38 @@ def cmd_report(args: argparse.Namespace) -> int:
8132
8617
  except OauthUsageConfigError:
8133
8618
  _fresh_cfg = _get_oauth_usage_config({})
8134
8619
 
8620
+ # Build a set of week_start_date keys that occur MORE THAN ONCE
8621
+ # in `weeks` — these are credited weeks (round-3, Bug B) where
8622
+ # `_apply_reset_events_to_weekrefs` synthesized a pre-credit
8623
+ # ref alongside the post-credit ref. Both refs share
8624
+ # `week_start_date` so the default
8625
+ # ``get_latest_usage_for_week(conn, ref)`` returns the SAME row
8626
+ # (the most recent snapshot in the whole week) for both,
8627
+ # collapsing the pre-credit row's `weeklyPercent` to the
8628
+ # post-credit value. For those keys ONLY, pin `as_of_utc` to
8629
+ # the ref's `week_end_at` so each segment renders its own
8630
+ # latest snapshot. Non-credit weeks (single ref per key) keep
8631
+ # the legacy unfiltered lookup so test fixtures that seed
8632
+ # snapshots OUTSIDE the API-derived week window (e.g.
8633
+ # `test_report_freshness`) keep finding their rows.
8634
+ _split_keys = {
8635
+ r.week_start.isoformat()
8636
+ for r in weeks
8637
+ if sum(
8638
+ 1 for x in weeks
8639
+ if x.week_start.isoformat() == r.week_start.isoformat()
8640
+ ) > 1
8641
+ }
8642
+
8135
8643
  for week_ref in weeks:
8136
- usage = get_latest_usage_for_week(conn, week_ref)
8644
+ key = week_ref.week_start.isoformat()
8645
+ usage = get_latest_usage_for_week(
8646
+ conn,
8647
+ week_ref,
8648
+ as_of_utc=(
8649
+ week_ref.week_end_at if key in _split_keys else None
8650
+ ),
8651
+ )
8137
8652
 
8138
8653
  # For weeks touched by a reset event, the cached
8139
8654
  # weekly_cost_snapshots row covers the API-derived range (which
@@ -8197,7 +8712,20 @@ def cmd_report(args: argparse.Namespace) -> int:
8197
8712
  "age_seconds": int(age_s),
8198
8713
  }
8199
8714
  trend.append(row)
8200
- if week_ref.key == current_ref.key:
8715
+ # Bug D (v1.7.2 round-4): for credited weeks `weeks` contains
8716
+ # TWO refs sharing `key` (pre-credit + post-credit segments).
8717
+ # `current_ref` was routed through
8718
+ # `_apply_reset_events_to_weekrefs` above so its
8719
+ # `week_start_at` reflects the post-credit segment's effective
8720
+ # start (or the original start for non-credit weeks). Match on
8721
+ # BOTH `key` AND `week_start_at` so the pre-credit ref doesn't
8722
+ # overwrite the post-credit row's selection on the second
8723
+ # iteration (last-write-wins on key-only equality picked the
8724
+ # wrong row on the user's live data).
8725
+ if (
8726
+ week_ref.key == current_ref.key
8727
+ and week_ref.week_start_at == current_ref.week_start_at
8728
+ ):
8201
8729
  current_row = row
8202
8730
 
8203
8731
  if current_row is None and trend:
@@ -8637,7 +9165,39 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
8637
9165
  except ValueError:
8638
9166
  pass
8639
9167
 
8640
- milestones = get_milestones_for_week(conn, week_start_date)
9168
+ # v1.7.2 segment filter: when a week_reset_events row exists for
9169
+ # the current ``week_end_at``, narrow the milestone listing to
9170
+ # the active (latest) segment so a credited week's header (which
9171
+ # already reflects the post-credit window via the canon-boundary
9172
+ # rewrite above) is coherent with the body. Sentinel ``0`` covers
9173
+ # pre-credit / no-event weeks; pre-005 DBs that didn't have the
9174
+ # column also default to 0 via the migration's ALTER DEFAULT.
9175
+ active_segment = 0
9176
+ canon_end_for_lookup = None
9177
+ latest_end_row = conn.execute(
9178
+ "SELECT week_end_at FROM weekly_usage_snapshots "
9179
+ "WHERE week_start_date = ? AND week_end_at IS NOT NULL "
9180
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
9181
+ (week_start_date,),
9182
+ ).fetchone()
9183
+ if latest_end_row is not None:
9184
+ canon_end_for_lookup = _canonicalize_optional_iso(
9185
+ latest_end_row["week_end_at"], "pb.cur"
9186
+ )
9187
+ if canon_end_for_lookup:
9188
+ seg_row = conn.execute(
9189
+ "SELECT id FROM week_reset_events "
9190
+ "WHERE new_week_end_at = ? "
9191
+ "ORDER BY id DESC LIMIT 1",
9192
+ (canon_end_for_lookup,),
9193
+ ).fetchone()
9194
+ if seg_row is not None:
9195
+ active_segment = int(seg_row["id"])
9196
+
9197
+ milestones = [
9198
+ m for m in get_milestones_for_week(conn, week_start_date)
9199
+ if int(m["reset_event_id"] or 0) == active_segment
9200
+ ]
8641
9201
 
8642
9202
  milestone_list = []
8643
9203
  for m in milestones:
@@ -8672,7 +9232,17 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
8672
9232
  else:
8673
9233
  print(f"Week: {week_start_date}..{week_end_date}")
8674
9234
  if not milestone_list:
8675
- print("No percent milestones recorded for this week.")
9235
+ if active_segment > 0:
9236
+ # v1.7.2: distinguish post-credit empty (just got
9237
+ # credited, no crossings yet) from genuinely-empty week.
9238
+ # The pre-credit ledger still exists in the DB — just
9239
+ # filtered out of the body — so the user shouldn't see
9240
+ # "No milestones" and assume the data is gone.
9241
+ print(
9242
+ "(post-credit segment, no milestones crossed yet)"
9243
+ )
9244
+ else:
9245
+ print("No percent milestones recorded for this week.")
8676
9246
  return 0
8677
9247
 
8678
9248
  print("Percent breakdown:\n")
@@ -9888,6 +10458,7 @@ def doctor_gather_state(
9888
10458
  # ── Data freshness ───────────────────────────────────────────────
9889
10459
  latest_snapshot_at = None
9890
10460
  forked_bucket_counts: dict | None = None
10461
+ credited_weeks: list[dict] | None = None
9891
10462
  try:
9892
10463
  if DB_PATH.exists():
9893
10464
  conn = sqlite3.connect(str(DB_PATH))
@@ -9924,6 +10495,61 @@ def doctor_gather_state(
9924
10495
  )
9925
10496
  except sqlite3.OperationalError:
9926
10497
  forked_bucket_counts[key] = 0
10498
+ # v1.7.2 credited-week tracking. For each week with a
10499
+ # past-effective ``week_reset_events`` row, gather the
10500
+ # latest weekly_percent + count of post-credit milestones.
10501
+ # The check warns when latest_percent >= 1.0 AND
10502
+ # post_credit_milestone_count == 0.
10503
+ # unixepoch() normalizes the cross-offset comparison.
10504
+ try:
10505
+ credit_rows = conn.execute(
10506
+ """
10507
+ SELECT wre.id AS event_id,
10508
+ wre.new_week_end_at AS end_at,
10509
+ wre.effective_reset_at_utc AS effective
10510
+ FROM week_reset_events wre
10511
+ WHERE unixepoch(wre.effective_reset_at_utc)
10512
+ <= unixepoch(?)
10513
+ """,
10514
+ (now_utc_iso(),),
10515
+ ).fetchall()
10516
+ credited_weeks = []
10517
+ for cr in credit_rows:
10518
+ end_at = cr[1]
10519
+ evt_id = cr[0]
10520
+ latest = conn.execute(
10521
+ """
10522
+ SELECT week_start_date, weekly_percent
10523
+ FROM weekly_usage_snapshots
10524
+ WHERE week_end_at = ?
10525
+ ORDER BY captured_at_utc DESC, id DESC
10526
+ LIMIT 1
10527
+ """,
10528
+ (end_at,),
10529
+ ).fetchone()
10530
+ if latest is None or latest[0] is None:
10531
+ continue
10532
+ ws = latest[0]
10533
+ lp = float(latest[1] or 0.0)
10534
+ try:
10535
+ mc_row = conn.execute(
10536
+ "SELECT COUNT(*) FROM percent_milestones "
10537
+ "WHERE week_start_date = ? AND reset_event_id = ?",
10538
+ (ws, evt_id),
10539
+ ).fetchone()
10540
+ mc = int(mc_row[0]) if mc_row and mc_row[0] else 0
10541
+ except sqlite3.OperationalError:
10542
+ mc = 0
10543
+ credited_weeks.append({
10544
+ "week_start_date": ws,
10545
+ "latest_weekly_percent": lp,
10546
+ "post_credit_milestone_count": mc,
10547
+ "event_id": evt_id,
10548
+ })
10549
+ except sqlite3.OperationalError:
10550
+ # week_reset_events table missing — treat as no
10551
+ # credited weeks (pre-feature DB).
10552
+ credited_weeks = []
9927
10553
  finally:
9928
10554
  conn.close()
9929
10555
  except Exception:
@@ -10069,6 +10695,7 @@ def doctor_gather_state(
10069
10695
  cache_last_entry_at=cache_last_entry_at,
10070
10696
  claude_jsonl_present=claude_jsonl_present,
10071
10697
  forked_bucket_counts=forked_bucket_counts,
10698
+ credited_weeks=credited_weeks,
10072
10699
  codex_entries_count=codex_entries_count,
10073
10700
  codex_last_entry_at=codex_last_entry_at,
10074
10701
  codex_jsonl_present=codex_jsonl_present,