cctally 1.7.1 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.3] - 2026-05-16
9
+
10
+ ### Added
11
+ - `cctally record-usage`: detect in-place 5h credits on a `>=5.0pp` drop within the same canonical `five_hour_window_key` while `prior_5h_resets_at > now_utc`; write a `five_hour_reset_events` row with the credit moment floored to a 10-minute slot on `effective_reset_at_utc` (supports stacked credits across DISTINCT 10-min slots up to ~30 per 5h block; same-slot collisions silently absorbed by `INSERT OR IGNORE` per spec §2.3 documented cap — an intentional bound, not a bug); force-write `hwm-5h` via the credit-only escape hatch since the normal monotonic-up write at `bin/_cctally_record.py:1722-1731` would refuse to decrease it; then DELETE stale-replica snapshots scoped to `(five_hour_window_key = ?, captured_at_utc >= effective_iso, round(five_hour_percent, 1) = round(prior_5h_pct, 1))` so `claude-statusline`'s in-memory replay of pre-credit `--percent` values across the credit moment cannot poison the post-credit clamp segment. Mirrors the v1.7.2 weekly credit-detection path parallel-not-identical; threshold is 5pp (not 25) because intra-block percents are smaller, and `effective_reset_at_utc` floor is 10 minutes (not the hour) so distinct intra-hour stacked credits stay reachable.
12
+ - schema: new `five_hour_reset_events` table with columns `(detected_at_utc, five_hour_window_key, prior_percent, post_percent, effective_reset_at_utc)` and `UNIQUE(five_hour_window_key, effective_reset_at_utc)`. Payload diverges from the weekly precedent (`prior_percent` + `post_percent` instead of boundary keys) because the 5h variant has a stable canonical window key and only the percent moves — storing both lets renderers show `−Δpp` without re-querying snapshots and supports the rare stacked-credit chain across distinct 10-minute slots. Created adjacent to `week_reset_events` in `_apply_schema` (both are parallel concepts at different cadences); no FK on `five_hour_window_key` per the CLAUDE.md documentation-only-FK gotcha; no `_backfill_five_hour_reset_events` call (forward-only ship per spec Q5; historical backfill deferred).
13
+ - migration `006_five_hour_milestones_reset_event_id`: adds `reset_event_id INTEGER NOT NULL DEFAULT 0` to `five_hour_milestones` and reshapes UNIQUE from `(five_hour_window_key, percent_threshold)` to `(five_hour_window_key, percent_threshold, reset_event_id)` so post-credit threshold crossings re-fire as distinct rows instead of being silently absorbed by `INSERT OR IGNORE` against the pre-credit row 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`. Live DDL at `bin/cctally:3902` is updated in lockstep with the migration handler so the dispatcher's fresh-install fast-stamp path lands the correct shape without invoking the handler; the `PRAGMA table_info` probe stamps the marker without redoing the rename when the column is already present (covers fresh-install + partial-failure-retry cases). Mirrors weekly migration 005's pattern. Sentinel `0` = pre-credit / no-event; existing rows backfill to 0 via the column DEFAULT.
14
+
15
+ ### Changed
16
+ - 5h DB clamp at `bin/_cctally_record.py:1521-1527` becomes reset-aware via JOIN on `five_hour_reset_events` keyed on the canonical `five_hour_window_key`; the `COALESCE`-to-epoch-zero default keeps legacy clamp behavior byte-identical on DBs without any event rows, while a credited window's `MAX(five_hour_percent)` over the post-credit segment correctly excludes pre-credit snapshots so a fresh post-credit OAuth value (e.g. 4%) lands instead of being held back by stale pre-credit history (e.g. 28%). `unixepoch()` wraps both sides of the captured-vs-effective comparison so the `Z`-vs-`+00:00` offset mix in stored values doesn't break the bound under lex compare — same rule as the production weekly clamp from v1.7.2.
17
+ - renderers: `cctally five-hour-blocks` shows an inline `⚡ credited −Xpp @ HH:MM` chip beside the block-start cell on credited block rows (multiple credits in one block concatenate as `⚡ −Xpp, −Ypp` across distinct 10-min slots); text + JSON envelope (new `credits[]` field per block) + share-output (`--format md` / `html` / `svg`) all carry the annotation via `_build_five_hour_blocks_snapshot`'s `__credits` side-channel piggy-backed on the existing `block_start` cell formatting. `cctally five-hour-breakdown` interleaves a `⚡ CREDIT −Xpp @ HH:MM` divider row between pre-credit and post-credit milestones (merged stream ordered by `captured_at_utc`); JSON gains `credits[]` parallel to `milestones[]` and each milestone now carries `resetEventId` (sentinel 0 = pre-credit, positive integer = post-credit segment id). Dashboard 5h panel chip + `CurrentWeekModal.tsx` 5h milestones section (new envelope key `current_week.five_hour_milestones` parallel to weekly's `current_week.milestones`) + dashboard alerts list row-identity widening (alert id becomes `five_hour:{window_key}:{threshold}:{reset_event_id}` mirroring the weekly precedent at `bin/_cctally_dashboard.py:2597` — NOT a filter, both segments stay in the list as distinct rows) + TUI 5h tile `[⚡ −Xpp]` badge.
18
+ - migration `003_merge_5h_block_duplicates_v1`: defensive update to the milestone dedup loop — widens the dedup key from `percent_threshold` alone to `(percent_threshold, reset_event_id)` via a `PRAGMA table_info` probe so an operator-triggered re-run after migration 006 (`cctally db unskip 003_merge_5h_block_duplicates_v1`, fresh-DB-from-corrupted-backup, future tooling) doesn't silently collapse legitimately distinct pre/post-credit milestone rows at the same threshold inside one physical block. Byte-identical on the legacy upgrade path where the column doesn't yet exist (003 runs before 006 in migration order; `has_seg=False` collapses the key tuple to `(threshold, 0)`).
19
+ - `cctally record-usage`: credit pivots (HWM force-write + stale-replica DELETE) now run UNCONDITIONALLY when a credit is detected on either axis — gating them on `INSERT OR IGNORE`'s `rowcount` (5h branch) or on the `already is None` pre-check (weekly branch) wedged the system permanently on pre-credit HWM + stale-replica rows if a prior invocation committed the event row but crashed before the pivots could run (memory `project_dedup_must_not_gate_side_effects.md`: "Skipping a no-op INSERT must NOT skip milestones/rollups/alerts; prior run may have died mid-flight"). The event-row INSERT stays gated against duplicates; pivots are individually idempotent so re-running them on the recovery tick is safe. Regressions: `tests/test_in_place_5h_credit_detection.py::test_pivots_run_when_event_row_already_committed` + `tests/test_in_place_credit_detection.py::test_weekly_pivots_run_when_event_row_already_committed`.
20
+ - `cctally record-usage`: round-4 follow-up — 5h credit-detection dedup pre-check refined from a single-field `post_percent` compare to a pair-check against BOTH `prior_percent` AND `post_percent` of the most-recent stored event row, AND the post-detection pivots (HWM force-write + stale-replica DELETE) hoisted OUT of the `if not is_dup:` block so they fire on every detection entry. The round-3 single-field predicate false-positived on a legitimate second credit when the user was idle between credits (Credit 1 lands prior=20/post=5; Credit 2 arrives with new CLI percent=0 while prior_5h_pct still reads 5 from the post-Credit-1 snapshot — `5 == 5` matched the stored `post_percent` so the second credit was silently swallowed, no event row written, no HWM force-write, no DELETE). Pair-check disambiguates: a genuine replay matches BOTH fields; a new credit-with-idle matches at most ONE. Hoisting the pivots is the second half of the same fix — a recovery tick where the pair-check legitimately dedupes (event row already durable from a crashed prior invocation, snapshot rolled back so prior_5h_pct still reads the pre-credit value, pair `(20,5)` matches stored `(20,5)`) still needs to force HWM down and DELETE stale replicas; pre-fix those pivots sat INSIDE `if not is_dup:` and got skipped on every recovery tick, leaving the system wedged on the pre-credit HWM forever. Both pivots are individually idempotent (file overwrite, DELETE on stable predicate) so re-running on a replay or recovery tick is always safe. Codex r4 P1 finding ([PR #46 comment 3252721643](https://github.com/omrikais/cctally-dev/pull/46#discussion_r3252721643), issue [#43](https://github.com/omrikais/cctally-dev/issues/43)). Regressions: `tests/test_in_place_5h_credit_detection.py::test_consecutive_credits_with_idle_between` (proves pair-check lets Credit 2 land) + `tests/test_in_place_5h_credit_detection.py::test_replay_with_pair_match_still_runs_pivots` (proves pivots fire when pair-check dedupes).
21
+ - `cctally record-usage`: 5h self-heal probe at `bin/_cctally_record.py:1958+` becomes segment-aware via `_resolve_active_five_hour_reset_event_id`, mirroring the weekly Probe 1 precedent at `bin/_cctally_record.py:1880-1908`. Without segment scoping a credited block's `MAX(percent_threshold)` over the whole ledger reads the pre-credit ceiling (e.g. 28%) and silently suppresses the post-credit ledger's heal even when it has zero rows; with scoping, post-credit threshold crossings inside a stale-`last_observed_at_utc` slot finally trigger heal. Regression: `tests/test_in_place_5h_credit_detection.py::test_5h_self_heal_probe_scoped_to_active_segment`.
22
+ - dashboard alerts envelope: weekly alerts `context` block now exposes `reset_event_id` parallel to the 5h shape — pre-Round-3 the weekly path widened the row identity (`id` includes `:{reset_event_id}`) but did NOT include the segment in `context`, while the 5h path did. Round-3 adds `context.reset_event_id` to weekly so both axes mirror the same shape and downstream consumers (panel, modal, third-party) can discriminate pre- vs post-credit crossings of the same (week, threshold) without scraping the opaque `id` string. Regression: `tests/test_5h_milestone_segment_threading.py::test_weekly_alerts_context_exposes_reset_event_id`.
23
+ - React component coverage: new credit-chip tests in `dashboard/web/__tests__/CurrentWeekPanel.test.tsx` (single + stacked deltas, `data-credit-count` analytics attr, in-row placement) + new 5h-milestone-section tests in `CurrentWeekModal.test.tsx` (section visibility gate, count pill, `⚡ CREDIT` divider row with `colspan=5`, React row key disambiguation by `reset_event_id` so post-credit threshold repeats render as distinct rows). Server-side envelope coverage extended in `tests/test_five_hour_block_envelope.py` (new `_seed_credit_event` helper + populated `credits[]` + empty-array contract + ascending-order chain across stacked credits). Share-output goldens: new `tests/fixtures/share/five-hour-blocks-credit-{md,html,svg}/` fixtures (one in-place credit seeded in `stats.db` via `bin/build-share-fixtures.py`) assert the `⚡ -20pp` chip carries uniformly through all three share-render formats. Dashboard CSS: new `.credit-chip` + `.mcw-5h-credit-row` + `.mcw-5h-credit-cell` + `.m-sec.sec-5h` rules in `dashboard/web/src/index.css` so the credit annotations match the rest of the panel/modal's accent-amber treatment.
24
+
25
+ ## [1.7.2] - 2026-05-16
26
+
27
+ ### Fixed
28
+ - `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.
29
+ - `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).
30
+ - `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.
31
+ - `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.
32
+ - `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.
33
+ - `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.
34
+ - `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.
35
+ - `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.
36
+ - `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.
37
+ - `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.
38
+ - `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%`.
39
+ - `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.
40
+ - `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.
41
+ - `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.
42
+ - `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.
43
+ - `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.
44
+
8
45
  ## [1.7.1] - 2026-05-15
