cctally 1.100.0 → 1.102.0

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +8 -2
  3. package/bin/_cctally_alerts.py +13 -2
  4. package/bin/_cctally_cache.py +3 -1
  5. package/bin/_cctally_cache_report.py +103 -6
  6. package/bin/_cctally_dashboard.py +1140 -282
  7. package/bin/_cctally_dashboard_conversation.py +12 -0
  8. package/bin/_cctally_dashboard_envelope.py +53 -61
  9. package/bin/_cctally_dashboard_share.py +101 -29
  10. package/bin/_cctally_dashboard_sources.py +663 -192
  11. package/bin/_cctally_diagnosis.py +1172 -0
  12. package/bin/_cctally_diagnosis_sources.py +4054 -0
  13. package/bin/_cctally_diff.py +20 -0
  14. package/bin/_cctally_forecast.py +329 -111
  15. package/bin/_cctally_milestone_history.py +10 -2
  16. package/bin/_cctally_parser.py +84 -0
  17. package/bin/_cctally_project.py +155 -47
  18. package/bin/_cctally_quota.py +14 -0
  19. package/bin/_cctally_record.py +151 -71
  20. package/bin/_cctally_refresh.py +105 -93
  21. package/bin/_cctally_share.py +9 -2
  22. package/bin/_cctally_source_analytics.py +40 -4
  23. package/bin/_cctally_statusline.py +8 -1
  24. package/bin/_cctally_tui.py +425 -234
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_blocks.py +12 -0
  28. package/bin/_lib_cache_report.py +110 -1
  29. package/bin/_lib_codex_conversation.py +14 -0
  30. package/bin/_lib_codex_conversation_query.py +22 -8
  31. package/bin/_lib_codex_pools.py +20 -8
  32. package/bin/_lib_conversation.py +6 -3
  33. package/bin/_lib_conversation_query.py +256 -69
  34. package/bin/_lib_dashboard_sources.py +212 -24
  35. package/bin/_lib_diagnosis.py +1261 -0
  36. package/bin/_lib_forecast.py +62 -4
  37. package/bin/_lib_perf.py +12 -0
  38. package/bin/_lib_pricing.py +8 -7
  39. package/bin/_lib_readme_refresh.py +26 -5
  40. package/bin/_lib_render.py +31 -3
  41. package/bin/_lib_share_templates.py +150 -55
  42. package/bin/_lib_snapshot_cache.py +71 -13
  43. package/bin/_lib_source_identity.py +50 -2
  44. package/bin/_lib_subscription_weeks.py +65 -0
  45. package/bin/cctally +103 -16
  46. package/bin/cctally-explain +5 -0
  47. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  48. package/dashboard/static/assets/index-Di2hljvB.css +1 -0
  49. package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  53. package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
