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/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.2] - 2026-05-16
9
+
10
+ ### Fixed
11
+ - `cctally record-usage`: detect Anthropic-issued in-place weekly credits (utilization drops while `resets_at` stays unchanged) and emit a `week_reset_events` row + force-write `hwm-7d` + seed a post-credit snapshot so dashboard / forecast / report / percent-breakdown / TUI stop freezing at the pre-credit high-water mark. Fires on a `>=25.0pp` drop, the same threshold as the existing boundary-shift path that catches Anthropic-shifted `resets_at` advances mid-week. Deduped via belt-and-suspenders: a pre-check `SELECT 1 FROM week_reset_events WHERE new_week_end_at = ?` short-circuits before any INSERT attempt, the `UNIQUE(old_week_end_at, new_week_end_at)` DDL constraint absorbs any race that slips past, and the post-credit seed snapshot brings prior_pct down to ~current_pct so the next tick's drop predicate is naturally below threshold (single trigger per credit). HWM file `hwm-7d` is force-written via the credit-only escape hatch since the normal monotonic-up write path at `bin/_cctally_record.py:1511-1525` would refuse to decrease it; force-write lands AFTER `conn.commit()` of the event row so a concurrent reader doesn't see the new HWM before the durable signal of the credit.
12
+ - `cctally record-usage`: the monotonic 7d DB clamp now joins against `week_reset_events`, so post-credit `MAX(weekly_percent)` filters to samples captured at-or-after `effective_reset_at_utc`. Fresh OAuth values land naturally instead of being held back by pre-credit history. No-op when no event row exists for the week (`COALESCE` defaults the filter to epoch-zero, so the regression-guard `test_reset_aware_clamp_without_event_preserves_legacy_behavior` confirms byte-identical legacy clamp behavior on un-credited weeks).
13
+ - `cctally`: `_backfill_week_reset_events` extended to detect historical in-place weekly credits in existing DBs via the same predicate as the live path — parallel `elif prior_end == cur_end` branch inside the existing scan loop, same `prior_end_dt > captured_dt` + `>=25pp` drop gate, same `_floor_to_hour` for the effective moment. Idempotent via `UNIQUE(old_week_end_at, new_week_end_at)` + `INSERT OR IGNORE`; the existing boundary-shift branch is byte-identical to v1.7.1 so existing user DBs synthesize event rows for past credits without affecting prior backfill output.
14
+ - `cctally percent_milestones`: schema migration 005 adds a `reset_event_id` column (default 0 = pre-credit segment / no-event sentinel) and reshapes the UNIQUE constraint from `(week_start_date, percent_threshold)` to `(week_start_date, percent_threshold, reset_event_id)` so post-credit threshold crossings land as separate rows from any pre-credit ones at the same threshold. SQLite can't ALTER a UNIQUE constraint in place — the handler uses the rename-recreate-copy idiom inside its own `BEGIN/COMMIT`; fast-path probe stamps the marker without re-doing the rename when the column is already present (covers fresh-install + partial-failure-retry cases). Existing rows backfill to `reset_event_id = 0` via the column DEFAULT; the migration's per-migration goldens at `tests/fixtures/migrations/per-migration/005_percent_milestones_reset_event_id/{pre,post}.sqlite` are the first lazy-adopted entries under that directory pattern.
15
+ - `cctally percent-breakdown` + dashboard milestone panel + TUI percent-milestones panel: now filter milestone rows by the active `week_reset_events` segment for the queried week (the latest event keyed on the canonical hour-floored `week_end_at`). A credited week's header (which already reflects the post-credit window via the canon-boundary rewrite) is now coherent with its body — pre- and post-credit crossings read as independent ledgers. An empty post-credit segment renders a distinct "(post-credit segment, no milestones crossed yet)" hint so the user can distinguish a freshly-credited week from a genuinely silent one; without this, a fresh-credited week would render "No percent milestones recorded for this week" while the pre-credit ledger is still intact in the DB.
16
+ - `cctally` milestone writer (`maybe_record_milestone` + helpers): now stamps the active `week_reset_events.id` into `percent_milestones.reset_event_id` so post-credit threshold crossings land as separate rows; `get_max_milestone_for_week`, `get_milestone_cost_for_week`, the `alerted_at` UPDATE inside the writer, and the post-INSERT cumulative-cost SELECT for the alert payload all gained a `reset_event_id` filter. Active-segment resolution uses `unixepoch()` on both sides of the `<=` comparison to absorb the `+00:00` vs `Z` offset mix between `week_reset_events.effective_reset_at_utc` (stored as `+00:00`) and a snapshot's `captured_at_utc` (stored as `Z`). Combined with the new UNIQUE shape this means a credited week sees post-credit 1% / 2% / 3% alerts fire fresh even if the pre-credit ledger already crossed those thresholds; the self-heal probe in the dedup-no-insert bail-out path is now also segment-scoped so the post-credit ledger doesn't get silently suppressed by a high pre-credit MAX.
17
+ - `cctally doctor`: new `data.post_credit_milestones` check warns when a credited week (`week_reset_events` row with effective < now) has `latest_weekly_percent >= 1.0` AND zero post-credit milestone rows. Informational WARN (no remediation), since the next `record-usage` tick at >=1% will self-heal via the segment-aware probe — surfaces the upgrade-window gap between when the credit lands and when the user accumulates enough usage to cross the post-credit 1% threshold. The `weekly_percent < 1.0` short-circuit prevents false-positive warns immediately after a credit when the user simply hasn't started using the new segment yet.
18
+ - `cctally record-usage`: round-3 user-test follow-up — defensive cleanup in the in-place credit detection branch. Between the moment Anthropic credits the user and `cctally record-usage` firing, the external `claude-statusline` tool can replay stale pre-credit `--percent` values (its in-memory HWM cache hasn't refreshed yet) — those replays land `captured_at_utc >= effective_reset_at_utc` and poison the reset-aware clamp's MAX over the post-credit segment, blocking legitimate fresh OAuth values from landing. The credit branch now runs a narrow DELETE pass scoped to `(week_start_date = ?, captured_at_utc >= effective_iso, round(weekly_percent, 1) = round(prior_pct, 1))` after writing the event row + force-writing `hwm-7d`. Strict-equality predicate avoids deleting legitimate post-credit climbs. Reported by user on the v1.7.2 dev branch with manual recovery already applied to the production DB; fix prevents recurrence.
19
+ - `cctally report` / `weekly`: round-3 user-test follow-up — credited weeks now render as TWO trend rows (pre-credit segment closed at `effective_reset_at_utc` AND post-credit segment opening at `effective_reset_at_utc`). Previously only the post-credit segment surfaced and the pre-credit segment's usage + cost (the bulk of the week's spend in the originating incident: 67% / $1484 across 6 days) was silently dropped from the trend table. `_apply_reset_events_to_weekrefs` synthesizes the pre-credit ref alongside the post-credit one for events whose row shape is `old_week_end_at == effective_reset_at_utc` (the in-place credit marker — boundary-shift events stay single-ref). `cmd_report`'s per-trend-row usage lookup now passes `as_of_utc = ref.week_end_at` for credited weeks so each segment renders its own latest snapshot (the shared `week_start_date` lookup key would otherwise return the post-credit value for both rows); non-credit weeks still use the unfiltered lookup so existing test fixtures that seed snapshots outside the API-derived week window keep finding their rows.
20
+ - `cctally blocks`: round-3 user-test follow-up — `_load_recorded_five_hour_windows` now overlays canonical anchors from `five_hour_blocks.five_hour_resets_at` (heavy-weight = 1000 per row) on top of the existing `weekly_usage_snapshots.five_hour_resets_at` source. The canonical rollup table holds the API-anchored 5h reset moment after `_canonical_5h_window_key` has absorbed Anthropic's seconds-level jitter; the heavy weight ensures that whenever the rollup table sees a window, the rollup's anchor always wins over any jittered raw snapshot value at the same 10-minute-floored key. Symptom this fixes: after an in-place credit, `cctally blocks` showed the ACTIVE row with the heuristic `~HH:MM` prefix while `cctally five-hour-blocks` correctly showed `⚡ HH:MM` (API-anchored). Both views now agree on the API anchor whenever the rollup table has it.
21
+ - `cctally report`: round-4 user-test follow-up — the "current week" summary box no longer renders the PRE-credit row (the closed segment) for credited weeks. Round-3's pre-credit ref synthesis in `_apply_reset_events_to_weekrefs` left both refs sharing `WeekRef.key`, so the match predicate `week_ref.key == current_ref.key` matched BOTH refs in `cmd_report`'s `current_row` loop and last-write-wins picked whichever was processed last. `current_ref` is now routed through `_apply_reset_events_to_weekrefs` itself so its `week_start_at` reflects the post-credit segment's effective start, and the row-match tightens to require BOTH `key` AND `week_start_at` equality — the pre-credit ref (original `week_start_at`) no longer overwrites the post-credit row's selection. Non-credited weeks are unaffected (`_apply_reset_events_to_weekrefs` is a no-op without an event row). On the user's live DB: summary box now correctly shows post-credit `4%` instead of pre-credit `67%`.
22
+ - `cctally blocks`: round-4 user-test follow-up — the ACTIVE 5h row now swaps to the API-anchored window when a canonical `five_hour_blocks` row exists for the current `five_hour_window_key`. Round-3's anchor-overlay in `_load_recorded_five_hour_windows` only fires when heuristic and canonical fall in the same 10-minute floor bucket; the user's heuristic ACTIVE anchor at `23:00 IDT` and the API-anchored `20:50 IDT` are 130 minutes apart (different floor buckets) so the swap didn't trigger. A new post-pass helper `_maybe_swap_active_block_to_canonical` looks up the live key from `weekly_usage_snapshots`, joins to `five_hour_blocks`, and — when the canonical window is still open relative to `now` — rewrites the active block's `start_time` / `end_time` to the canonical pair and flips `anchor` to `"recorded"` so the renderer drops the `~` prefix. Skips cleanly when no canonical row matches or the canonical window has already closed (then the heuristic anchor reflects genuine ongoing activity in a later window). `cctally blocks` ACTIVE row + `cctally five-hour-blocks` ACTIVE row now agree on the API anchor.
23
+ - `cctally blocks`: round-5 user-test follow-up — the active-block canonical swap now ALSO re-aggregates token / cost totals over the canonical interval (Bug F). The round-4 swap only rewrote the displayed timestamps; the underlying entries were still grouped against the heuristic anchor's `[heuristic_start, heuristic_end)` interval, so a displayed `20:50 IDT → 01:50 IDT` window could show cost from the heuristic's `23:00 → 04:00` group instead — on live data the user saw the swapped window with a $45.42 total when the canonical window's real cost was $128+. `_maybe_swap_active_block_to_canonical` now takes the `all_entries` list, filters to `[canonical_block_start, canonical_block_end)`, and rebuilds the block via `_build_activity_block(...)` so every total stays in one code path (no field-by-field assignment that could drift if the dataclass grows). The displayed timestamps and totals are now coherent on every active-block swap.
24
+ - `cctally` dashboard envelope `trend.weeks[]` (and `cctally weekly-history`): round-5 user-test follow-up — credited weeks now render as TWO trend rows with correct per-segment `used_pct` values (Bug G). Round-3 fixed `cmd_report`'s trend table but `_tui_build_trend` (in `bin/_cctally_tui.py`) — which feeds the dashboard's `trend.weeks[]` envelope, the dashboard share modal's trend panel, and `weekly-history` — still used a key-only `get_latest_usage_for_week(conn, week_ref)` lookup that returned the SAME post-credit snapshot for both segments (both refs share `WeekRef.key`). User saw "May 09 4%" and "May 15 4%" side-by-side in the dashboard's `$/1% Trend` panel — both segments collapsed to the post-credit value. The fix mirrors `cmd_report`'s pattern: detect split-keys (where multiple refs in `week_refs` share `key`), pin `as_of_utc = week_ref.week_end_at` for those refs so each segment finds its own latest snapshot, and route the current_ref through `_apply_reset_events_to_weekrefs` so the `is_current` predicate can disambiguate by BOTH `key` AND `week_start_at` (not just key). Non-credit weeks keep the legacy unfiltered lookup so existing fixtures stay byte-stable.
25
+ - `cctally` dashboard Weekly panel: round-5 user-test follow-up — credited weeks now show TWO rows in the Weekly panel (pre-credit + post-credit segments) instead of silently dropping the pre-credit interval (Bug K). `_apply_reset_events_to_subweeks` shifts the credited SubWeek's `start_ts` to `effective_reset_at_utc`, so `_aggregate_weekly`'s bucket for that week covers ONLY the post-credit interval; the bulk of the week's cost (the user's $1491 of pre-credit spend) was invisible in the panel — only the $134 post-credit segment showed up. `_dashboard_build_weekly_periods` now post-processes its bucket-built rows: for each in-place credit event (`old_week_end_at == effective_reset_at_utc` shape) whose post-credit SubWeek end_ts matches a built row, the dashboard re-walks the entries list filtered to `[original_start, effective)`, re-aggregates cost / tokens / per-model via `_calculate_entry_cost`, looks up `weekly_percent` capped to `captured_at_utc <= effective_reset_at_utc` (the pre-credit peak), and inserts a synthesized pre-credit `WeeklyPeriodRow` BEFORE the post-credit row. Pre-credit row's label uses the original start date, post-credit's label keeps the effective reset date; `is_current` only fires on the post-credit row (the live segment). No-op on non-credit weeks.
26
+ - `cctally blocks`: round-5 user-test follow-up — eliminated the phantom heuristic "~" block that appeared between two canonical blocks after an in-place credit (Bug J). Anthropic's credit creates two overlapping canonical 5h windows: the pre-credit `[block_start_at, original_resets_at]` (e.g. 15:50 → 20:50 UTC) and the post-credit `[block_start_at, new_resets_at]` (e.g. 17:50 → 22:50 UTC). `_select_non_overlapping_recorded_windows` enforces the 5h-disjoint invariant by dropping ONE of the overlapping anchors — leaving the dropped block's entries unanchored and rendered as a heuristic "~" row at $45 sandwiched between the two real canonical rows (visible on the user's `cctally blocks` output). `_load_recorded_five_hour_windows` now detects overlapping canonical pairs where an in-place credit moment falls inside the overlap, truncates the EARLIER block's R to the credit moment (10-min floor), and records `(truncated_R → original_block_start)` in a new `block_start_overrides` map returned alongside the anchor list. Truncated anchors bypass the weighted scheduler (their non-overlap is guaranteed by construction, but the scheduler treats every R as the end of a full 5h window and would still flag them as colliding with the adjacent earlier block). `_group_entries_into_blocks` accepts `block_start_overrides` and uses it both for entry partitioning (the bucket's lower bound becomes the override, not `R - 5h`) and display (the recorded block's `start_time` becomes the override, not `R - 5h` which would be hours before the real start). Result: the truncated block displays correctly with its real `block_start_at` and its real ~2-hour duration instead of the misleading 5h heuristic window. Threaded through `cmd_blocks`, `_dashboard_build_blocks_panel`, and the dashboard `/api/block/:start_at` handler so all three callers see the same anchor set.
27
+
8
28
  ## [1.7.1] - 2026-05-15
9
29
 
10
30
  ### Fixed
@@ -1955,6 +1955,176 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
1955
1955
  week_end_at=sw.end_ts,
1956
1956
  ))