9
46
 
10
47
  ### 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:
@@ -2316,7 +2490,7 @@ def _select_current_block_for_envelope(
2316
2490
 
2317
2491
  block = conn.execute(
2318
2492
  """
2319
- SELECT block_start_at, last_observed_at_utc,
2493
+ SELECT five_hour_window_key, block_start_at, last_observed_at_utc,
2320
2494
  seven_day_pct_at_block_start,
2321
2495
  crossed_seven_day_reset
2322
2496
  FROM five_hour_blocks
@@ -2365,11 +2539,41 @@ def _select_current_block_for_envelope(
2365
2539
  None if (p_anchor is None or current_used_pct is None)
2366
2540
  else round(current_used_pct - p_anchor, 9)
2367
2541
  )
2542
+
2543
+ # Spec §5.3 — in-place credit events for this 5h block's window,
2544
+ # ascending by ``effective_reset_at_utc``. Drives the
2545
+ # ``CurrentWeekPanel.tsx`` ``⚡ credited -Xpp`` chip and the
2546
+ # ``CurrentWeekModal.tsx`` merged-stream 5h milestones section.
2547
+ # Snake_case keys to match the envelope convention (see CLAUDE.md;
2548
+ # CLI ``--json`` uses camelCase, dashboard envelope is snake_case).
2549
+ cred_rows = conn.execute(
2550
+ """
2551
+ SELECT effective_reset_at_utc, prior_percent, post_percent
2552
+ FROM five_hour_reset_events
2553
+ WHERE five_hour_window_key = ?
2554
+ ORDER BY effective_reset_at_utc ASC
2555
+ """,
2556
+ (int(block["five_hour_window_key"]),),
2557
+ ).fetchall()
2558
+ credits = [
2559
+ {
2560
+ "effective_reset_at_utc": c["effective_reset_at_utc"],
2561
+ "prior_percent": float(c["prior_percent"]),
2562
+ "post_percent": float(c["post_percent"]),
2563
+ "delta_pp": round(
2564
+ float(c["post_percent"]) - float(c["prior_percent"]), 1
2565
+ ),
2566
+ }
2567
+ for c in cred_rows
2568
+ ]
2569
+
2368
2570
  return {
2369
2571
  "block_start_at": block["block_start_at"],
2572
+ "five_hour_window_key": int(block["five_hour_window_key"]),
2370
2573
  "seven_day_pct_at_block_start": p_start,
2371
2574
  "seven_day_pct_delta_pp": delta,
2372
2575
  "crossed_seven_day_reset": crossed,
2576
+ "credits": credits,
2373
2577
  }
2374
2578
 
2375
2579
 
@@ -2397,10 +2601,17 @@ def _build_alerts_envelope_array(
2397
2601
  final sort.
2398
2602
  """
2399
2603
  out: list[dict] = []
2604
+ # ``reset_event_id`` (v1.7.2) segments the same (week, threshold)
2605
+ # across pre-credit (0) and post-credit (event.id) cohorts, both
2606
+ # of which can be alerted. The envelope id must include the
2607
+ # segment so React's <li key={a.id}> / <tr key={a.id}> doesn't
2608
+ # collide on the duplicate (week, threshold) pair. Older clients
2609
+ # tolerate longer ids — the id is opaque to them; only the React
2610
+ # key uniqueness invariant matters.
2400
2611
  weekly_rows = conn.execute(
2401
2612
  """
2402
2613
  SELECT week_start_date, percent_threshold, captured_at_utc,
2403
- alerted_at, cumulative_cost_usd
2614
+ alerted_at, cumulative_cost_usd, reset_event_id
2404
2615
  FROM percent_milestones
2405
2616
  WHERE alerted_at IS NOT NULL
2406
2617
  ORDER BY alerted_at DESC
@@ -2413,7 +2624,7 @@ def _build_alerts_envelope_array(
2413
2624
  cumulative = float(r["cumulative_cost_usd"])
2414
2625
  dpp = (cumulative / threshold) if threshold else None
2415
2626
  out.append({
2416
- "id": f"weekly:{r['week_start_date']}:{threshold}",
2627
+ "id": f"weekly:{r['week_start_date']}:{threshold}:{r['reset_event_id']}",
2417
2628
  "axis": "weekly",
2418
2629
  "threshold": threshold,
2419
2630
  "crossed_at": r["captured_at_utc"],
@@ -2422,13 +2633,29 @@ def _build_alerts_envelope_array(
2422
2633
  "week_start_date": r["week_start_date"],
2423
2634
  "cumulative_cost_usd": cumulative,
2424
2635
  "dollars_per_percent": dpp,
2636
+ # Round-3: parallel to the 5h context block below — both
2637
+ # axes now expose ``reset_event_id`` so downstream
2638
+ # clients (panel, modal, third-party consumers) can
2639
+ # discriminate pre- vs post-credit crossings of the
2640
+ # same (week, threshold) without scraping the
2641
+ # envelope ``id`` string. 0 = pre-credit / no-event;
2642
+ # event.id = post-credit segment.
2643
+ "reset_event_id": int(r["reset_event_id"]),
2425
2644
  },
2426
2645
  })
2427
2646
 
2647
+ # Site F (spec §3.2 bucket C / §3.3): widen the row identity to
2648
+ # include ``reset_event_id`` so post-credit (seg=event.id) crossings
2649
+ # of the same (window_key, threshold) don't collide with pre-credit
2650
+ # (seg=0) crossings on the React row key. Older clients tolerate
2651
+ # longer ids — the id is opaque to them; only the React key
2652
+ # uniqueness invariant matters. Mirrors the weekly precedent at
2653
+ # line ~2597.
2428
2654
  fh_rows = conn.execute(
2429
2655
  """
2430
2656
  SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
2431
- m.alerted_at, m.block_cost_usd, b.block_start_at
2657
+ m.alerted_at, m.block_cost_usd, m.reset_event_id,
2658
+ b.block_start_at
2432
2659
  FROM five_hour_milestones m
2433
2660
  LEFT JOIN five_hour_blocks b ON b.five_hour_window_key = m.five_hour_window_key
2434
2661
  WHERE m.alerted_at IS NOT NULL
@@ -2440,7 +2667,10 @@ def _build_alerts_envelope_array(
2440
2667
  for r in fh_rows:
2441
2668
  threshold = int(r["percent_threshold"])
2442
2669
  out.append({
2443
- "id": f"five_hour:{int(r['five_hour_window_key'])}:{threshold}",
2670
+ "id": (
2671
+ f"five_hour:{int(r['five_hour_window_key'])}:"
2672
+ f"{threshold}:{int(r['reset_event_id'])}"
2673
+ ),
2444
2674
  "axis": "five_hour",
2445
2675
  "threshold": threshold,
2446
2676
  "crossed_at": r["captured_at_utc"],
@@ -2449,6 +2679,7 @@ def _build_alerts_envelope_array(
2449
2679
  "five_hour_window_key": int(r["five_hour_window_key"]),
2450
2680
  "block_start_at": r["block_start_at"] or "",
2451
2681
  "block_cost_usd": float(r["block_cost_usd"] or 0.0),
2682
+ "reset_event_id": int(r["reset_event_id"]),
2452
2683
  },
2453
2684
  })
2454
2685
 
@@ -2884,6 +3115,17 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
2884
3115
  }
2885
3116
  for m in (snap.percent_milestones or [])
2886
3117
  ],
3118
+ # Spec §5.3 (Codex r1 finding 3) — NEW envelope key
3119
+ # parallel to ``milestones`` (which carries the WEEKLY
3120
+ # timeline). 5h-block milestones for the active block,
3121
+ # in capture-time order, both pre- and post-credit
3122
+ # segments included (bucket B per §3.2 — no
3123
+ # ``reset_event_id`` filter; the React layer renders
3124
+ # repeated thresholds as distinct rows keyed on
3125
+ # ``reset_event_id``). Empty list when no 5h block is
3126
+ # bound or the data source crashed during sync
3127
+ # (recorded on ``last_sync_error``).
3128
+ "five_hour_milestones": getattr(snap, "five_hour_milestones", []) or [],
2887
3129
  },
2888
3130
 
2889
3131
  "forecast":
@@ -4658,8 +4900,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4658
4900
  now_utc = _command_as_of()
4659
4901
  # Recorded-windows lookup widens by one block on each side so
4660
4902
  # 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,
4903
+ recorded_windows, block_start_overrides = (
4904
+ _load_recorded_five_hour_windows(
4905
+ start_at - BLOCK_DURATION, end_at + BLOCK_DURATION,
4906
+ )
4663
4907
  )
4664
4908
  # Entries: only the window we care about. Mirrors the panel's
4665
4909
  # discipline of pre-filtering before grouping (cf.
@@ -4677,7 +4921,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
4677
4921
  ))
4678
4922
  blocks = _group_entries_into_blocks(
4679
4923
  entries_in_window, mode="auto",
4680
- recorded_windows=recorded_windows, now=now_utc,
4924
+ recorded_windows=recorded_windows,
4925
+ block_start_overrides=block_start_overrides,
4926
+ now=now_utc,
4681
4927
  )
4682
4928
  target = next(
4683
4929
  (b for b in blocks