@@ -982,6 +982,8 @@ def _source_safe_claude_project_detail(
982
982
  "key": key,
983
983
  "label": label,
984
984
  "window_weeks": detail.get("window_weeks"),
985
+ "window_start_at": detail.get("window_start_at"),
986
+ "window_end_at": detail.get("window_end_at"),
985
987
  "window_cost_usd": detail.get("window_cost_usd"),
986
988
  "window_attributed_pct": detail.get("window_attributed_pct"),
987
989
  "models": detail.get("models", []),
@@ -1330,6 +1332,7 @@ def _build_claude_source_detail(
1330
1332
  recorded_windows, block_start_overrides, canonical_intervals = (
1331
1333
  _load_recorded_five_hour_windows(start_at - BLOCK_DURATION, end_at + BLOCK_DURATION)
1332
1334
  )
1335
+ entry_membership: dict[int, list[UsageEntry]] = {}
1333
1336
  blocks = _group_entries_into_blocks(
1334
1337
  entries,
1335
1338
  mode="auto",
@@ -1337,6 +1340,7 @@ def _build_claude_source_detail(
1337
1340
  block_start_overrides=block_start_overrides,
1338
1341
  canonical_intervals=canonical_intervals,
1339
1342
  now=now_utc,
1343
+ _entry_membership=entry_membership,
1340
1344
  )
1341
1345
  target = next(
1342
1346
  (block for block in blocks if not block.is_gap and block.start_time == start_at),
@@ -1344,9 +1348,7 @@ def _build_claude_source_detail(
1344
1348
  )
1345
1349
  if target is None:
1346
1350
  raise SourceResourceNotFound()
1347
- block_entries = [
1348
- entry for entry in entries if target.start_time <= entry.timestamp < target.end_time
1349
- ]
1351
+ block_entries = entry_membership[id(target)]
1350
1352
  return _source_safe_claude_block_detail(
1351
1353
  _build_block_detail(target, block_entries), key=key,
1352
1354
  )
@@ -1662,6 +1664,28 @@ def _make_sync_loop_collaborators(*, ref, hub) -> dict:
1662
1664
  }
1663
1665
 
1664
1666
 
1667
+ @contextlib.contextmanager
1668
+ def _rebuilding_claim(mark_rebuilding, *, prepare=None):
1669
+ """Bracket one owner-scoped rebuilding claim, including preparation.
1670
+
1671
+ ``prepare`` may itself publish/claim (``_SnapshotRef.capture_batch`` does),
1672
+ so it belongs inside the same structural cleanup boundary as the explicit
1673
+ mark. This is the single guard used by the periodic loop and both
1674
+ synchronous HTTP rebuild routes; a future exception between claim and work
1675
+ therefore cannot strand ``rebuilding=true`` for the process lifetime.
1676
+ """
1677
+ prepared = None
1678
+ try:
1679
+ if prepare is not None:
1680
+ prepared = prepare()
1681
+ if mark_rebuilding is not None:
1682
+ mark_rebuilding(True)
1683
+ yield prepared
1684
+ finally:
1685
+ if mark_rebuilding is not None:
1686
+ mark_rebuilding(False)
1687
+
1688
+
1665
1689
  def _dashboard_sync_loop(
1666
1690
  *,
1667
1691
  stop,
@@ -1705,43 +1729,32 @@ def _dashboard_sync_loop(
1705
1729
  settlement counters, which must not advance for a batch that never existed.
1706
1730
  """
1707
1731
  while not stop.is_set():
1708
- batch = None
1732
+ prepare = None
1709
1733
  if (pending_request is not None and capture_batch is not None
1710
1734
  and pending_request()):
1711
- batch = capture_batch()
1712
- if mark_rebuilding is not None:
1713
- # BEFORE t0: the publish is a non-blocking queue put, and keeping
1714
- # it outside the measured span leaves the #313 bound's algebra
1715
- # exactly as it is. An automatic tick reaches this with no batch
1716
- # captured, which is the whole point — `rebuilding` describes the
1717
- # iteration, not the request that may or may not have started it.
1718
- mark_rebuilding(True)
1719
- t0 = monotonic()
1720
- status, warnings = "ok", ()
1721
- try:
1722
- result = (run_iteration(batch=batch) if batch is not None
1723
- else run_iteration())
1724
- if isinstance(result, dict):
1725
- warnings = tuple(result.get("warnings") or ())
1726
- except Exception: # noqa: BLE001 — see below
1727
- # An escaped exception must not kill the only drainer: an accepted
1728
- # 202 would then never reach a terminal state, and the client would
1729
- # hold `queued…` forever waiting for a settlement no surviving
1730
- # thread can publish.
1731
- status = "failed"
1732
- _log_sync_iteration_failure()
1733
- finally:
1734
- # Failure time is charged to the cooldown exactly like success
1735
- # time, so a crash loop cannot busy-spin.
1736
- work = monotonic() - t0
1737
- if batch is not None and settle is not None:
1738
- settle(batch[0], status, warnings)
1739
- if mark_rebuilding is not None:
1740
- # Every exit path, including an escaped exception: a flag left
1741
- # set would pin the client's chip at `syncing…` for the life of
1742
- # the process. `settle` has already cleared it on a requested
1743
- # tick, so this publishes nothing extra there.
1744
- mark_rebuilding(False)
1735
+ prepare = capture_batch
1736
+ # Claim publication stays BEFORE t0, preserving #313's measured-work
1737
+ # algebra, but the shared guard begins before capture_batch because that
1738
+ # preparation also claims this thread's owner id.
1739
+ with _rebuilding_claim(mark_rebuilding, prepare=prepare) as batch:
1740
+ t0 = monotonic()
1741
+ status, warnings = "ok", ()
1742
+ try:
1743
+ result = (run_iteration(batch=batch) if batch is not None
1744
+ else run_iteration())
1745
+ if isinstance(result, dict):
1746
+ warnings = tuple(result.get("warnings") or ())
1747
+ except Exception: # noqa: BLE001 — see below
1748
+ # An escaped exception must not kill the only drainer: an
1749
+ # accepted 202 would then never reach a terminal state.
1750
+ status = "failed"
1751
+ _log_sync_iteration_failure()
1752
+ finally:
1753
+ # Failure time is charged to the cooldown exactly like success
1754
+ # time, so a crash loop cannot busy-spin.
1755
+ work = monotonic() - t0
1756
+ if batch is not None and settle is not None:
1757
+ settle(batch[0], status, warnings)
1745
1758
 
1746
1759
  deadline = _next_deadline(t0, interval, work)
1747
1760
  floor = t0 + 2.0 * work
@@ -2017,6 +2030,10 @@ def _build_alert_payload_weekly(*args, **kwargs):
2017
2030
  return sys.modules["cctally"]._build_alert_payload_weekly(*args, **kwargs)
2018
2031
 
2019
2032
 
2033
+ def synthetic_preview_week_start(*args, **kwargs):
2034
+ return sys.modules["cctally"].synthetic_preview_week_start(*args, **kwargs)
2035
+
2036
+
2020
2037
  def _build_alert_payload_five_hour(*args, **kwargs):
2021
2038
  return sys.modules["cctally"]._build_alert_payload_five_hour(*args, **kwargs)
2022
2039
 
@@ -2385,42 +2402,10 @@ class _SnapshotRef:
2385
2402
  # therefore remove only the clearing thread's own claim; `rebuilding`
2386
2403
  # is then true exactly while at least one rebuilder holds one.
2387
2404
  #
2388
- # Owner scoping made a LEAKED CLAIM strictly worse than the boolean it
2389
- # replaced, so do not record the opposite. Under the boolean a leaked
2390
- # `True` was cleared by whichever rebuilder next reached `set_final`, so
2391
- # it self-healed on the following rebuild. Every clear site here discards
2392
- # `threading.get_ident()`, so a claim left behind by a thread that has
2393
- # exited can be discarded by NO other thread, and `rebuilding` would stay
2394
- # true — pinning every client's chip at `syncing…` for the life of the
2395
- # process.
2396
- #
2397
- # No leak is reachable today, but the argument splits by CALLER, not by
2398
- # add site. `mark_rebuilding` below is reached from both routes — the
2399
- # sync loop through `_make_sync_loop_collaborators` and an HTTP handler
2400
- # thread through `DashboardHTTPHandler.mark_rebuilding` — so reading one
2401
- # add site answers for neither. Four callers add a claim.
2402
- #
2403
- # Two of the four are bracketed: `_handle_post_sync` and
2404
- # `_handle_post_settings` each mark inside a `try` whose `finally`
2405
- # clears, so nothing between the two can leak the claim.
2406
- #
2407
- # The other two are the sync loop's `capture_batch()` and its
2408
- # `mark_rebuilding(True)`, and they are NOT bracketed. Both run before
2409
- # `t0` and therefore before the `try:` whose `finally` clears them; the
2410
- # loop's own comment at `mark_rebuilding(True)` gives the #313
2411
- # duty-algebra reason for that one's placement. `_dashboard_sync_loop`'s
2412
- # `while` has no outer handler, so a raise in that gap would kill the
2413
- # drainer and leak the claim together. The gap is safe because nothing
2414
- # in it raises: the `_restamp_locked()` each add performs is a
2415
- # `dataclasses.replace` over `DataSnapshot`, a plain dataclass with no
2416
- # `__post_init__`, no `init=False` field and no `InitVar`;
2417
- # `SSEHub.publish` holds its own lock and swallows
2418
- # `queue.Full`/`queue.Empty`; `ref.get()` is a lock-and-return; and what
2419
- # remains is a clock read and two local assignments.
2420
- #
2421
- # A new `add` must therefore satisfy one of the two: a same-thread
2422
- # `finally` that clears it, or a proven non-raising path to one. There
2423
- # is no self-healing path behind either.
2405
+ # Owner scoping makes a leaked claim permanent: another thread cannot
2406
+ # discard this thread's id. All three rebuilding routes therefore use
2407
+ # `_rebuilding_claim`, whose boundary begins before any preparation
2408
+ # that can claim and ends after the terminal publish.
2424
2409
  self._rebuilding_owners: set[int] = set()
2425
2410
  self._snap = self._stamped_locked(initial)
2426
2411
 
@@ -3770,7 +3755,22 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
3770
3755
  recorded-windows-widening trick (loads reset windows from
3771
3756
  ``[start - BLOCK_DURATION, end + BLOCK_DURATION]`` so a recorded
3772
3757
  reset just outside the visible window can still anchor blocks
3773
- inside it) and the strict-window entry filter.
3758
+ inside it) and the post-group block-overlap filter.
3759
+
3760
+ #620 S1 D8: the entry set is grouped into native blocks FIRST and the
3761
+ week filter is then applied to whole blocks, retaining every block whose
3762
+ interval overlaps ``[week_start_at, week_end_at)`` with its full native
3763
+ totals. It used to filter ENTRIES to the week before grouping, so a
3764
+ block straddling a week boundary was folded from only the part of itself
3765
+ that fell inside the week — while ``/api/block/<iso>`` fetches the
3766
+ block's own native window and applies no week clip, so the panel and its
3767
+ own drilldown reported different totals for the same ``start_at`` and
3768
+ the panel's was permanently short. Selecting a block that overlaps the
3769
+ week is the deliberate part of the contract and stays; clipping a
3770
+ selected block's contents was the defect.
3771
+
3772
+ The fetch already read one block duration on each side, so this needs no
3773
+ additional query.
3774
3774
 
3775
3775
  Returning the full ``BlocksView`` (rows + totals) lets the sync
3776
3776
  thread populate ``DataSnapshot.blocks_total_cost_usd`` /
@@ -3781,13 +3781,12 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
3781
3781
  fetch_start = week_start_at - BLOCK_DURATION
3782
3782
  fetch_end = week_end_at + BLOCK_DURATION
3783
3783
  entries = get_entries(fetch_start, fetch_end, skip_sync=skip_sync)
3784
- entries = [e for e in entries if week_start_at <= e.timestamp < week_end_at]
3785
3784
 
3786
3785
  recorded_windows, block_start_overrides, canonical_intervals = (
3787
3786
  _load_recorded_five_hour_windows(fetch_start, fetch_end)
3788
3787
  )
3789
3788
  c = _cctally()
3790
- return c.build_blocks_view(
3789
+ view = c.build_blocks_view(
3791
3790
  entries,
3792
3791
  now_utc=now_utc,
3793
3792
  recorded_windows=recorded_windows,
@@ -3798,6 +3797,60 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
3798
3797
  display_tz=display_tz,
3799
3798
  mode="auto",
3800
3799
  )
3800
+ return _blocks_view_overlapping_week(
3801
+ view, week_start_at=week_start_at, week_end_at=week_end_at,
3802
+ )
3803
+
3804
+
3805
+ def _blocks_view_overlapping_week(view, *, week_start_at, week_end_at):
3806
+ """Retain only the blocks whose interval overlaps
3807
+ ``[week_start_at, week_end_at)``, keeping each retained block's FULL
3808
+ native totals (#620 S1 D8).
3809
+
3810
+ Overlap is the standard half-open test ``start < week_end and end >
3811
+ week_start``: a block that merely touches a bound (its end exactly at
3812
+ ``week_start_at``, or its start exactly at ``week_end_at``) shares no
3813
+ instant with the week and is not retained.
3814
+
3815
+ Totals are re-derived from the retained non-gap blocks so the React
3816
+ panel's ``footer total == sum(visible rows)`` invariant still holds, and
3817
+ ``aggregated`` is filtered in lockstep so no consumer can read a block
3818
+ set that disagrees with ``rows``.
3819
+ """
3820
+ kept_blocks = []
3821
+ total_cost = 0.0
3822
+ total_tokens = 0
3823
+ kept_starts = set()
3824
+ for b in view.aggregated:
3825
+ start = getattr(b, "start_time", None)
3826
+ end = getattr(b, "end_time", None)
3827
+ if start is None or end is None:
3828
+ # API-anchored views carry dicts, not Blocks. This adapter only
3829
+ # ever sees the heuristic path, but degrade by retaining rather
3830
+ # than silently dropping a shape we cannot classify.
3831
+ kept_blocks.append(b)
3832
+ continue
3833
+ if not (start < week_end_at and end > week_start_at):
3834
+ continue
3835
+ kept_blocks.append(b)
3836
+ if getattr(b, "is_gap", False):
3837
+ continue
3838
+ # Plain `+=` rather than `stable_sum`, deliberately: this mirrors
3839
+ # `build_blocks_view`'s own accumulation (`bin/_lib_view_models.py`),
3840
+ # and this function exists to publish the SAME totals that view
3841
+ # publishes. A different fold here could disagree with it in the last
3842
+ # ULP, which is the divergence the function was written to remove.
3843
+ total_cost += b.cost_usd
3844
+ total_tokens += b.total_tokens
3845
+ kept_starts.add(start.astimezone(dt.timezone.utc).isoformat())
3846
+ rows = tuple(r for r in view.rows if r.start_at in kept_starts)
3847
+ return dataclasses.replace(
3848
+ view,
3849
+ rows=rows,
3850
+ aggregated=tuple(kept_blocks),
3851
+ total_cost_usd=total_cost,
3852
+ total_tokens=total_tokens,
3853
+ )
3801
3854
 
3802
3855
 
3803
3856
  def _dashboard_build_blocks_panel(conn: "sqlite3.Connection",
@@ -4152,6 +4205,158 @@ def _projects_week_start_monday_utc(ts: "dt.datetime") -> "dt.datetime":
4152
4205
  )
4153
4206
 
4154
4207
 
4208
+ class _ProjectsWeekGrid:
4209
+ """The ordered half-open subscription intervals the Projects panel
4210
+ attributes cost into (#620 S1 D1).
4211
+
4212
+ ``_projects_week_start_monday_utc`` above is the fallback used when no
4213
+ snapshot anchor is available; this is what replaces it when one IS
4214
+ available. Intervals come from ``_compute_subscription_weeks``, the same
4215
+ kernel ``cmd_project`` buckets by, so the panel and the CLI describe one
4216
+ set of weeks rather than two.
4217
+
4218
+ Intervals are half-open ``[start, end)`` and are NOT assumed to be seven
4219
+ days long: Anthropic's reset day drifts, and a drifted cycle produces a
4220
+ genuinely short week. ``end_for`` therefore returns the interval's own
4221
+ end, never ``start + 7d``.
4222
+ """
4223
+
4224
+ __slots__ = ("starts", "ends", "_end_by_start", "_start_by_date")
4225
+
4226
+ def __init__(self, bounds: "list[tuple[dt.datetime, dt.datetime]]"):
4227
+ ordered = sorted(bounds, key=lambda b: b[0])
4228
+ self.starts = [b[0] for b in ordered]
4229
+ self.ends = [b[1] for b in ordered]
4230
+ self._end_by_start = {s: e for s, e in ordered}
4231
+ # `weekly_usage_snapshots.week_start_date` is the date-only lookup
4232
+ # key a legacy row carries when it has no `week_start_at`. Later
4233
+ # intervals win a collision, matching the "last capture per week
4234
+ # wins" rule the percentage read already applies.
4235
+ self._start_by_date = {s.date(): s for s in self.starts}
4236
+
4237
+ def __bool__(self) -> bool:
4238
+ return bool(self.starts)
4239
+
4240
+ def week_for(self, ts: "dt.datetime") -> "dt.datetime | None":
4241
+ """The start of the interval containing ``ts``, or None when ``ts``
4242
+ falls outside every interval.
4243
+
4244
+ First-match-wins on the reset-day-drift overlap the clamp can leave
4245
+ behind — the same walk-back `cmd_project._week_start_for` performs,
4246
+ so an entry near a drifted boundary lands in the same week on both
4247
+ surfaces.
4248
+ """
4249
+ ts_utc = ts.astimezone(dt.timezone.utc)
4250
+ idx = bisect.bisect_right(self.starts, ts_utc) - 1
4251
+ if idx < 0:
4252
+ return None
4253
+ while idx > 0 and self.starts[idx - 1] <= ts_utc < self.ends[idx - 1]:
4254
+ idx -= 1
4255
+ if self.starts[idx] <= ts_utc < self.ends[idx]:
4256
+ return self.starts[idx]
4257
+ return None
4258
+
4259
+ def start_for_date(self, day: "dt.date") -> "dt.datetime | None":
4260
+ """The interval start whose own date is ``day``, for a legacy
4261
+ snapshot row that carries ``week_start_date`` but no
4262
+ ``week_start_at``."""
4263
+ return self._start_by_date.get(day)
4264
+
4265
+ def end_for(self, start: "dt.datetime") -> "dt.datetime":
4266
+ """The interval's real end. Falls back to ``start + 7d`` only for a
4267
+ start this grid does not know, which the padding below produces."""
4268
+ return self._end_by_start.get(start, start + dt.timedelta(days=7))
4269
+
4270
+ def window_ending_at(
4271
+ self, cw_start: "dt.datetime", weeks_back: int,
4272
+ ) -> "list[tuple[dt.datetime, dt.datetime]]":
4273
+ """The last ``weeks_back`` intervals up to and including the one
4274
+ starting at ``cw_start``, oldest first.
4275
+
4276
+ When the grid holds fewer than ``weeks_back`` intervals at or before
4277
+ ``cw_start``, the head is padded backwards in seven-day steps. The
4278
+ padding is a genuine no-anchor tail — history older than any snapshot
4279
+ — so the seven-day assumption is the right one there.
4280
+
4281
+ The walk itself lives in ``_lib_subscription_weeks`` because
4282
+ ``cmd_project`` performs the same one; a copy here is how the two
4283
+ surfaces drifted apart in the first place.
4284
+ """
4285
+ return _cctally().subscription_window_ending_at(
4286
+ list(zip(self.starts, self.ends)), cw_start, weeks_back,
4287
+ )
4288
+
4289
+
4290
+ def _projects_week_grid(
4291
+ conn: "sqlite3.Connection",
4292
+ *,
4293
+ anchor_utc: "dt.datetime",
4294
+ weeks_back: int,
4295
+ account_key: "str | None" = None,
4296
+ ) -> "_ProjectsWeekGrid | None":
4297
+ """Build the panel's subscription-week grid, or None when no snapshot
4298
+ row carries an anchor.
4299
+
4300
+ Returning None is the deliberate no-anchor path: the caller then keeps
4301
+ ``_projects_week_start_monday_utc`` throughout, which is what that
4302
+ function was written for and what every anchorless fixture already
4303
+ exercises byte-identically.
4304
+
4305
+ Cost: one grouped read of ``weekly_usage_snapshots`` plus the reset-event
4306
+ join `_compute_subscription_weeks` already performs. It adds no walk over
4307
+ ``session_entries`` and nothing per entry, so this does not re-open the
4308
+ per-tick rescan #583 owns.
4309
+ """
4310
+ acct_pred = "" if account_key is None else " AND account_key = ?"
4311
+ acct_params: tuple = () if account_key is None else (account_key,)
4312
+ try:
4313
+ row = conn.execute(
4314
+ "SELECT COUNT(*) FROM weekly_usage_snapshots "
4315
+ "WHERE week_start_at IS NOT NULL "
4316
+ " AND week_end_at IS NOT NULL "
4317
+ " AND week_start_date IS NOT NULL"
4318
+ f"{acct_pred}",
4319
+ acct_params,
4320
+ ).fetchone()
4321
+ except sqlite3.OperationalError:
4322
+ return None
4323
+ if not row or not row[0]:
4324
+ return None
4325
+
4326
+ # A generous provisional range, defined once in `_lib_subscription_weeks`
4327
+ # because `cmd_project` needs the identical range: the extrapolation
4328
+ # anchor `_compute_subscription_weeks` picks is relative to `range_start`,
4329
+ # so two callers asking the same question over different ranges can be
4330
+ # handed differently-phased intervals for the same history.
4331
+ range_start, range_end = _cctally().subscription_window_probe_range(
4332
+ anchor_utc, weeks_back,
4333
+ )
4334
+ try:
4335
+ subweeks = _cctally()._compute_subscription_weeks(
4336
+ conn, range_start, range_end, account_key=account_key,
4337
+ )
4338
+ except Exception:
4339
+ # A malformed anchor must not take the panel down; the Monday
4340
+ # fallback still renders a coherent (if approximate) window.
4341
+ return None
4342
+ bounds: "list[tuple[dt.datetime, dt.datetime]]" = []
4343
+ for sw in subweeks:
4344
+ try:
4345
+ s = parse_iso_datetime(
4346
+ sw.start_ts, "projects week.start_ts",
4347
+ ).astimezone(dt.timezone.utc)
4348
+ e = parse_iso_datetime(
4349
+ sw.end_ts, "projects week.end_ts",
4350
+ ).astimezone(dt.timezone.utc)
4351
+ except (TypeError, ValueError):
4352
+ continue
4353
+ if e > s:
4354
+ bounds.append((s, e))
4355
+ if not bounds:
4356
+ return None
4357
+ return _ProjectsWeekGrid(bounds)
4358
+
4359
+
4155
4360
  def _projects_week_label(week_start: "dt.datetime") -> str:
4156
4361
  """Render a `wk Mon DD` label for the trend chart x-axis.
4157
4362
 
@@ -4200,9 +4405,25 @@ def _projects_iter_session_entries(conn: "sqlite3.Connection",
4200
4405
  downstream. An ``EXPLAIN QUERY PLAN`` regression asserts the mutation_seq
4201
4406
  index seek (``tests/test_projects_envelope.py``).
4202
4407
  """
4203
- since_iso = since.astimezone(dt.timezone.utc).strftime(
4204
- "%Y-%m-%dT%H:%M:%SZ"
4205
- )
4408
+ # The SQL bounds are an outward-widened CANDIDATE filter; the real
4409
+ # membership test is the Python one every caller applies
4410
+ # (`_fold_projects_entry`'s interval gate, `_week_for`, or
4411
+ # `_fetch_delta_rows`' own pre-filter). #620 S1: the lower bound is
4412
+ # widened by one second because ingestion stores
4413
+ # `timestamp.astimezone(utc).isoformat()`, which keeps a `+00:00`
4414
+ # offset, while this predicate spells its bound `Z` — and SQLite
4415
+ # compares the column lexically, where `+` (0x2B) sorts BELOW `Z`
4416
+ # (0x5A). An entry stored at exactly `since`, or in the first second
4417
+ # after it, therefore sorts below the bound and is dropped before any
4418
+ # Python gate runs. That was unreachable while a week always started at
4419
+ # Monday 00:00 UTC, which carries no entries; a real subscription week
4420
+ # starts at the reset instant, and an entry lands on it routinely. The
4421
+ # widening admits at most one extra second of candidates, which the
4422
+ # Python gate then rejects, so no caller's result changes except the one
4423
+ # that was silently losing the boundary entry.
4424
+ since_iso = (
4425
+ since.astimezone(dt.timezone.utc) - dt.timedelta(seconds=1)
4426
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
4206
4427
  until_iso = until.astimezone(dt.timezone.utc).strftime(
4207
4428
  "%Y-%m-%dT%H:%M:%SZ"
4208
4429
  )
@@ -4393,6 +4614,25 @@ def _shared_range_row_to_usage_entry(row):
4393
4614
  )
4394
4615
 
4395
4616
 
4617
+ def _shared_range_row_to_priced_usage_entry(row):
4618
+ """Prepare one shared row's effective cost once for both range folds."""
4619
+ entry = _shared_range_row_to_usage_entry(row)
4620
+ if entry.model == "<synthetic>":
4621
+ return entry
4622
+ return _cctally().UsageEntry(
4623
+ timestamp=entry.timestamp,
4624
+ model=entry.model,
4625
+ usage=entry.usage,
4626
+ cost_usd=_calculate_entry_cost(
4627
+ entry.model,
4628
+ entry.usage,
4629
+ mode="auto",
4630
+ cost_usd=entry.cost_usd,
4631
+ ),
4632
+ source_path=entry.source_path,
4633
+ )
4634
+
4635
+
4396
4636
  def _fold_prepared_daily_entries(
4397
4637
  accumulators, entries, *, display_tz=None, mode: str = "auto",
4398
4638
  ):
@@ -4504,6 +4744,10 @@ class _ProjWeekBucket(NamedTuple):
4504
4744
  first_order: str
4505
4745
  first_id: int
4506
4746
  first_key: "Any"
4747
+ # Retained so selected-window totals can deduplicate a session that is
4748
+ # active in more than one subscription bucket (#634). The default keeps
4749
+ # older fixture constructors source-compatible.
4750
+ session_ids: "frozenset[str]" = frozenset()
4507
4751
 
4508
4752
 
4509
4753
  def _fold_projects_entry(
@@ -4512,7 +4756,9 @@ def _fold_projects_entry(
4512
4756
  *,
4513
4757
  resolver_cache: dict,
4514
4758
  week_start: "dt.datetime | None",
4759
+ week_end: "dt.datetime | None" = None,
4515
4760
  prepared_daily_entries: "list | None" = None,
4761
+ priced_entry=None,
4516
4762
  ) -> "float | None":
4517
4763
  """Fold ONE ``_projects_iter_session_entries`` row onto ``mut`` (the shared
4518
4764
  per-row body, #271 §20 Codex-P1a).
@@ -4524,6 +4770,15 @@ def _fold_projects_entry(
4524
4770
  row is filtered out (``<synthetic>`` model, or its Monday-anchored week ≠
4525
4771
  ``week_start``) — the caller then skips ``week_total`` / ``tail`` advance.
4526
4772
 
4773
+ The membership gate is the half-open interval ``[week_start, week_end)``
4774
+ (#620 S1 D1). It was a ``_projects_week_start_monday_utc(ts) ==
4775
+ week_start`` equality, which is the SAME predicate whenever ``week_start``
4776
+ is a Monday 00:00 UTC and ``week_end`` is ``week_start + 7d`` — so every
4777
+ Monday-anchored caller is byte-unchanged — but the interval form also
4778
+ admits a real subscription week that neither starts on a Monday nor runs
4779
+ a full seven days. ``week_end`` defaults to ``week_start + 7d`` for a
4780
+ caller that has not been threaded through yet.
4781
+
4527
4782
  ``mut[bp]`` is the running mutable dict ``{"cost_usd": float,
4528
4783
  "sessions": set, "first_seen": dt, "last_seen": dt, "first_order": ts_iso,
4529
4784
  "first_id": int, "first_key": ProjectKey}``. The first row seen for a
@@ -4546,32 +4801,43 @@ def _fold_projects_entry(
4546
4801
  if model == "<synthetic>":
4547
4802
  return None
4548
4803
  ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
4549
- if week_start is not None and _projects_week_start_monday_utc(ts) != week_start:
4550
- return None
4551
- usage = claude_usage_dict( # #195 chokepoint
4552
- input_tokens=input_tok,
4553
- output_tokens=output_tok,
4554
- cache_creation_tokens=cache_create,
4555
- cache_read_tokens=cache_read,
4556
- cache_1h_tokens=cache_1h,
4557
- speed=speed,
4558
- )
4559
- entry_cost = _calculate_entry_cost(
4560
- model,
4561
- usage,
4562
- mode="auto",
4563
- cost_usd=cost_raw,
4564
- )
4804
+ if week_start is not None:
4805
+ w_end = (
4806
+ week_end if week_end is not None
4807
+ else week_start + dt.timedelta(days=7)
4808
+ )
4809
+ if not (week_start <= ts < w_end):
4810
+ return None
4811
+ if priced_entry is None:
4812
+ usage = claude_usage_dict( # #195 chokepoint
4813
+ input_tokens=input_tok,
4814
+ output_tokens=output_tok,
4815
+ cache_creation_tokens=cache_create,
4816
+ cache_read_tokens=cache_read,
4817
+ cache_1h_tokens=cache_1h,
4818
+ speed=speed,
4819
+ )
4820
+ entry_cost = _calculate_entry_cost(
4821
+ model,
4822
+ usage,
4823
+ mode="auto",
4824
+ cost_usd=cost_raw,
4825
+ )
4826
+ else:
4827
+ usage = priced_entry.usage
4828
+ entry_cost = priced_entry.cost_usd
4565
4829
  if prepared_daily_entries is not None:
4566
4830
  # #567: preserve the canonical daily entry and aggregator while
4567
4831
  # handing off the effective cost this pass already computed.
4568
- prepared_daily_entries.append(c.UsageEntry(
4569
- timestamp=dt.datetime.fromisoformat(ts_iso),
4570
- model=model,
4571
- usage=usage,
4572
- cost_usd=entry_cost,
4573
- source_path=source_path,
4574
- ))
4832
+ prepared_daily_entries.append(
4833
+ priced_entry if priced_entry is not None else c.UsageEntry(
4834
+ timestamp=dt.datetime.fromisoformat(ts_iso),
4835
+ model=model,
4836
+ usage=usage,
4837
+ cost_usd=entry_cost,
4838
+ source_path=source_path,
4839
+ )
4840
+ )
4575
4841
  pkey = c._resolve_project_key(project_path, "git-root", resolver_cache)
4576
4842
  bp = pkey.bucket_path
4577
4843
  a = mut.get(bp)
@@ -4600,6 +4866,7 @@ def _fold_projects_entry(
4600
4866
 
4601
4867
  def fold_projects_over_range(
4602
4868
  rows, *, resolver_cache=None, prepared_daily_entries=None,
4869
+ priced_entries=None,
4603
4870
  ) -> "dict[str, dict]":
4604
4871
  """Fold an ALREADY-MATERIALISED candidate stream into per-bucket totals.
4605
4872
 
@@ -4622,13 +4889,17 @@ def fold_projects_over_range(
4622
4889
  """
4623
4890
  mut: "dict[str, dict]" = {}
4624
4891
  cache = {} if resolver_cache is None else resolver_cache
4625
- for row in rows:
4892
+ if priced_entries is not None and len(priced_entries) != len(rows):
4893
+ raise ValueError("priced shared-range entries do not match source rows")
4894
+ for index, row in enumerate(rows):
4626
4895
  _fold_projects_entry(
4627
4896
  mut,
4628
4897
  row,
4629
4898
  resolver_cache=cache,
4630
4899
  week_start=None,
4631
4900
  prepared_daily_entries=prepared_daily_entries,
4901
+ priced_entry=(
4902
+ priced_entries[index] if priced_entries is not None else None),
4632
4903
  )
4633
4904
  return mut
4634
4905
 
@@ -4900,25 +5171,35 @@ def _shared_range_cache_payload(
4900
5171
  }
4901
5172
 
4902
5173
 
4903
- def build_cached_claude_range_aggregates(
5174
+ @dataclass(frozen=True)
5175
+ class ClaudeRangeAggregateCapture:
5176
+ """Cache-owned inputs for one post-transaction Claude range fold."""
5177
+
5178
+ base: tuple
5179
+ max_entry_id: int
5180
+ entry_mutation_seq: int
5181
+ shared_end_exclusive: object
5182
+ prior: object
5183
+ delta_rows: tuple
5184
+ full_rows: tuple | None
5185
+
5186
+
5187
+ def capture_cached_claude_range_aggregates(
4904
5188
  conn,
4905
5189
  *,
4906
5190
  shared_start,
4907
5191
  shared_end_exclusive,
4908
- now_utc,
4909
5192
  display_tz,
4910
- legacy_labels,
4911
5193
  max_entry_id: "int | None" = None,
4912
5194
  entry_mutation_seq: "int | None" = None,
4913
5195
  generation: int = 0,
4914
5196
  ):
4915
- """Build or increment the one-snapshot Claude range folds (#567).
5197
+ """Capture only cache-backed inputs for the #567 append accumulator.
4916
5198
 
4917
- Pure appends are folded onto the cached raw accumulators. A shifted range
4918
- floor, backwards clock, generation or session-file identity change,
4919
- non-monotone signature, or an id-stable mutation of an already-folded row
4920
- falls back to one full ordered pass. The cache stores no public labels, so
4921
- the current legacy population is reapplied on every publication.
5199
+ This half may run under `_tui_build_source_bundle`'s pinned transaction. It
5200
+ performs no deepcopy, pricing, project fold, daily fold, payload assembly,
5201
+ or memo mutation. The returned rows are ordinary immutable SQLite tuples,
5202
+ so the caller can end the read transaction before consuming them.
4922
5203
  """
4923
5204
  if max_entry_id is None or entry_mutation_seq is None:
4924
5205
  observed_id, observed_seq = _shared_range_entry_signature(conn)
@@ -4935,7 +5216,8 @@ def build_cached_claude_range_aggregates(
4935
5216
  generation=generation,
4936
5217
  )
4937
5218
  prior = _CLAUDE_RANGE_AGGREGATE_MEMO.get("state")
4938
- state = None
5219
+ delta_rows: tuple = ()
5220
+ full_rows: tuple | None = None
4939
5221
  if isinstance(prior, dict) and prior.get("base") == base:
4940
5222
  monotone = (
4941
5223
  max_entry_id >= prior["max_entry_id"]
@@ -4951,9 +5233,6 @@ def build_cached_claude_range_aggregates(
4951
5233
  )
4952
5234
  )
4953
5235
  if monotone and not old_row_changed:
4954
- project_mut = copy.deepcopy(prior["project_mut"])
4955
- daily_accumulators = copy.deepcopy(prior["daily_accumulators"])
4956
- resolver_cache = dict(prior["resolver_cache"])
4957
5236
  delta_by_id = {}
4958
5237
  for row in _shared_range_entries_after_id(
4959
5238
  conn, prior["max_entry_id"],
@@ -4971,85 +5250,198 @@ def build_cached_claude_range_aggregates(
4971
5250
  ):
4972
5251
  if row[0] <= prior["max_entry_id"]:
4973
5252
  delta_by_id[row[0]] = row
4974
- delta_rows = sorted(
5253
+ delta_rows = tuple(sorted(
4975
5254
  delta_by_id.values(),
4976
5255
  key=lambda row: (row[1], row[0]),
4977
- )
5256
+ ))
4978
5257
  prior_tail = prior["tail"]
4979
- if prior_tail is None or all(
5258
+ if not (prior_tail is None or all(
4980
5259
  (row[1], row[0]) > prior_tail
4981
5260
  for row in delta_rows
4982
5261
  if row[2] != "<synthetic>"
4983
- ):
4984
- prepared = []
4985
- for row in delta_rows:
4986
- _fold_projects_entry(
4987
- project_mut,
4988
- row,
4989
- resolver_cache=resolver_cache,
4990
- week_start=None,
4991
- prepared_daily_entries=prepared,
4992
- )
4993
- _fold_prepared_daily_entries(
4994
- daily_accumulators,
4995
- prepared,
4996
- display_tz=display_tz,
4997
- )
4998
- tail = prior_tail
4999
- real_delta = [
5000
- row for row in delta_rows if row[2] != "<synthetic>"
5001
- ]
5002
- if real_delta:
5003
- last = real_delta[-1]
5004
- tail = (last[1], last[0])
5005
- state = {
5006
- "base": base,
5007
- "max_entry_id": max_entry_id,
5008
- "entry_mutation_seq": entry_mutation_seq,
5009
- "end_exclusive": shared_end_exclusive,
5010
- "tail": tail,
5011
- "project_mut": project_mut,
5012
- "daily_accumulators": daily_accumulators,
5013
- "resolver_cache": resolver_cache,
5014
- }
5015
- if state is None:
5016
- rows = tuple(iter_shared_range_entries(
5262
+ )):
5263
+ # An out-of-order append cannot be folded onto the accumulator.
5264
+ # Capture the cold carrier while the same snapshot is pinned;
5265
+ # discovering this after rollback would require a second read
5266
+ # generation.
5267
+ full_rows = tuple(iter_shared_range_entries(
5268
+ conn, start=shared_start,
5269
+ end_exclusive=shared_end_exclusive,
5270
+ ))
5271
+ delta_rows = ()
5272
+ else:
5273
+ full_rows = tuple(iter_shared_range_entries(
5274
+ conn, start=shared_start,
5275
+ end_exclusive=shared_end_exclusive,
5276
+ ))
5277
+ else:
5278
+ full_rows = tuple(iter_shared_range_entries(
5017
5279
  conn, start=shared_start, end_exclusive=shared_end_exclusive,
5018
5280
  ))
5019
- prepared = []
5020
- resolver_cache = {}
5021
- project_mut = fold_projects_over_range(
5022
- rows,
5023
- resolver_cache=resolver_cache,
5024
- prepared_daily_entries=prepared,
5281
+ return ClaudeRangeAggregateCapture(
5282
+ base=base,
5283
+ max_entry_id=max_entry_id,
5284
+ entry_mutation_seq=entry_mutation_seq,
5285
+ shared_end_exclusive=shared_end_exclusive,
5286
+ prior=prior,
5287
+ delta_rows=delta_rows,
5288
+ full_rows=full_rows,
5289
+ )
5290
+
5291
+
5292
+ def build_cached_claude_range_aggregates_from_capture(
5293
+ capture: ClaudeRangeAggregateCapture,
5294
+ *,
5295
+ now_utc,
5296
+ display_tz,
5297
+ legacy_labels,
5298
+ tolerate_leg_failures: bool = False,
5299
+ ):
5300
+ """Fold and publish one captured Claude range accumulator outside the pin.
5301
+
5302
+ ``tolerate_leg_failures`` is the source-bundle path's typed degradation
5303
+ seam. Project identity resolution and the daily calendar are independent
5304
+ folds over the same captured rows, so a project-only fault must not discard
5305
+ a valid daily result. The one-shot compatibility wrapper keeps the legacy
5306
+ raise-on-any-fault contract by leaving it false.
5307
+ """
5308
+ prior = capture.prior
5309
+ incremental = capture.full_rows is None and isinstance(prior, dict)
5310
+ rows = capture.delta_rows if incremental else (capture.full_rows or ())
5311
+ payload: dict[str, object] = {}
5312
+ outcomes = {
5313
+ "projects": {"state": "ok"},
5314
+ "daily": {"state": "ok"},
5315
+ }
5316
+ failures: dict[str, Exception] = {}
5317
+ project_mut = None
5318
+ resolver_cache = None
5319
+ daily_accumulators = None
5320
+
5321
+ try:
5322
+ prepared = tuple(
5323
+ _shared_range_row_to_priced_usage_entry(row) for row in rows)
5324
+ except Exception as exc:
5325
+ prepared = ()
5326
+ failures["projects"] = exc
5327
+ failures["daily"] = exc
5328
+ outcomes["projects"] = {
5329
+ "state": "failed", "code": "claude_fold_failed"}
5330
+ outcomes["daily"] = {
5331
+ "state": "failed", "code": "claude_fold_failed"}
5332
+
5333
+ try:
5334
+ if "projects" in failures:
5335
+ raise failures["projects"]
5336
+ if incremental:
5337
+ project_mut = copy.deepcopy(prior["project_mut"])
5338
+ resolver_cache = dict(prior["resolver_cache"])
5339
+ for row, priced_entry in zip(rows, prepared):
5340
+ _fold_projects_entry(
5341
+ project_mut,
5342
+ row,
5343
+ resolver_cache=resolver_cache,
5344
+ week_start=None,
5345
+ priced_entry=priced_entry,
5346
+ )
5347
+ else:
5348
+ resolver_cache = {}
5349
+ project_mut = fold_projects_over_range(
5350
+ rows,
5351
+ resolver_cache=resolver_cache,
5352
+ priced_entries=prepared,
5353
+ )
5354
+ payload["projects"] = _project_aggregate_rows_from_folded(
5355
+ project_mut, legacy_labels)
5356
+ except Exception as exc:
5357
+ failures["projects"] = exc
5358
+ outcomes["projects"] = {
5359
+ "state": "failed", "code": "claude_fold_failed"}
5360
+
5361
+ try:
5362
+ if "daily" in failures:
5363
+ raise failures["daily"]
5364
+ daily_accumulators = (
5365
+ copy.deepcopy(prior["daily_accumulators"])
5366
+ if incremental else {}
5025
5367
  )
5026
- daily_accumulators = {}
5027
5368
  _fold_prepared_daily_entries(
5028
- daily_accumulators, prepared, display_tz=display_tz,
5029
- )
5369
+ daily_accumulators, prepared, display_tz=display_tz)
5370
+ daily_buckets = _finalize_daily_accumulators(daily_accumulators)
5371
+ daily_rows = _build_daily_aggregate_rows_from_buckets(
5372
+ daily_buckets, now_utc=now_utc, display_tz=display_tz)
5373
+ c = _cctally()
5374
+ payload["daily"] = [
5375
+ c.daily_panel_row_to_wire(row) for row in daily_rows]
5376
+ except Exception as exc:
5377
+ failures["daily"] = exc
5378
+ outcomes["daily"] = {
5379
+ "state": "failed", "code": "claude_fold_failed"}
5380
+
5381
+ if incremental:
5382
+ tail = prior["tail"]
5383
+ real_delta = [
5384
+ row for row in capture.delta_rows if row[2] != "<synthetic>"
5385
+ ]
5386
+ if real_delta:
5387
+ last = real_delta[-1]
5388
+ tail = (last[1], last[0])
5389
+ else:
5030
5390
  real_rows = [row for row in rows if row[2] != "<synthetic>"]
5031
5391
  tail = None
5032
5392
  if real_rows:
5033
5393
  last = real_rows[-1]
5034
5394
  tail = (last[1], last[0])
5395
+
5396
+ if not failures:
5035
5397
  state = {
5036
- "base": base,
5037
- "max_entry_id": max_entry_id,
5038
- "entry_mutation_seq": entry_mutation_seq,
5039
- "end_exclusive": shared_end_exclusive,
5398
+ "base": capture.base,
5399
+ "max_entry_id": capture.max_entry_id,
5400
+ "entry_mutation_seq": capture.entry_mutation_seq,
5401
+ "end_exclusive": capture.shared_end_exclusive,
5040
5402
  "tail": tail,
5041
5403
  "project_mut": project_mut,
5042
5404
  "daily_accumulators": daily_accumulators,
5043
5405
  "resolver_cache": resolver_cache,
5044
5406
  }
5045
- payload = _shared_range_cache_payload(
5046
- state,
5047
- legacy_labels=legacy_labels,
5407
+ _cctally()._load_sibling("_lib_snapshot_cache")._assert_owner()
5408
+ _CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
5409
+
5410
+ if failures and not tolerate_leg_failures:
5411
+ raise next(iter(failures.values()))
5412
+ if tolerate_leg_failures:
5413
+ return payload, outcomes
5414
+ return payload
5415
+
5416
+
5417
+ def build_cached_claude_range_aggregates(
5418
+ conn,
5419
+ *,
5420
+ shared_start,
5421
+ shared_end_exclusive,
5422
+ now_utc,
5423
+ display_tz,
5424
+ legacy_labels,
5425
+ max_entry_id: "int | None" = None,
5426
+ entry_mutation_seq: "int | None" = None,
5427
+ generation: int = 0,
5428
+ ):
5429
+ """One-shot compatibility wrapper for non-pinned focused callers."""
5430
+ capture = capture_cached_claude_range_aggregates(
5431
+ conn,
5432
+ shared_start=shared_start,
5433
+ shared_end_exclusive=shared_end_exclusive,
5434
+ display_tz=display_tz,
5435
+ max_entry_id=max_entry_id,
5436
+ entry_mutation_seq=entry_mutation_seq,
5437
+ generation=generation,
5438
+ )
5439
+ return build_cached_claude_range_aggregates_from_capture(
5440
+ capture,
5048
5441
  now_utc=now_utc,
5049
5442
  display_tz=display_tz,
5443
+ legacy_labels=legacy_labels,
5050
5444
  )
5051
- _CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
5052
- return payload
5053
5445
 
5054
5446
 
5055
5447
  def _aggregate_projects_week_raw(
@@ -5080,7 +5472,8 @@ def _aggregate_projects_week_raw(
5080
5472
  conn, since=week_start, until=week_end,
5081
5473
  ):
5082
5474
  entry_cost = _fold_projects_entry(
5083
- mut, row, resolver_cache=resolver_cache, week_start=week_start,
5475
+ mut, row, resolver_cache=resolver_cache,
5476
+ week_start=week_start, week_end=week_end,
5084
5477
  )
5085
5478
  if entry_cost is None:
5086
5479
  continue
@@ -5104,6 +5497,7 @@ def _finalize_projects_mut(mut: dict) -> "dict[str, _ProjWeekBucket]":
5104
5497
  first_order=a["first_order"],
5105
5498
  first_id=a["first_id"],
5106
5499
  first_key=a["first_key"],
5500
+ session_ids=frozenset(a["sessions"]),
5107
5501
  )
5108
5502
  for bp, a in mut.items()
5109
5503
  }
@@ -5143,7 +5537,7 @@ def _aggregate_projects_week(
5143
5537
  def _assemble_projects_via_cache(
5144
5538
  conn: "sqlite3.Connection",
5145
5539
  *,
5146
- weeks_full: "list[dt.datetime]",
5540
+ week_bounds: "list[tuple[dt.datetime, dt.datetime]]",
5147
5541
  cw_start: "dt.datetime",
5148
5542
  cw_end: "dt.datetime",
5149
5543
  cur_max_id: int,
@@ -5186,9 +5580,16 @@ def _assemble_projects_via_cache(
5186
5580
  for bp, wb in week_buckets.items():
5187
5581
  buckets[(bp, w)] = {
5188
5582
  "cost_usd": wb.cost_usd,
5189
- # `sessions` is only ever `len()`-d downstream; a `range` of the
5190
- # cached count reproduces that without storing the id set.
5191
- "sessions": range(wb.sessions_count),
5583
+ # Window totals union these identities so a resumed session
5584
+ # crossing a reset is counted once. The range fallback serves
5585
+ # test/legacy constructors that predate ``session_ids``.
5586
+ "sessions": (
5587
+ wb.session_ids
5588
+ if wb.session_ids
5589
+ else frozenset(
5590
+ (w, index) for index in range(wb.sessions_count)
5591
+ )
5592
+ ),
5192
5593
  "first_seen": wb.first_seen,
5193
5594
  "last_seen": wb.last_seen,
5194
5595
  }
@@ -5218,33 +5619,47 @@ def _assemble_projects_via_cache(
5218
5619
  if r[2] == "<synthetic>": # r[2] = model
5219
5620
  continue
5220
5621
  ts = parse_iso_datetime(r[1], "session_entries.timestamp_utc")
5221
- if _projects_week_start_monday_utc(ts) != cw_start:
5622
+ if not (cw_start <= ts < cw_end):
5222
5623
  continue
5223
5624
  out.append(r)
5224
5625
  out.sort(key=lambda r: (r[1], r[0])) # (ts_iso, id)
5225
5626
  return out
5226
5627
 
5227
- for w in weeks_full:
5628
+ # The cache identity below is `(start, end)`, while the spec named
5629
+ # "account, exact start, exact end". The account axis is omitted
5630
+ # deliberately, not by oversight: this panel always folds merged
5631
+ # (`_projects_week_grid` is called here with `account_key=None`), so
5632
+ # every entry in this cache was produced by the one merged read and two
5633
+ # scopes cannot collide in it. Adding a constant third component would
5634
+ # be a key that never varies. If the panel ever gains an account scope,
5635
+ # the axis has to be added at the same time — an account-scoped fold
5636
+ # served from a merged slot is a wrong answer, not a stale one.
5637
+ for w, w_end in week_bounds:
5228
5638
  if w == cw_start:
5229
5639
  week_buckets, week_total = sc.accumulate_projects_current_week(
5230
- week_key=sc.projects_env_week_key(cw_start),
5640
+ # #620 S1: the accumulator's identity is the INTERVAL. An
5641
+ # early reset that moves the current week's bounds must
5642
+ # cold-refold the slot rather than keep appending to a
5643
+ # running aggregate folded over the old window.
5644
+ week_key=sc.projects_env_week_key(cw_start, cw_end),
5231
5645
  cur_max_id=cur_max_id,
5232
5646
  cur_max_seq=cur_max_seq,
5233
5647
  fetch_all_raw=_fetch_all_raw,
5234
5648
  fetch_delta_rows=_fetch_delta_rows,
5235
5649
  finalize=_finalize_projects_mut,
5236
5650
  fold=lambda mut, row: _fold_projects_entry(
5237
- mut, row, resolver_cache=resolver_cache, week_start=cw_start,
5651
+ mut, row, resolver_cache=resolver_cache,
5652
+ week_start=cw_start, week_end=cw_end,
5238
5653
  ),
5239
5654
  )
5240
5655
  else:
5241
- week_iso = sc.projects_env_week_key(w)
5656
+ week_iso = sc.projects_env_week_key(w, w_end)
5242
5657
  hit = sc.projects_env_week_get(week_iso)
5243
5658
  if hit is not None:
5244
5659
  week_buckets, week_total = hit
5245
5660
  else:
5246
5661
  week_buckets, week_total = _aggregate_projects_week(
5247
- conn, week_start=w, week_end=w + dt.timedelta(days=7),
5662
+ conn, week_start=w, week_end=w_end,
5248
5663
  resolver_cache=resolver_cache,
5249
5664
  )
5250
5665
  sc.projects_env_week_put(week_iso, week_buckets, week_total)
@@ -5268,14 +5683,21 @@ def _build_projects_envelope(
5268
5683
  shape from spec §5.2 (no per-model breakdowns, no first/last seen
5269
5684
  per session, no per-row $/1%; just cost / attributed_pct / sessions).
5270
5685
 
5271
- Week boundaries follow ``cmd_project``'s Monday-anchored UTC
5272
- fallback (``bin/cctally:4711``); ``weekly_usage_snapshots`` rows are
5273
- matched by ``week_start_date`` (date-only) for ``attributed_pct``.
5274
-
5275
- ``current_week`` is passed through opaquely — if non-None and
5276
- carrying a ``.week_start_at`` UTC datetime, that boundary supplants
5277
- the Monday fallback for the current week's bucket. None (the
5278
- default) preserves the fallback.
5686
+ Week boundaries are the real subscription intervals (#620 S1 D1):
5687
+ ``_projects_week_grid`` derives them from ``_compute_subscription_weeks``,
5688
+ the same kernel ``cmd_project`` buckets by, so the two surfaces attribute
5689
+ the same projects over the same weeks. A ``weekly_usage_snapshots`` row
5690
+ is matched onto an interval by its ``week_start_at`` anchor, falling back
5691
+ to ``week_start_date`` for a legacy row that carries no anchor; a row
5692
+ that matches no interval contributes nothing, leaving ``attributed_pct``
5693
+ None rather than attributing over a mismatched population. Only a store
5694
+ with no anchored snapshot at all falls back to
5695
+ ``_projects_week_start_monday_utc``, which is what that function was
5696
+ written for.
5697
+
5698
+ ``current_week`` is passed through opaquely — if non-None and carrying a
5699
+ ``.week_start_at`` UTC datetime, that instant selects which interval is
5700
+ the current week. None (the default) uses ``now_utc``.
5279
5701
 
5280
5702
  Determinism: same conn + same ``now_utc`` ⇒ byte-identical JSON
5281
5703
  (R-PROJ5 invariant). Per-tick memoized on
@@ -5330,33 +5752,51 @@ def _build_projects_envelope(
5330
5752
  return cached
5331
5753
 
5332
5754
  # ---- Week-start anchor (current subscription week) ------------------
5333
- # ``TuiCurrentWeek.week_start_at`` is NOT a valid Monday lookup key
5334
- # after ``_apply_midweek_reset_override`` — it is shifted to the
5335
- # in-week reset instant (e.g. Friday 13:00 UTC) while the bucket
5336
- # aggregator below snaps every entry to its containing ISO-Monday
5337
- # via ``_week_for``. Using ``cw_key`` directly as the bucket-lookup
5338
- # key strands all current-week activity in an empty bucket and emits
5339
- # ``rows: []`` with ``total_cost_usd: 0.0``. Snap to the canonical
5340
- # Monday-UTC week anchor here so the lookup keys align — same
5341
- # invariant the weekly handling notes call out for
5342
- # ``weekly_usage_snapshots``/``percent_milestones`` cross-table
5343
- # joins. Regression: ``tests/fixtures/dashboard/reset-week/`` +
5755
+ # #620 S1 D1. The panel buckets cost into the REAL subscription
5756
+ # intervals — the ones `_compute_subscription_weeks` derives from the
5757
+ # retained reset anchors, and the ones `cmd_project` already buckets by.
5758
+ # `_projects_week_start_monday_utc` remains what its own docstring says
5759
+ # it is: the fallback for when no anchor is available. It used to be
5760
+ # applied to the anchor itself, which discarded the very thing it was
5761
+ # written to defer to, so the cost window and the quota window described
5762
+ # different intervals for every account whose reset is not exactly
5763
+ # Monday midnight UTC — in practice almost all of them.
5764
+ #
5765
+ # ``TuiCurrentWeek.week_start_at`` after ``_apply_midweek_reset_override``
5766
+ # is the in-week reset instant rather than the week's start. It is used
5767
+ # here only to locate the containing interval, never as a bucket key, so
5768
+ # a shifted value resolves to the same week the entry walk uses and no
5769
+ # activity is stranded. Regression:
5770
+ # ``tests/fixtures/dashboard/reset-week/`` +
5344
5771
  # ``test_current_week_rows_populated_after_midweek_reset``.
5345
- if cw_key is not None:
5346
- cw_start = _projects_week_start_monday_utc(cw_key)
5772
+ anchor_instant = cw_key if cw_key is not None else now_utc
5773
+ grid = _projects_week_grid(
5774
+ conn, anchor_utc=anchor_instant, weeks_back=weeks_back,
5775
+ )
5776
+ cw_start = grid.week_for(anchor_instant) if grid is not None else None
5777
+ if cw_start is None:
5778
+ # No anchor covers `now` — the genuine fallback path, byte-identical
5779
+ # to the pre-#620 behaviour for a store with no anchored snapshots.
5780
+ grid = None
5781
+ cw_start = _projects_week_start_monday_utc(anchor_instant)
5782
+ cw_end = cw_start + dt.timedelta(days=7)
5783
+ week_bounds = [
5784
+ (
5785
+ cw_start - dt.timedelta(days=7 * (weeks_back - 1 - i)),
5786
+ cw_start - dt.timedelta(days=7 * (weeks_back - 2 - i)),
5787
+ )
5788
+ for i in range(weeks_back)
5789
+ ]
5347
5790
  else:
5348
- cw_start = _projects_week_start_monday_utc(now_utc)
5791
+ cw_end = grid.end_for(cw_start)
5792
+ week_bounds = grid.window_ending_at(cw_start, weeks_back)
5349
5793
 
5350
- # Build a list of canonical Monday-anchored week starts ending with
5351
- # cw_start, oldest → newest, of length ``weeks_back``. Clamping to
5794
+ # Week starts, oldest → newest, of length ``weeks_back``. Clamping to
5352
5795
  # actual history happens after the entry walk reveals what weeks
5353
5796
  # have any activity.
5354
- weeks_full = [
5355
- cw_start - dt.timedelta(days=7 * (weeks_back - 1 - i))
5356
- for i in range(weeks_back)
5357
- ]
5358
- cw_end = cw_start + dt.timedelta(days=7)
5359
- since_dt = weeks_full[0]
5797
+ weeks_full = [b[0] for b in week_bounds]
5798
+ end_by_week = {s: e for s, e in week_bounds}
5799
+ since_dt = week_bounds[0][0]
5360
5800
  until_dt = cw_end # exclusive end; SQL is `>= since AND <= until`
5361
5801
 
5362
5802
  # ---- Bucket entries per (ProjectKey, week_start) --------------------
@@ -5371,7 +5811,7 @@ def _build_projects_envelope(
5371
5811
  # HTTP-drill): the original single full-window walk, byte-unchanged.
5372
5812
  if use_projects_env_cache:
5373
5813
  buckets, total_cost_by_week, key_by_bucket = _assemble_projects_via_cache(
5374
- conn, weeks_full=weeks_full, cw_start=cw_start, cw_end=cw_end,
5814
+ conn, week_bounds=week_bounds, cw_start=cw_start, cw_end=cw_end,
5375
5815
  cur_max_id=max_id, cur_max_seq=entry_mutation_seq,
5376
5816
  )
5377
5817
  else:
@@ -5387,8 +5827,13 @@ def _build_projects_envelope(
5387
5827
  key_by_bucket = {}
5388
5828
 
5389
5829
  def _week_for(ts: dt.datetime) -> "dt.datetime | None":
5390
- wstart = _projects_week_start_monday_utc(ts)
5391
- if wstart < weeks_full[0] or wstart > weeks_full[-1]:
5830
+ if grid is not None:
5831
+ wstart = grid.week_for(ts)
5832
+ if wstart is None:
5833
+ return None
5834
+ else:
5835
+ wstart = _projects_week_start_monday_utc(ts)
5836
+ if wstart not in end_by_week:
5392
5837
  return None
5393
5838
  return wstart
5394
5839
 
@@ -5478,34 +5923,78 @@ def _build_projects_envelope(
5478
5923
  # (bin/cctally:1162-1168) and the doctor credited-week check
5479
5924
  # (bin/cctally:8706-8714).
5480
5925
  #
5481
- # Portable per-key-latest pattern: read rows ordered by capture-
5482
- # ascending and let later rows overwrite. The final value per key
5483
- # is the most-recent snapshot.
5926
+ # Reduce to one latest NON-NULL row per candidate date in SQLite, then let
5927
+ # Python resolve those bounded rows onto the anchored interval grid. NULL
5928
+ # rows never erased the prior known value in the former ascending fold, so
5929
+ # they are excluded before ranking. The outer capture order preserves the
5930
+ # same final-overwrite behaviour if two legacy date keys resolve to one
5931
+ # interval. The date bounds keep historical status-line ticks out of the
5932
+ # scan, while the per-date ranking makes the rows crossing into Python
5933
+ # proportional to candidate boundary dates rather than tick count. Do not
5934
+ # LIMIT before Python resolves legacy date keys: an unresolvable key must
5935
+ # not evict a valid rendered-week row (#620 S1 A3).
5484
5936
  weekly_pct_by_week: dict[dt.datetime, float] = {}
5485
5937
  try:
5486
5938
  cur = conn.execute(
5487
- "SELECT week_start_date, weekly_percent "
5488
- "FROM weekly_usage_snapshots "
5489
- "ORDER BY captured_at_utc ASC, id ASC"
5939
+ "WITH ranked AS ("
5940
+ " SELECT week_start_date, week_start_at, weekly_percent,"
5941
+ " captured_at_utc, id,"
5942
+ " ROW_NUMBER() OVER ("
5943
+ " PARTITION BY week_start_date"
5944
+ " ORDER BY captured_at_utc DESC, id DESC"
5945
+ " ) AS latest_rank"
5946
+ " FROM weekly_usage_snapshots"
5947
+ " WHERE week_start_date >= ? AND week_start_date < ?"
5948
+ " AND date(week_start_date) IS NOT NULL"
5949
+ " AND weekly_percent IS NOT NULL"
5950
+ ")"
5951
+ " SELECT week_start_date, week_start_at, weekly_percent"
5952
+ " FROM ranked WHERE latest_rank = 1"
5953
+ " ORDER BY captured_at_utc ASC, id ASC",
5954
+ (
5955
+ since_dt.date().isoformat(),
5956
+ cw_end.date().isoformat(),
5957
+ ),
5490
5958
  )
5491
5959
  rows = cur.fetchall()
5492
5960
  except sqlite3.OperationalError:
5493
5961
  # No weekly_usage_snapshots table — leaves attributed_pct = None
5494
5962
  # throughout (acceptable per spec §2.7).
5495
5963
  rows = []
5496
- for week_date_str, weekly_pct in rows:
5964
+ for week_date_str, week_start_at, weekly_pct in rows:
5497
5965
  try:
5498
5966
  wd = dt.date.fromisoformat(week_date_str)
5499
5967
  except (TypeError, ValueError):
5500
5968
  continue
5501
- # Snap the date to UTC Monday 00:00 (matches the bucketing key).
5502
- wstart = dt.datetime.combine(
5503
- wd, dt.time(0, 0, 0), tzinfo=dt.timezone.utc,
5504
- )
5505
- # Snap to Monday (snapshot rows that captured a non-Monday week
5506
- # boundary still align to the same canonical bucket as the entry
5507
- # walk, since the bucketing is Monday-anchored).
5508
- wstart = _projects_week_start_monday_utc(wstart)
5969
+ wstart: "dt.datetime | None" = None
5970
+ if grid is not None:
5971
+ # #620 S1 D1. Resolve the row onto the SAME interval the entry
5972
+ # walk buckets into, so the numerator, the denominator and this
5973
+ # percentage are all taken from one half-open window before the
5974
+ # multiplication below.
5975
+ if week_start_at:
5976
+ try:
5977
+ anchor = parse_iso_datetime(
5978
+ week_start_at, "weekly_usage_snapshots.week_start_at",
5979
+ ).astimezone(dt.timezone.utc)
5980
+ except (TypeError, ValueError):
5981
+ anchor = None
5982
+ if anchor is not None:
5983
+ wstart = grid.week_for(anchor)
5984
+ if wstart is None:
5985
+ # A legacy row carrying only the date-only boundary. One
5986
+ # shared interval still serves both the cost and the
5987
+ # percentage; taking them from different intervals is never
5988
+ # acceptable, so an unresolvable row contributes nothing and
5989
+ # `attributed_pct` stays None (#620 S1 A3).
5990
+ wstart = grid.start_for_date(wd)
5991
+ if wstart is None:
5992
+ continue
5993
+ else:
5994
+ # No anchor anywhere in the store: the genuine Monday fallback.
5995
+ wstart = _projects_week_start_monday_utc(dt.datetime.combine(
5996
+ wd, dt.time(0, 0, 0), tzinfo=dt.timezone.utc,
5997
+ ))
5509
5998
  if weekly_pct is not None:
5510
5999
  weekly_pct_by_week[wstart] = float(weekly_pct)
5511
6000
 
@@ -5532,12 +6021,11 @@ def _build_projects_envelope(
5532
6021
  if weeks_with_activity:
5533
6022
  # Window = inclusive [oldest_active_week, cw_start]. Always emits
5534
6023
  # cw_start (panel + trend share the same current_week column).
6024
+ # Keyed by the SAME rule as the current week (#620 S1 D1) — the
6025
+ # subscription intervals themselves, not a seven-day walk, because
6026
+ # a mixed keying inside one panel is the defect restated.
5535
6027
  oldest = min(weeks_with_activity[0], cw_start)
5536
- trend_weeks = []
5537
- w = oldest
5538
- while w <= cw_start:
5539
- trend_weeks.append(w)
5540
- w += dt.timedelta(days=7)
6028
+ trend_weeks = [w for w in weeks_full if oldest <= w <= cw_start]
5541
6029
  else:
5542
6030
  trend_weeks = [cw_start]
5543
6031
 
@@ -5613,6 +6101,19 @@ def _build_projects_envelope(
5613
6101
  sessions_per_week.append(len(b["sessions"]))
5614
6102
  first_seen_per_week.append(_iso_z(b["first_seen"]))
5615
6103
  last_seen_per_week.append(_iso_z(b["last_seen"]))
6104
+ session_counts_by_window: dict[str, int] = {}
6105
+ for window_weeks in PROJECT_WINDOW_WEEKS_CHOICES:
6106
+ selected_weeks = trend_weeks[-window_weeks:]
6107
+ # Cached real buckets contribute string identities; the
6108
+ # source-compatible fallback contributes opaque tuple identities.
6109
+ window_sessions: set[object] = set()
6110
+ for selected_week in selected_weeks:
6111
+ selected_bucket = buckets.get((bp, selected_week))
6112
+ if selected_bucket is not None:
6113
+ window_sessions.update(selected_bucket["sessions"])
6114
+ session_counts_by_window[str(window_weeks)] = len(
6115
+ window_sessions,
6116
+ )
5616
6117
  # Skip projects with zero total cost across the entire window
5617
6118
  # (the bucket-loop only enters projects that have at least one
5618
6119
  # entry, so this is mainly a safety check).
@@ -5624,6 +6125,7 @@ def _build_projects_envelope(
5624
6125
  "weekly_cost": weekly_cost,
5625
6126
  "weekly_pct": weekly_pct_arr,
5626
6127
  "sessions_per_week": sessions_per_week,
6128
+ "session_counts_by_window": session_counts_by_window,
5627
6129
  "first_seen_per_week": first_seen_per_week,
5628
6130
  "last_seen_per_week": last_seen_per_week,
5629
6131
  })
@@ -5729,14 +6231,87 @@ def _project_detail_for_window(
5729
6231
  if bucket_path is None:
5730
6232
  return None
5731
6233
 
5732
- # ---- Window bounds (Monday-anchored UTC fallback, like the builder) -
6234
+ # ---- Window bounds, from the envelope's own current-week anchor ----
6235
+ # That anchor is the account's real subscription week start; the
6236
+ # Monday-midnight snap is only the no-anchor fallback (#620).
5733
6237
  cw_start = parse_iso_datetime(
5734
6238
  env["current_week"]["week_start_at"],
5735
6239
  "projects.current_week.week_start_at",
6240
+ ).astimezone(dt.timezone.utc)
6241
+
6242
+ # The drill resolves the SAME interval union its panel resolved, by
6243
+ # rebuilding the panel's grid rather than stepping back in seven-day
6244
+ # multiples. Subscription reset shifts can leave gaps between buckets;
6245
+ # the outer start/end pair is only a candidate-query bound, while
6246
+ # ``window_intervals`` is the authoritative membership contract.
6247
+ # `_ProjectsWeekGrid` exists because a drifted reset day produces a
6248
+ # genuinely short week: on `non-monday-anchor` at `weeks_back=4` the grid
6249
+ # starts the window at 2026-03-27T09:00Z while a seven-day walk yields
6250
+ # 2026-03-26T09:00Z, and an early reset that shortens the current week
6251
+ # would likewise leave a `cw_start + 7d` end counting cost past the
6252
+ # week's real end. `window_start_at` / `window_end_at` publish these
6253
+ # bounds to the client as authoritative, so a divergence here renders a
6254
+ # window the panel never computed.
6255
+ #
6256
+ # The grid is rebuilt from the PANEL'S OWN anchor, not from `cw_start`.
6257
+ # `_projects_week_grid` derives its provisional range from an ISO-Monday
6258
+ # snap of whatever anchor it is handed, and `cw_start` is the interval
6259
+ # START while the panel anchors on `current_week.week_start_at` — which
6260
+ # after `_apply_midweek_reset_override` is the in-week reset instant and
6261
+ # can sit up to a week later. The two therefore snap to different Mondays
6262
+ # and `_compute_subscription_weeks` can pick a different extrapolation
6263
+ # anchor for each, so window equality would rest on a coincidence rather
6264
+ # than on the two surfaces asking the same question. `cw_start` remains
6265
+ # the anchor `window_ending_at` walks back from, because that walk needs
6266
+ # an interval start.
6267
+ panel_anchor = getattr(current_week, "week_start_at", None)
6268
+ if not isinstance(panel_anchor, dt.datetime):
6269
+ panel_anchor = now_utc
6270
+ detail_grid = _projects_week_grid(
6271
+ conn, anchor_utc=panel_anchor, weeks_back=weeks_back,
5736
6272
  )
5737
- since_dt = cw_start - dt.timedelta(days=7 * (weeks_back - 1))
5738
- until_dt = cw_start + dt.timedelta(days=7)
5739
-
6273
+ detail_bounds = (
6274
+ detail_grid.window_ending_at(cw_start, weeks_back)
6275
+ if detail_grid is not None else []
6276
+ )
6277
+ if detail_bounds:
6278
+ since_dt = detail_bounds[0][0]
6279
+ until_dt = detail_bounds[-1][1]
6280
+ else:
6281
+ # No anchor covers this start — the same no-anchor tail the panel
6282
+ # falls back to, where the seven-day assumption is the right one.
6283
+ since_dt = cw_start - dt.timedelta(days=7 * (weeks_back - 1))
6284
+ until_dt = cw_start + dt.timedelta(days=7)
6285
+ detail_bounds = [(since_dt, until_dt)]
6286
+ detail_bounds_utc = [
6287
+ (
6288
+ start.astimezone(dt.timezone.utc),
6289
+ end.astimezone(dt.timezone.utc),
6290
+ )
6291
+ for start, end in detail_bounds
6292
+ ]
6293
+ window_intervals = [
6294
+ {"start_at": _iso_z(start), "end_at": _iso_z(end)}
6295
+ for start, end in detail_bounds_utc
6296
+ ]
6297
+ since_iso = since_dt.astimezone(dt.timezone.utc).strftime(
6298
+ "%Y-%m-%dT%H:%M:%SZ"
6299
+ )
6300
+ until_iso = until_dt.astimezone(dt.timezone.utc).strftime(
6301
+ "%Y-%m-%dT%H:%M:%SZ"
6302
+ )
6303
+ # SQL candidate bound, NOT the published one. Ingestion stores
6304
+ # `timestamp_utc` as `…+00:00` while these bounds are spelled `…Z`, and
6305
+ # SQLite compares that column lexically with `+` (0x2B) below `Z` (0x5A).
6306
+ # So the lower bound drops an entry sitting exactly on it and the upper
6307
+ # bound admits one sitting exactly on it — an asymmetry in both
6308
+ # directions. Widening the lower bound by a second makes SQL an outward
6309
+ # candidate filter at both ends; the half-open membership test is then
6310
+ # enforced on the PARSED datetime in the entry loop, which is the only
6311
+ # place it can be stated honestly.
6312
+ since_sql_iso = (
6313
+ since_dt.astimezone(dt.timezone.utc) - dt.timedelta(seconds=1)
6314
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
5740
6315
  # ---- Build bucket → source_paths map for SQL-side scoping ----------
5741
6316
  # Walk session_files (~8k rows) once instead of session_entries
5742
6317
  # (~150k+ rows). _resolve_project_key gets called at most ~distinct-
@@ -5787,6 +6362,9 @@ def _project_detail_for_window(
5787
6362
  "key": project_key,
5788
6363
  "bucket_path": bucket_path,
5789
6364
  "window_weeks": weeks_back,
6365
+ "window_start_at": since_iso,
6366
+ "window_end_at": until_iso,
6367
+ "window_intervals": window_intervals,
5790
6368
  "window_cost_usd": 0.0,
5791
6369
  "window_attributed_pct": None,
5792
6370
  "models": [],
@@ -5810,13 +6388,6 @@ def _project_detail_for_window(
5810
6388
  [(p,) for p in bucket_source_paths],
5811
6389
  )
5812
6390
 
5813
- since_iso = since_dt.astimezone(dt.timezone.utc).strftime(
5814
- "%Y-%m-%dT%H:%M:%SZ"
5815
- )
5816
- until_iso = until_dt.astimezone(dt.timezone.utc).strftime(
5817
- "%Y-%m-%dT%H:%M:%SZ"
5818
- )
5819
-
5820
6391
  # ---- Walk session_entries (project-scoped) once -------------------
5821
6392
  # INNER JOIN to _drill_paths drops every row whose source_path
5822
6393
  # doesn't belong to this bucket. The Python-side filter that
@@ -5832,7 +6403,7 @@ def _project_detail_for_window(
5832
6403
  "LEFT JOIN session_files sf ON sf.path = e.source_path "
5833
6404
  "WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
5834
6405
  "ORDER BY e.timestamp_utc ASC, e.id ASC",
5835
- (since_iso, until_iso),
6406
+ (since_sql_iso, until_iso),
5836
6407
  )
5837
6408
 
5838
6409
  # Per-model rollup: {model -> {cost_usd, sessions, in, out, cache_*}}
@@ -5855,6 +6426,17 @@ def _project_detail_for_window(
5855
6426
  # on _drill_paths already restricted the result set to entries
5856
6427
  # whose source_path belongs to this bucket.
5857
6428
  ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
6429
+ # The half-open membership test. The SQL bounds above are a widened
6430
+ # candidate filter that admits a second on each side, because the
6431
+ # column's stored offset spelling and the bound's spelling do not
6432
+ # compare the way the interval means; this is where the interval is
6433
+ # actually decided.
6434
+ ts_utc = ts.astimezone(dt.timezone.utc)
6435
+ if not any(
6436
+ interval_start <= ts_utc < interval_end
6437
+ for interval_start, interval_end in detail_bounds_utc
6438
+ ):
6439
+ continue
5858
6440
  entry_cost = _calculate_entry_cost(
5859
6441
  model,
5860
6442
  claude_usage_dict( # #195 chokepoint
@@ -5977,6 +6559,9 @@ def _project_detail_for_window(
5977
6559
  "key": project_key,
5978
6560
  "bucket_path": bucket_path,
5979
6561
  "window_weeks": weeks_back,
6562
+ "window_start_at": since_iso,
6563
+ "window_end_at": until_iso,
6564
+ "window_intervals": window_intervals,
5980
6565
  "window_cost_usd": window_cost,
5981
6566
  "window_attributed_pct": win_pct,
5982
6567
  "models": models_out,
@@ -6191,6 +6776,36 @@ def _qs_str(q: dict, key: str, default: str | None) -> str | None:
6191
6776
  return vals[0] if vals else default
6192
6777
 
6193
6778
 
6779
+ def _qs_flag(q: dict, key: str) -> bool:
6780
+ """Parse a single query-string boolean.
6781
+
6782
+ A bare `?flag` and `?flag=1` are both true; `0`, `false` and `no` are
6783
+ false. Written out rather than spelled `bool(_qs_str(...))`, because that
6784
+ reads `?reveal_projects=0` as a REQUEST to reveal — the string "0" is
6785
+ truthy — which is the wrong direction for a privacy switch.
6786
+ """
6787
+ raw = _qs_str(q, key, None)
6788
+ if raw is None:
6789
+ return False
6790
+ return raw.strip().lower() not in ("0", "false", "no", "off")
6791
+
6792
+
6793
+ class _DiagnosisSelectorError(Exception):
6794
+ """A `/api/diagnosis` request whose selectors are wrong (#620 S2).
6795
+
6796
+ Distinct from `EstablishmentFailure`, whose codes are the closed
6797
+ report-establishment enum. A selector this route rejects — an unknown
6798
+ source, an account combined with `source=all` — never reached the point of
6799
+ establishing a report, so it carries its own code rather than borrowing one
6800
+ from that enum and claiming a stage it never got to.
6801
+ """
6802
+
6803
+ def __init__(self, code: str, message: str) -> None:
6804
+ super().__init__(message)
6805
+ self.code = code
6806
+ self.message = message
6807
+
6808
+
6194
6809
  # ── /api/debug/backend on-demand cache-state helpers (issue #276, Session A) ──
6195
6810
  # All read-only, cheap, and privacy-safe: they leak ONLY row counts, signature
6196
6811
  # legs (ints/tuples), pending-flag names, and the tool version — never prompt /
@@ -6471,6 +7086,11 @@ _GET_ROUTES = (
6471
7086
  ("exact", "/api/share/presets", "_handle_share_presets_get", None, False),
6472
7087
  ("exact", "/api/share/history", "_handle_share_history_get", None, False),
6473
7088
  ("exact", "/api/doctor", "_handle_get_doctor", None, False),
7089
+ # #620 S2 — the on-demand diagnosis. Exact, so it can neither shadow nor
7090
+ # be shadowed by the parameterized conversation routes below (dispatch
7091
+ # compares exact paths first, so the placement here is a convention).
7092
+ ("exact", "/api/diagnosis", "_handle_get_diagnosis",
7093
+ ("scope", "endpoint.diagnosis"), False),
6474
7094
  ("exact", "/api/debug/backend", "_handle_get_debug_backend", None, False),
6475
7095
  ("exact", "/api/conversations/facets", "_handle_get_conversations_facets",
6476
7096
  ("scope", "endpoint.conversations_facets"), False),
@@ -6948,32 +7568,22 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
6948
7568
  })
6949
7569
  return
6950
7570
  try:
6951
- # The locked section is a REBUILD, and this is the path an
6952
- # uncontended manual refresh actually takes, so it reports itself
6953
- # like every other rebuild does.
6954
- cls.mark_rebuilding(True)
6955
- warnings: list = []
6956
- if do_refresh:
6957
- if cls.no_sync:
6958
- warnings.append({"code": "refresh_skipped_no_sync"})
6959
- else:
6960
- result = _refresh_usage_inproc()
6961
- if result.status != "ok":
6962
- warnings.append({"code": result.status})
6963
- try:
6964
- cls.run_sync_now_locked()
6965
- except Exception as exc:
6966
- self.log_error("/api/sync rebuild failed: %r", exc)
6967
- self.send_error(500, "sync failed")
6968
- return
7571
+ with _rebuilding_claim(cls.mark_rebuilding):
7572
+ warnings: list = []
7573
+ if do_refresh:
7574
+ if cls.no_sync:
7575
+ warnings.append({"code": "refresh_skipped_no_sync"})
7576
+ else:
7577
+ result = _refresh_usage_inproc()
7578
+ if result.status != "ok":
7579
+ warnings.append({"code": result.status})
7580
+ try:
7581
+ cls.run_sync_now_locked()
7582
+ except Exception as exc:
7583
+ self.log_error("/api/sync rebuild failed: %r", exc)
7584
+ self.send_error(500, "sync failed")
7585
+ return
6969
7586
  finally:
6970
- # Drops THIS thread's claim only, so the ordering against the lock
6971
- # release is not what makes it safe: a concurrent rebuilder's claim
6972
- # is a different set member and this call cannot touch it, whichever
6973
- # side of the release it runs on. On the success path the rebuild's
6974
- # terminal publish has already dropped this thread's claim and this
6975
- # adds no frame; the exception path is what needs it.
6976
- cls.mark_rebuilding(False)
6977
7587
  sync_lock.release()
6978
7588
 
6979
7589
  if warnings:
@@ -7992,12 +8602,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
7992
8602
  # outside the acquire because `run_sync_now` takes `sync_lock` itself;
7993
8603
  # a wait for a rebuild already in flight is honestly in-flight too.
7994
8604
  try:
7995
- type(self).mark_rebuilding(True)
7996
- type(self).run_sync_now()
8605
+ with _rebuilding_claim(type(self).mark_rebuilding):
8606
+ type(self).run_sync_now()
7997
8607
  except Exception as exc:
7998
8608
  eprint(f"warning: settings broadcast failed: {exc!r}")
7999
- finally:
8000
- type(self).mark_rebuilding(False)
8001
8609
 
8002
8610
  self._respond_json(200, out)
8003
8611
 
@@ -8098,10 +8706,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8098
8706
  return
8099
8707
 
8100
8708
  if axis == "weekly":
8709
+ # Mirrors the CLI `alerts test --axis weekly` branch: the preview
8710
+ # carries the reset INSTANT a real crossing carries, so it renders
8711
+ # the instant form rather than the day-granularity fallback.
8712
+ preview_week_start = synthetic_preview_week_start()
8101
8713
  payload = _build_alert_payload_weekly(
8102
8714
  threshold=threshold,
8103
8715
  crossed_at_utc=now_utc_iso(),
8104
- week_start_date=dt.date.today().isoformat(),
8716
+ week_start_date=preview_week_start.date().isoformat(),
8717
+ week_start_at=preview_week_start.isoformat().replace(
8718
+ "+00:00", "Z"
8719
+ ),
8105
8720
  cumulative_cost_usd=1.23,
8106
8721
  dollars_per_percent=0.01,
8107
8722
  )
@@ -8409,6 +9024,250 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8409
9024
  self.log_error("/api/doctor failed after commit: %r", exc)
8410
9025
  self.close_connection = True
8411
9026
 
9027
+ # ── GET /api/diagnosis (#620 S2, spec §5) ───────────────────────────────
9028
+ #
9029
+ # The on-demand diagnosis. NOT an envelope key: an envelope key would pay
9030
+ # #607's client per-frame cost on every tick for a surface most ticks never
9031
+ # display, and would force a deliberate re-take of the byte-identity
9032
+ # baseline at bench/baselines/envelope-oracle.json.
9033
+
9034
+ def _send_diagnosis_json(self, status: int, body: dict) -> None:
9035
+ encoded = encode_dashboard_json_bytes(body, ensure_ascii=False)
9036
+ self.send_response(status)
9037
+ self.send_header("Content-Type", "application/json; charset=utf-8")
9038
+ self.send_header("Content-Length", str(len(encoded)))
9039
+ self.send_header("Cache-Control", "no-cache")
9040
+ self.end_headers()
9041
+ self.wfile.write(encoded)
9042
+
9043
+ def _diagnosis_selectors(self, query):
9044
+ """Resolve one request's selectors into a `DiagnosisScope`.
9045
+
9046
+ Raises `_DiagnosisSelectorError` for anything the caller got wrong and
9047
+ `EstablishmentFailure` for anything that could not be established. Both
9048
+ are 400 here; only `store_unavailable` and `generation_incoherent` are
9049
+ 503, because those two say the machine could not answer rather than
9050
+ that the request was wrong.
9051
+ """
9052
+ c = _cctally()
9053
+ diagnosis = c._load_sibling("_cctally_diagnosis")
9054
+ sources = c._load_sibling("_cctally_diagnosis_sources")
9055
+ kernel = c._load_sibling("_lib_diagnosis")
9056
+
9057
+ source = _qs_str(query, "source", "claude") or "claude"
9058
+ if source not in ("claude", "codex", "all"):
9059
+ raise _DiagnosisSelectorError(
9060
+ "invalid_selector",
9061
+ f"source must be claude, codex or all, not {source!r}")
9062
+ account = _qs_str(query, "account", None)
9063
+ if account is not None and source == "all":
9064
+ # Account keys are provider-scoped, so one selector cannot address
9065
+ # both providers. The CLI exits 2 on the same condition.
9066
+ raise _DiagnosisSelectorError(
9067
+ "invalid_selector",
9068
+ "account cannot be combined with source=all "
9069
+ "(account keys are provider-scoped)")
9070
+ speed = _qs_str(query, "speed", None)
9071
+ if speed in ("auto", ""):
9072
+ speed = None
9073
+ if speed is not None and speed not in ("standard", "fast"):
9074
+ raise _DiagnosisSelectorError(
9075
+ "invalid_selector",
9076
+ f"speed must be auto, standard or fast, not {speed!r}")
9077
+
9078
+ raw_tz = _qs_str(query, "tz", None)
9079
+ config = _apply_display_tz_override(
9080
+ load_config(), type(self).display_tz_pref_override,
9081
+ )
9082
+ try:
9083
+ tz_name = diagnosis.resolve_tz_name(
9084
+ argparse.Namespace(tz=raw_tz), config,
9085
+ )
9086
+ except ValueError as exc:
9087
+ raise _DiagnosisSelectorError("invalid_selector", str(exc)) from exc
9088
+
9089
+ now_utc = _command_as_of()
9090
+ start_raw = _qs_str(query, "start_at", None)
9091
+ end_raw = _qs_str(query, "end_at", None)
9092
+ if start_raw or end_raw:
9093
+ # The alert-follow path carries INSTANTS, not calendar days: a
9094
+ # five-hour block start is not a date, so the window grammar's
9095
+ # date-only form cannot reproduce the window a warning fired
9096
+ # against. Explicit bounds are how that window reaches the route.
9097
+ if not (start_raw and end_raw):
9098
+ raise kernel.EstablishmentFailure(
9099
+ kernel.EstablishmentError.RANGE_UNRESOLVED.value,
9100
+ "start_at and end_at must be given together")
9101
+ start_at = parse_iso_datetime(start_raw, "start_at")
9102
+ end_at = parse_iso_datetime(end_raw, "end_at")
9103
+ start_at = (start_at.replace(tzinfo=dt.timezone.utc)
9104
+ if start_at.tzinfo is None
9105
+ else start_at.astimezone(dt.timezone.utc))
9106
+ end_at = (end_at.replace(tzinfo=dt.timezone.utc)
9107
+ if end_at.tzinfo is None
9108
+ else end_at.astimezone(dt.timezone.utc))
9109
+ label = ""
9110
+ else:
9111
+ token = _qs_str(query, "window", "this-week") or "this-week"
9112
+ diff_kernel = c._load_sibling("_lib_diff_kernel")
9113
+ try:
9114
+ parsed = diagnosis._resolve_window(
9115
+ argparse.Namespace(window=token), now_utc, tz_name,
9116
+ )
9117
+ except diff_kernel.NoAnchorError as exc:
9118
+ # A week token this machine holds no anchor for is an
9119
+ # unresolved RANGE. It is a `RuntimeError`, so without this it
9120
+ # reached the outer handler and was served as 500 — an
9121
+ # unexplained internal error for a request the server could
9122
+ # perfectly well describe.
9123
+ raise kernel.EstablishmentFailure(
9124
+ kernel.EstablishmentError.RANGE_UNRESOLVED.value,
9125
+ str(exc)) from exc
9126
+ start_at, end_at, label = (parsed.start_utc, parsed.end_utc,
9127
+ parsed.label)
9128
+
9129
+ account_key = None
9130
+ if account is not None:
9131
+ account_key = self._resolve_diagnosis_account(
9132
+ sources, account,
9133
+ source if source in ("claude", "codex") else "claude",
9134
+ )
9135
+ # DiagnosisScope's own __post_init__ raises `range_unresolved` for a
9136
+ # naive or inverted window, so an out-of-order pair is rejected by the
9137
+ # same rule the CLI applies rather than by a second one here.
9138
+ return sources.DiagnosisScope(
9139
+ source=source,
9140
+ account_key=account_key,
9141
+ window_start=start_at,
9142
+ window_end=end_at,
9143
+ effective_speed=speed,
9144
+ display_tz=tz_name,
9145
+ label=label,
9146
+ ), now_utc, _qs_flag(query, "reveal_projects")
9147
+
9148
+ @staticmethod
9149
+ def _resolve_diagnosis_account(sources, ref: str, provider: str):
9150
+ """Resolve `?account=` over the diagnosis's own read-only open path.
9151
+
9152
+ `resolve_account_filter` reaches stats.db through the ordinary opener,
9153
+ which migrates, repairs, imports and replays. This route reads and
9154
+ never writes, so it resolves the ref over `mode=ro`, exactly as
9155
+ `cmd_explain` does — and a ref that could not be resolved for ANY
9156
+ reason, including a machine with no registry at all, is an unresolved
9157
+ account rather than an unavailable store.
9158
+ """
9159
+ import _lib_accounts as accounts
9160
+ kernel = _cctally()._load_sibling("_lib_diagnosis")
9161
+ try:
9162
+ conn = sources.open_read_only("stats")
9163
+ except kernel.EstablishmentFailure as exc:
9164
+ raise kernel.EstablishmentFailure(
9165
+ kernel.EstablishmentError.ACCOUNT_UNRESOLVED.value,
9166
+ f"account {ref!r} is ambiguous or unknown "
9167
+ f"(this machine holds no account registry)") from exc
9168
+ try:
9169
+ return accounts.resolve_account_ref(conn, ref, provider)
9170
+ except accounts.AccountRefError as exc:
9171
+ raise kernel.EstablishmentFailure(
9172
+ kernel.EstablishmentError.ACCOUNT_UNRESOLVED.value,
9173
+ f"account {ref!r} is ambiguous or unknown") from exc
9174
+ finally:
9175
+ conn.close()
9176
+
9177
+ def _handle_get_diagnosis(self) -> None:
9178
+ """`GET /api/diagnosis` — the on-demand diagnosis (#620 S2, spec §5).
9179
+
9180
+ Read-only and mutating nothing: the whole read goes through
9181
+ `_cctally_diagnosis_sources`, whose only opener is a `mode=ro` connect
9182
+ that performs no schema work, no migration, no legacy import and no
9183
+ contract repair. It runs on the request thread, outside the snapshot
9184
+ build and outside its pinned cache transaction.
9185
+
9186
+ `_require_api_auth` applies automatically, before dispatch. No CSRF
9187
+ check: `_check_origin_csrf` is opt-in for the routes that mutate, and
9188
+ this one does not.
9189
+
9190
+ Status codes: 200 for any valid coherently-generated report, including
9191
+ a healthy one, an empty one and one whose fields are withheld; 400 for
9192
+ a malformed selector and for an unresolved range or account; 503 for
9193
+ `generation_incoherent`, for `store_unavailable`, and for a report the
9194
+ kernel's `unreadable_store_is_terminal` holds over — that last one
9195
+ publishes the withheld REPORT as the body, not an empty error, for the
9196
+ same reason the CLI still prints it while exiting 3: a person needs to
9197
+ read the typed cause. 500 for an unexpected invariant failure, which is
9198
+ never converted into a healthy 200.
9199
+ """
9200
+ import urllib.parse as _urlparse
9201
+
9202
+ c = _cctally()
9203
+ kernel = c._load_sibling("_lib_diagnosis")
9204
+ diagnosis = c._load_sibling("_cctally_diagnosis")
9205
+ sources = c._load_sibling("_cctally_diagnosis_sources")
9206
+ query = _urlparse.parse_qs(_urlparse.urlparse(self.path).query)
9207
+
9208
+ # Preparation and commit are separate, as on every other JSON route:
9209
+ # before headers a failure can still become a JSON 500; after them the
9210
+ # only valid recovery is log + close.
9211
+ try:
9212
+ try:
9213
+ scope, now_utc, reveal = self._diagnosis_selectors(query)
9214
+ # Evaluated ONCE, before the plan is resolved, and threaded
9215
+ # into plan stage 1. A class denied there is settled: the
9216
+ # store it would have needed is never opened, probed or
9217
+ # digested, and the identifier this response publishes
9218
+ # describes what actually ran rather than what was intended.
9219
+ # The predicate itself is untouched (D-E) — this composes it,
9220
+ # it does not change it.
9221
+ report = sources.build_diagnosis(
9222
+ scope, measured_at=now_utc,
9223
+ transcripts_visible=self._transcripts_visible_to_request(),
9224
+ )
9225
+ except _DiagnosisSelectorError as exc:
9226
+ self._send_diagnosis_json(
9227
+ 400, {"error": exc.message, "code": exc.code})
9228
+ return
9229
+ except ValueError as exc:
9230
+ # A malformed instant from `parse_iso_datetime`.
9231
+ self._send_diagnosis_json(400, {
9232
+ "error": str(exc),
9233
+ "code": kernel.EstablishmentError.RANGE_UNRESOLVED.value,
9234
+ })
9235
+ return
9236
+ except kernel.EstablishmentFailure as exc:
9237
+ # `store_unavailable` reaches this arm only from SELECTOR
9238
+ # resolution — the week anchor, which reads stats.db through
9239
+ # the ordinary opener. It can never arrive from
9240
+ # `build_diagnosis`, which converts every store failure it
9241
+ # meets into `_unavailable_provider_result` so the withheld
9242
+ # report is published rather than lost; nor from
9243
+ # `_resolve_diagnosis_account`, which re-codes an unopenable
9244
+ # stats.db as `account_unresolved`. Both conversions are
9245
+ # deliberate, so do not read the mapping as dead code.
9246
+ status = 503 if exc.code in (
9247
+ kernel.EstablishmentError.STORE_UNAVAILABLE.value,
9248
+ kernel.EstablishmentError.GENERATION_INCOHERENT.value,
9249
+ ) else 400
9250
+ self._send_diagnosis_json(
9251
+ status, {"error": exc.message, "code": exc.code})
9252
+ return
9253
+
9254
+ scopes = {result.source: diagnosis._scope_for(sources, scope,
9255
+ result.source)
9256
+ for result in report.results}
9257
+ body = diagnosis.diagnosis_to_wire(report, scopes=scopes,
9258
+ reveal_projects=reveal)
9259
+ status = 503 if kernel.unreadable_store_is_terminal(report) else 200
9260
+ except Exception as exc: # noqa: BLE001
9261
+ self.log_error("/api/diagnosis failed before commit: %r", exc)
9262
+ self._send_diagnosis_json(500, {"error": "internal error"})
9263
+ return
9264
+
9265
+ try:
9266
+ self._send_diagnosis_json(status, body)
9267
+ except Exception as exc: # noqa: BLE001
9268
+ self.log_error("/api/diagnosis failed after commit: %r", exc)
9269
+ self.close_connection = True
9270
+
8412
9271
  def _handle_get_session_detail(self, path: str) -> None:
8413
9272
  """Return TuiSessionDetail JSON for the given session id (spec §3.2).
8414
9273
 
@@ -8727,12 +9586,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8727
9586
  entries_in_window = list(get_entries(
8728
9587
  start_at, end_at, skip_sync=self.no_sync,
8729
9588
  ))
9589
+ entry_membership: dict[int, list[UsageEntry]] = {}
8730
9590
  blocks = _group_entries_into_blocks(
8731
9591
  entries_in_window, mode="auto",
8732
9592
  recorded_windows=recorded_windows,
8733
9593
  block_start_overrides=block_start_overrides,
8734
9594
  canonical_intervals=canonical_intervals,
8735
9595
  now=now_utc,
9596
+ _entry_membership=entry_membership,
8736
9597
  )
8737
9598
  target = next(
8738
9599
  (b for b in blocks
@@ -8743,10 +9604,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8743
9604
  status = 404
8744
9605
  body = encode_dashboard_json_bytes({"error": "block not found"})
8745
9606
  else:
8746
- block_entries = [
8747
- e for e in entries_in_window
8748
- if target.start_time <= e.timestamp < target.end_time
8749
- ]
9607
+ block_entries = entry_membership[id(target)]
8750
9608
  # Resolve display tz once per request so the block detail's
8751
9609
  # `label` matches the snapshot envelope's blocks panel.
8752
9610
  # Shared resolver -- same warn-once semantics as