1957
1957
 
1958
+ # Bug K (v1.7.2 round-5): synthesize a pre-credit segment row for
1959
+ # each in-place credit event. Without this the credited week shows
1960
+ # ONLY the post-credit segment ($134 on live data) and the bulk of
1961
+ # the week's cost (~$372 in entries before the credit moment) is
1962
+ # invisible to the user.
1963
+ #
1964
+ # _apply_reset_events_to_subweeks shifts the credited SubWeek's
1965
+ # start_ts to ``effective_reset_at_utc``, so _aggregate_weekly's
1966
+ # bucket for that SubWeek already covers ONLY the post-credit
1967
+ # interval. We rebuild the pre-credit bucket here by filtering the
1968
+ # same ``entries`` list to ``[original_start, effective)`` and
1969
+ # re-aggregating cost / tokens / per-model.
1970
+ #
1971
+ # The pre-credit row's ``used_pct`` comes from the
1972
+ # weekly_usage_snapshots row captured at-or-before the credit
1973
+ # moment (the pre-credit peak the user reached); fall back to None
1974
+ # if no snapshot was recorded before the credit fired.
1975
+ in_place_credits = conn.execute(
1976
+ "SELECT new_week_end_at, effective_reset_at_utc "
1977
+ "FROM week_reset_events "
1978
+ "WHERE old_week_end_at = effective_reset_at_utc"
1979
+ ).fetchall()
1980
+ if in_place_credits:
1981
+ _lib_pricing = sys.modules.get("_lib_pricing")
1982
+ if _lib_pricing is None:
1983
+ import importlib.util as _ilu, pathlib as _pl
1984
+ _p = _pl.Path(__file__).resolve().parent / "_lib_pricing.py"
1985
+ _spec = _ilu.spec_from_file_location("_lib_pricing", _p)
1986
+ _lib_pricing = _ilu.module_from_spec(_spec)
1987
+ sys.modules["_lib_pricing"] = _lib_pricing
1988
+ _spec.loader.exec_module(_lib_pricing)
1989
+ _calc = _lib_pricing._calculate_entry_cost
1990
+
1991
+ insertions: list[tuple[int, WeeklyPeriodRow]] = []
1992
+ for ev in in_place_credits:
1993
+ try:
1994
+ eff_dt = parse_iso_datetime(
1995
+ ev["effective_reset_at_utc"], "credit.eff"
1996
+ )
1997
+ new_end_dt = parse_iso_datetime(
1998
+ ev["new_week_end_at"], "credit.new_end"
1999
+ )
2000
+ except ValueError:
2001
+ continue
2002
+ # Find the SubWeek whose end_ts equals new_week_end_at (the
2003
+ # post-credit segment); its start_ts has already been
2004
+ # shifted to ``effective`` by _apply_reset_events_to_subweeks.
2005
+ post_sw = None
2006
+ for w in weeks:
2007
+ try:
2008
+ w_end = parse_iso_datetime(w.end_ts, "sw.end")
2009
+ except ValueError:
2010
+ continue
2011
+ if w_end == new_end_dt:
2012
+ post_sw = w
2013
+ break
2014
+ if post_sw is None:
2015
+ continue
2016
+
2017
+ # Original start instant: take the EARLIEST recorded
2018
+ # week_start_at for this week_start_date. The post-credit
2019
+ # SubWeek's start_ts is the shifted value (= effective); the
2020
+ # MIN over weekly_usage_snapshots gives us the original
2021
+ # API-derived start before the override fired.
2022
+ orig_row = conn.execute(
2023
+ "SELECT MIN(week_start_at) AS ws "
2024
+ "FROM weekly_usage_snapshots "
2025
+ "WHERE week_start_date = ? AND week_start_at IS NOT NULL",
2026
+ (post_sw.start_date.isoformat(),),
2027
+ ).fetchone()
2028
+ if orig_row is None or orig_row["ws"] is None:
2029
+ continue
2030
+ try:
2031
+ original_start_iso = str(orig_row["ws"])
2032
+ original_start_dt = parse_iso_datetime(
2033
+ original_start_iso, "credit.original_start"
2034
+ )
2035
+ except ValueError:
2036
+ continue
2037
+ if original_start_dt >= eff_dt:
2038
+ # No pre-credit interval to aggregate.
2039
+ continue
2040
+
2041
+ # Aggregate entries in [original_start, effective).
2042
+ pre_input = pre_output = pre_cc = pre_cr = 0
2043
+ pre_cost = 0.0
2044
+ pre_models: dict[str, float] = {}
2045
+ pre_entry_count = 0
2046
+ for e in entries:
2047
+ if original_start_dt <= e.timestamp < eff_dt:
2048
+ usage = e.usage
2049
+ pre_input += usage.get("input_tokens", 0)
2050
+ pre_output += usage.get("output_tokens", 0)
2051
+ pre_cc += usage.get("cache_creation_input_tokens", 0)
2052
+ pre_cr += usage.get("cache_read_input_tokens", 0)
2053
+ c = _calc(
2054
+ e.model, usage, mode="auto", cost_usd=e.cost_usd,
2055
+ )
2056
+ pre_cost += c
2057
+ pre_models[e.model] = pre_models.get(e.model, 0.0) + c
2058
+ pre_entry_count += 1
2059
+ if pre_entry_count == 0 and pre_cost <= 0:
2060
+ # No measurable pre-credit activity — skip insertion.
2061
+ continue
2062
+
2063
+ # Pre-credit used_pct: latest snapshot at-or-before the
2064
+ # credit moment for this week_start_date.
2065
+ pre_usage = conn.execute(
2066
+ "SELECT weekly_percent FROM weekly_usage_snapshots "
2067
+ "WHERE week_start_date = ? "
2068
+ " AND unixepoch(captured_at_utc) <= unixepoch(?) "
2069
+ "ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
2070
+ (post_sw.start_date.isoformat(), ev["effective_reset_at_utc"]),
2071
+ ).fetchone()
2072
+ pre_used_pct: float | None = None
2073
+ if pre_usage is not None and pre_usage["weekly_percent"] is not None:
2074
+ pre_used_pct = float(pre_usage["weekly_percent"])
2075
+ pre_dpp = (
2076
+ pre_cost / pre_used_pct
2077
+ if pre_used_pct and pre_used_pct > 0 else None
2078
+ )
2079
+
2080
+ pre_total = pre_input + pre_output + pre_cc + pre_cr
2081
+ pre_model_breakdowns = [
2082
+ {"modelName": m, "cost": c}
2083
+ for m, c in sorted(pre_models.items(), key=lambda kv: -kv[1])
2084
+ ]
2085
+ pre_label = original_start_dt.strftime("%m-%d")
2086
+ pre_row = WeeklyPeriodRow(
2087
+ label=pre_label,
2088
+ cost_usd=pre_cost,
2089
+ total_tokens=pre_total,
2090
+ input_tokens=pre_input,
2091
+ output_tokens=pre_output,
2092
+ cache_creation_tokens=pre_cc,
2093
+ cache_read_tokens=pre_cr,
2094
+ used_pct=pre_used_pct,
2095
+ dollar_per_pct=pre_dpp,
2096
+ delta_cost_pct=None,
2097
+ # Pre-credit segment is historical even though it
2098
+ # shares the bucket date with the live week.
2099
+ is_current=False,
2100
+ models=_model_breakdowns_to_models(
2101
+ pre_model_breakdowns, pre_cost
2102
+ ),
2103
+ week_start_at=original_start_iso,
2104
+ week_end_at=ev["effective_reset_at_utc"],
2105
+ )
2106
+
2107
+ # Find post-credit row's index and insert pre-credit BEFORE
2108
+ # it (chronological order: pre then post in oldest-first).
2109
+ post_idx = None
2110
+ for i, r in enumerate(rows_oldest_first):
2111
+ if r.week_start_at == post_sw.start_ts and r.week_end_at == post_sw.end_ts:
2112
+ post_idx = i
2113
+ break
2114
+ if post_idx is None:
2115
+ # The post-credit row may have been dropped by
2116
+ # _aggregate_weekly (no entries in the post-credit
2117
+ # interval) — append at the most-recent slot so the
2118
+ # pre-credit segment still surfaces.
2119
+ insertions.append((len(rows_oldest_first), pre_row))
2120
+ else:
2121
+ insertions.append((post_idx, pre_row))
2122
+
2123
+ # Apply insertions in REVERSE index order so prior insertions
2124
+ # don't shift the indices of later ones.
2125
+ for idx, pre_row in sorted(insertions, key=lambda t: -t[0]):
2126
+ rows_oldest_first.insert(idx, pre_row)
2127
+
1958
2128
  # Reverse so caller gets newest-first; compute delta_cost_pct vs the
1959
2129
  # immediately older row in that orientation.
1960
2130
  rows = list(reversed(rows_oldest_first))
@@ -2091,10 +2261,14 @@ def _dashboard_build_blocks_panel(conn: "sqlite3.Connection",
2091
2261
  entries = get_entries(fetch_start, fetch_end, skip_sync=skip_sync)
2092
2262
  entries = [e for e in entries if week_start_at <= e.timestamp < week_end_at]
2093
2263
 
2094
- recorded_windows = _load_recorded_five_hour_windows(fetch_start, fetch_end)
2264
+ recorded_windows, block_start_overrides = _load_recorded_five_hour_windows(
2265
+ fetch_start, fetch_end,
2266
+ )
2095
2267
  blocks = _group_entries_into_blocks(
2096
2268
  entries, mode="auto",
2097
- recorded_windows=recorded_windows, now=now_utc,
2269
+ recorded_windows=recorded_windows,
2270
+ block_start_overrides=block_start_overrides,
2271
+ now=now_utc,
2098
2272
  )
2099
2273
  blocks = [b for b in blocks if not b.is_gap]
2100
2274
  if not blocks:
@@ -2397,10 +2571,17 @@ def _build_alerts_envelope_array(
2397
2571
  final sort.
2398
2572
  """
2399
2573
  out: list[dict] = []
2574
+ # ``reset_event_id`` (v1.7.2) segments the same (week, threshold)
2575
+ # across pre-credit (0) and post-credit (event.id) cohorts, both
2576
+ # of which can be alerted. The envelope id must include the
2577
+ # segment so React's <li key={a.id}> / <tr key={a.id}> doesn't
2578
+ # collide on the duplicate (week, threshold) pair. Older clients
2579
+ # tolerate longer ids — the id is opaque to them; only the React
2580
+ # key uniqueness invariant matters.
2400
2581
  weekly_rows = conn.execute(
2401
2582
  """
2402
2583
  SELECT week_start_date, percent_threshold, captured_at_utc,
2403
- alerted_at, cumulative_cost_usd
2584
+ alerted_at, cumulative_cost_usd, reset_event_id
2404
2585
  FROM percent_milestones
2405
2586
  WHERE alerted_at IS NOT NULL
2406
2587
  ORDER BY alerted_at DESC
@@ -2413,7 +2594,7 @@ def _build_alerts_envelope_array(
2413
2594
  cumulative = float(r["cumulative_cost_usd"])
2414
2595
  dpp = (cumulative / threshold) if threshold else None
2415
2596
  out.append({
2416
- "id": f"weekly:{r['week_start_date']}:{threshold}",
2597
+ "id": f"weekly:{r['week_start_date']}:{threshold}:{r['reset_event_id']}",
2417
2598
  "axis": "weekly",
2418
2599
  "threshold": threshold,
2419
2600
  "crossed_at": r["captured_at_utc"],
@@ -4658,8 +4839,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4658
4839
  now_utc = _command_as_of()
4659
4840
  # Recorded-windows lookup widens by one block on each side so
4660
4841
  # a recorded reset just outside the bounds can still anchor.
4661
- recorded_windows = _load_recorded_five_hour_windows(
4662
- start_at - BLOCK_DURATION, end_at + BLOCK_DURATION,
4842
+ recorded_windows, block_start_overrides = (
4843
+ _load_recorded_five_hour_windows(
4844
+ start_at - BLOCK_DURATION, end_at + BLOCK_DURATION,
4845
+ )
4663
4846
  )
4664
4847
  # Entries: only the window we care about. Mirrors the panel's
4665
4848
  # discipline of pre-filtering before grouping (cf.
@@ -4677,7 +4860,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4677
4860
  ))
4678
4861
  blocks = _group_entries_into_blocks(
4679
4862
  entries_in_window, mode="auto",
4680
- recorded_windows=recorded_windows, now=now_utc,
4863
+ recorded_windows=recorded_windows,
4864
+ block_start_overrides=block_start_overrides,
4865
+ now=now_utc,
4681
4866
  )
4682
4867
  target = next(
4683
4868
  (b for b in blocks
@@ -1329,6 +1329,114 @@ def _migration_heal_forked_week_start_date_buckets(conn: sqlite3.Connection) ->
1329
1329
  raise
1330
1330
 
1331
1331
 
1332
+ @stats_migration("005_percent_milestones_reset_event_id")
1333
+ def _migration_percent_milestones_reset_event_id(conn: sqlite3.Connection) -> None:
1334
+ """Add ``reset_event_id`` to ``percent_milestones`` so post-credit
1335
+ threshold crossings can coexist with pre-credit ones for the same
1336
+ ``(week_start_date, percent_threshold)``.
1337
+
1338
+ Sentinel: ``0`` = pre-credit / no event. Existing rows backfill to
1339
+ ``0`` via the ``DEFAULT 0`` clause on the new column.
1340
+
1341
+ The new UNIQUE constraint is
1342
+ ``UNIQUE(week_start_date, percent_threshold, reset_event_id)`` so the
1343
+ same (week, threshold) pair can land twice if a goodwill credit
1344
+ re-opens the segment under a fresh ``week_reset_events.id``. SQLite
1345
+ can't ALTER a UNIQUE constraint in place — we use the
1346
+ rename-recreate-copy idiom.
1347
+
1348
+ Companion live-path edits: ``cmd_record_usage`` now stamps the
1349
+ active segment (the latest ``week_reset_events.id`` for the
1350
+ current ``new_week_end_at``, else 0) into ``reset_event_id``; the
1351
+ in-place credit detection branch can re-fire the same threshold
1352
+ after a credit.
1353
+
1354
+ Idempotent: a second invocation finds the column already present
1355
+ and returns. Empty-table fast path: when the column is already
1356
+ present the marker still gets stamped — no schema edit needed.
1357
+ """
1358
+ # Fast-path probe: column already present means a prior run of this
1359
+ # migration (or a fresh-install fast-stamp from the dispatcher that
1360
+ # already picked up the new live-schema CREATE TABLE) has done the
1361
+ # work. Just stamp the marker and return.
1362
+ cols = {
1363
+ str(r[1])
1364
+ for r in conn.execute("PRAGMA table_info(percent_milestones)").fetchall()
1365
+ }
1366
+ if "reset_event_id" in cols:
1367
+ conn.execute(
1368
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1369
+ "VALUES (?, ?)",
1370
+ ("005_percent_milestones_reset_event_id", now_utc_iso()),
1371
+ )
1372
+ conn.commit()
1373
+ return
1374
+
1375
+ conn.execute("BEGIN")
1376
+ try:
1377
+ # Add the column with sentinel 0 default (covers existing rows).
1378
+ conn.execute(
1379
+ "ALTER TABLE percent_milestones "
1380
+ "ADD COLUMN reset_event_id INTEGER NOT NULL DEFAULT 0"
1381
+ )
1382
+ # SQLite can't ALTER a UNIQUE constraint in place; rename, recreate
1383
+ # with the new 3-column UNIQUE, copy, drop. Preserves ids and every
1384
+ # existing column (including those added by add_column_if_missing:
1385
+ # five_hour_percent_at_crossing, alerted_at).
1386
+ conn.execute(
1387
+ "ALTER TABLE percent_milestones RENAME TO percent_milestones_old_005"
1388
+ )
1389
+ conn.execute(
1390
+ """
1391
+ CREATE TABLE percent_milestones (
1392
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1393
+ captured_at_utc TEXT NOT NULL,
1394
+ week_start_date TEXT NOT NULL,
1395
+ week_end_date TEXT NOT NULL,
1396
+ week_start_at TEXT,
1397
+ week_end_at TEXT,
1398
+ percent_threshold INTEGER NOT NULL,
1399
+ cumulative_cost_usd REAL NOT NULL,
1400
+ marginal_cost_usd REAL,
1401
+ usage_snapshot_id INTEGER NOT NULL,
1402
+ cost_snapshot_id INTEGER NOT NULL,
1403
+ five_hour_percent_at_crossing REAL,
1404
+ alerted_at TEXT,
1405
+ reset_event_id INTEGER NOT NULL DEFAULT 0,
1406
+ UNIQUE(week_start_date, percent_threshold, reset_event_id)
1407
+ )
1408
+ """
1409
+ )
1410
+ conn.execute(
1411
+ """
1412
+ INSERT INTO percent_milestones (
1413
+ id, captured_at_utc, week_start_date, week_end_date,
1414
+ week_start_at, week_end_at, percent_threshold,
1415
+ cumulative_cost_usd, marginal_cost_usd,
1416
+ usage_snapshot_id, cost_snapshot_id,
1417
+ five_hour_percent_at_crossing, alerted_at, reset_event_id
1418
+ )
1419
+ SELECT id, captured_at_utc, week_start_date, week_end_date,
1420
+ week_start_at, week_end_at, percent_threshold,
1421
+ cumulative_cost_usd, marginal_cost_usd,
1422
+ usage_snapshot_id, cost_snapshot_id,
1423
+ five_hour_percent_at_crossing, alerted_at,
1424
+ reset_event_id
1425
+ FROM percent_milestones_old_005
1426
+ """
1427
+ )
1428
+ conn.execute("DROP TABLE percent_milestones_old_005")
1429
+ conn.execute(
1430
+ "INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
1431
+ "VALUES (?, ?)",
1432
+ ("005_percent_milestones_reset_event_id", now_utc_iso()),
1433
+ )
1434
+ conn.commit()
1435
+ except Exception:
1436
+ conn.rollback()
1437
+ raise
1438
+
1439
+
1332
1440
  # === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
1333
1441
 
1334
1442
  # ──────────────────────────────────────────────────────────────────────