cctally 1.100.0 → 1.101.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 (36) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/bin/_cctally_alerts.py +13 -2
  3. package/bin/_cctally_cache.py +3 -1
  4. package/bin/_cctally_cache_report.py +103 -6
  5. package/bin/_cctally_dashboard.py +713 -166
  6. package/bin/_cctally_dashboard_envelope.py +28 -38
  7. package/bin/_cctally_dashboard_share.py +101 -29
  8. package/bin/_cctally_dashboard_sources.py +663 -192
  9. package/bin/_cctally_diff.py +20 -0
  10. package/bin/_cctally_forecast.py +304 -96
  11. package/bin/_cctally_milestone_history.py +10 -2
  12. package/bin/_cctally_project.py +155 -47
  13. package/bin/_cctally_record.py +7 -1
  14. package/bin/_cctally_share.py +9 -2
  15. package/bin/_cctally_source_analytics.py +40 -4
  16. package/bin/_cctally_tui.py +425 -234
  17. package/bin/_lib_alert_scope.py +685 -0
  18. package/bin/_lib_alerts_payload.py +112 -7
  19. package/bin/_lib_cache_report.py +110 -1
  20. package/bin/_lib_codex_pools.py +20 -8
  21. package/bin/_lib_dashboard_sources.py +212 -24
  22. package/bin/_lib_forecast.py +12 -4
  23. package/bin/_lib_pricing.py +8 -7
  24. package/bin/_lib_render.py +31 -3
  25. package/bin/_lib_share_templates.py +150 -55
  26. package/bin/_lib_snapshot_cache.py +71 -13
  27. package/bin/_lib_source_identity.py +50 -2
  28. package/bin/_lib_subscription_weeks.py +65 -0
  29. package/bin/cctally +10 -0
  30. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  31. package/dashboard/static/assets/index-D6Eb9KDn.js +97 -0
  32. package/dashboard/static/assets/index-i3g7g8zo.css +1 -0
  33. package/dashboard/static/dashboard.html +2 -2
  34. package/package.json +2 -1
  35. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  36. 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", []),
@@ -2017,6 +2019,10 @@ def _build_alert_payload_weekly(*args, **kwargs):
2017
2019
  return sys.modules["cctally"]._build_alert_payload_weekly(*args, **kwargs)
2018
2020
 
2019
2021
 
2022
+ def synthetic_preview_week_start(*args, **kwargs):
2023
+ return sys.modules["cctally"].synthetic_preview_week_start(*args, **kwargs)
2024
+
2025
+
2020
2026
  def _build_alert_payload_five_hour(*args, **kwargs):
2021
2027
  return sys.modules["cctally"]._build_alert_payload_five_hour(*args, **kwargs)
2022
2028
 
@@ -3770,7 +3776,22 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
3770
3776
  recorded-windows-widening trick (loads reset windows from
3771
3777
  ``[start - BLOCK_DURATION, end + BLOCK_DURATION]`` so a recorded
3772
3778
  reset just outside the visible window can still anchor blocks
3773
- inside it) and the strict-window entry filter.
3779
+ inside it) and the post-group block-overlap filter.
3780
+
3781
+ #620 S1 D8: the entry set is grouped into native blocks FIRST and the
3782
+ week filter is then applied to whole blocks, retaining every block whose
3783
+ interval overlaps ``[week_start_at, week_end_at)`` with its full native
3784
+ totals. It used to filter ENTRIES to the week before grouping, so a
3785
+ block straddling a week boundary was folded from only the part of itself
3786
+ that fell inside the week — while ``/api/block/<iso>`` fetches the
3787
+ block's own native window and applies no week clip, so the panel and its
3788
+ own drilldown reported different totals for the same ``start_at`` and
3789
+ the panel's was permanently short. Selecting a block that overlaps the
3790
+ week is the deliberate part of the contract and stays; clipping a
3791
+ selected block's contents was the defect.
3792
+
3793
+ The fetch already read one block duration on each side, so this needs no
3794
+ additional query.
3774
3795
 
3775
3796
  Returning the full ``BlocksView`` (rows + totals) lets the sync
3776
3797
  thread populate ``DataSnapshot.blocks_total_cost_usd`` /
@@ -3781,13 +3802,12 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
3781
3802
  fetch_start = week_start_at - BLOCK_DURATION
3782
3803
  fetch_end = week_end_at + BLOCK_DURATION
3783
3804
  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
3805
 
3786
3806
  recorded_windows, block_start_overrides, canonical_intervals = (
3787
3807
  _load_recorded_five_hour_windows(fetch_start, fetch_end)
3788
3808
  )
3789
3809
  c = _cctally()
3790
- return c.build_blocks_view(
3810
+ view = c.build_blocks_view(
3791
3811
  entries,
3792
3812
  now_utc=now_utc,
3793
3813
  recorded_windows=recorded_windows,
@@ -3798,6 +3818,60 @@ def _dashboard_build_blocks_view(conn: "sqlite3.Connection",
3798
3818
  display_tz=display_tz,
3799
3819
  mode="auto",
3800
3820
  )
3821
+ return _blocks_view_overlapping_week(
3822
+ view, week_start_at=week_start_at, week_end_at=week_end_at,
3823
+ )
3824
+
3825
+
3826
+ def _blocks_view_overlapping_week(view, *, week_start_at, week_end_at):
3827
+ """Retain only the blocks whose interval overlaps
3828
+ ``[week_start_at, week_end_at)``, keeping each retained block's FULL
3829
+ native totals (#620 S1 D8).
3830
+
3831
+ Overlap is the standard half-open test ``start < week_end and end >
3832
+ week_start``: a block that merely touches a bound (its end exactly at
3833
+ ``week_start_at``, or its start exactly at ``week_end_at``) shares no
3834
+ instant with the week and is not retained.
3835
+
3836
+ Totals are re-derived from the retained non-gap blocks so the React
3837
+ panel's ``footer total == sum(visible rows)`` invariant still holds, and
3838
+ ``aggregated`` is filtered in lockstep so no consumer can read a block
3839
+ set that disagrees with ``rows``.
3840
+ """
3841
+ kept_blocks = []
3842
+ total_cost = 0.0
3843
+ total_tokens = 0
3844
+ kept_starts = set()
3845
+ for b in view.aggregated:
3846
+ start = getattr(b, "start_time", None)
3847
+ end = getattr(b, "end_time", None)
3848
+ if start is None or end is None:
3849
+ # API-anchored views carry dicts, not Blocks. This adapter only
3850
+ # ever sees the heuristic path, but degrade by retaining rather
3851
+ # than silently dropping a shape we cannot classify.
3852
+ kept_blocks.append(b)
3853
+ continue
3854
+ if not (start < week_end_at and end > week_start_at):
3855
+ continue
3856
+ kept_blocks.append(b)
3857
+ if getattr(b, "is_gap", False):
3858
+ continue
3859
+ # Plain `+=` rather than `stable_sum`, deliberately: this mirrors
3860
+ # `build_blocks_view`'s own accumulation (`bin/_lib_view_models.py`),
3861
+ # and this function exists to publish the SAME totals that view
3862
+ # publishes. A different fold here could disagree with it in the last
3863
+ # ULP, which is the divergence the function was written to remove.
3864
+ total_cost += b.cost_usd
3865
+ total_tokens += b.total_tokens
3866
+ kept_starts.add(start.astimezone(dt.timezone.utc).isoformat())
3867
+ rows = tuple(r for r in view.rows if r.start_at in kept_starts)
3868
+ return dataclasses.replace(
3869
+ view,
3870
+ rows=rows,
3871
+ aggregated=tuple(kept_blocks),
3872
+ total_cost_usd=total_cost,
3873
+ total_tokens=total_tokens,
3874
+ )
3801
3875
 
3802
3876
 
3803
3877
  def _dashboard_build_blocks_panel(conn: "sqlite3.Connection",
@@ -4152,6 +4226,158 @@ def _projects_week_start_monday_utc(ts: "dt.datetime") -> "dt.datetime":
4152
4226
  )
4153
4227
 
4154
4228
 
4229
+ class _ProjectsWeekGrid:
4230
+ """The ordered half-open subscription intervals the Projects panel
4231
+ attributes cost into (#620 S1 D1).
4232
+
4233
+ ``_projects_week_start_monday_utc`` above is the fallback used when no
4234
+ snapshot anchor is available; this is what replaces it when one IS
4235
+ available. Intervals come from ``_compute_subscription_weeks``, the same
4236
+ kernel ``cmd_project`` buckets by, so the panel and the CLI describe one
4237
+ set of weeks rather than two.
4238
+
4239
+ Intervals are half-open ``[start, end)`` and are NOT assumed to be seven
4240
+ days long: Anthropic's reset day drifts, and a drifted cycle produces a
4241
+ genuinely short week. ``end_for`` therefore returns the interval's own
4242
+ end, never ``start + 7d``.
4243
+ """
4244
+
4245
+ __slots__ = ("starts", "ends", "_end_by_start", "_start_by_date")
4246
+
4247
+ def __init__(self, bounds: "list[tuple[dt.datetime, dt.datetime]]"):
4248
+ ordered = sorted(bounds, key=lambda b: b[0])
4249
+ self.starts = [b[0] for b in ordered]
4250
+ self.ends = [b[1] for b in ordered]
4251
+ self._end_by_start = {s: e for s, e in ordered}
4252
+ # `weekly_usage_snapshots.week_start_date` is the date-only lookup
4253
+ # key a legacy row carries when it has no `week_start_at`. Later
4254
+ # intervals win a collision, matching the "last capture per week
4255
+ # wins" rule the percentage read already applies.
4256
+ self._start_by_date = {s.date(): s for s in self.starts}
4257
+
4258
+ def __bool__(self) -> bool:
4259
+ return bool(self.starts)
4260
+
4261
+ def week_for(self, ts: "dt.datetime") -> "dt.datetime | None":
4262
+ """The start of the interval containing ``ts``, or None when ``ts``
4263
+ falls outside every interval.
4264
+
4265
+ First-match-wins on the reset-day-drift overlap the clamp can leave
4266
+ behind — the same walk-back `cmd_project._week_start_for` performs,
4267
+ so an entry near a drifted boundary lands in the same week on both
4268
+ surfaces.
4269
+ """
4270
+ ts_utc = ts.astimezone(dt.timezone.utc)
4271
+ idx = bisect.bisect_right(self.starts, ts_utc) - 1
4272
+ if idx < 0:
4273
+ return None
4274
+ while idx > 0 and self.starts[idx - 1] <= ts_utc < self.ends[idx - 1]:
4275
+ idx -= 1
4276
+ if self.starts[idx] <= ts_utc < self.ends[idx]:
4277
+ return self.starts[idx]
4278
+ return None
4279
+
4280
+ def start_for_date(self, day: "dt.date") -> "dt.datetime | None":
4281
+ """The interval start whose own date is ``day``, for a legacy
4282
+ snapshot row that carries ``week_start_date`` but no
4283
+ ``week_start_at``."""
4284
+ return self._start_by_date.get(day)
4285
+
4286
+ def end_for(self, start: "dt.datetime") -> "dt.datetime":
4287
+ """The interval's real end. Falls back to ``start + 7d`` only for a
4288
+ start this grid does not know, which the padding below produces."""
4289
+ return self._end_by_start.get(start, start + dt.timedelta(days=7))
4290
+
4291
+ def window_ending_at(
4292
+ self, cw_start: "dt.datetime", weeks_back: int,
4293
+ ) -> "list[tuple[dt.datetime, dt.datetime]]":
4294
+ """The last ``weeks_back`` intervals up to and including the one
4295
+ starting at ``cw_start``, oldest first.
4296
+
4297
+ When the grid holds fewer than ``weeks_back`` intervals at or before
4298
+ ``cw_start``, the head is padded backwards in seven-day steps. The
4299
+ padding is a genuine no-anchor tail — history older than any snapshot
4300
+ — so the seven-day assumption is the right one there.
4301
+
4302
+ The walk itself lives in ``_lib_subscription_weeks`` because
4303
+ ``cmd_project`` performs the same one; a copy here is how the two
4304
+ surfaces drifted apart in the first place.
4305
+ """
4306
+ return _cctally().subscription_window_ending_at(
4307
+ list(zip(self.starts, self.ends)), cw_start, weeks_back,
4308
+ )
4309
+
4310
+
4311
+ def _projects_week_grid(
4312
+ conn: "sqlite3.Connection",
4313
+ *,
4314
+ anchor_utc: "dt.datetime",
4315
+ weeks_back: int,
4316
+ account_key: "str | None" = None,
4317
+ ) -> "_ProjectsWeekGrid | None":
4318
+ """Build the panel's subscription-week grid, or None when no snapshot
4319
+ row carries an anchor.
4320
+
4321
+ Returning None is the deliberate no-anchor path: the caller then keeps
4322
+ ``_projects_week_start_monday_utc`` throughout, which is what that
4323
+ function was written for and what every anchorless fixture already
4324
+ exercises byte-identically.
4325
+
4326
+ Cost: one grouped read of ``weekly_usage_snapshots`` plus the reset-event
4327
+ join `_compute_subscription_weeks` already performs. It adds no walk over
4328
+ ``session_entries`` and nothing per entry, so this does not re-open the
4329
+ per-tick rescan #583 owns.
4330
+ """
4331
+ acct_pred = "" if account_key is None else " AND account_key = ?"
4332
+ acct_params: tuple = () if account_key is None else (account_key,)
4333
+ try:
4334
+ row = conn.execute(
4335
+ "SELECT COUNT(*) FROM weekly_usage_snapshots "
4336
+ "WHERE week_start_at IS NOT NULL "
4337
+ " AND week_end_at IS NOT NULL "
4338
+ " AND week_start_date IS NOT NULL"
4339
+ f"{acct_pred}",
4340
+ acct_params,
4341
+ ).fetchone()
4342
+ except sqlite3.OperationalError:
4343
+ return None
4344
+ if not row or not row[0]:
4345
+ return None
4346
+
4347
+ # A generous provisional range, defined once in `_lib_subscription_weeks`
4348
+ # because `cmd_project` needs the identical range: the extrapolation
4349
+ # anchor `_compute_subscription_weeks` picks is relative to `range_start`,
4350
+ # so two callers asking the same question over different ranges can be
4351
+ # handed differently-phased intervals for the same history.
4352
+ range_start, range_end = _cctally().subscription_window_probe_range(
4353
+ anchor_utc, weeks_back,
4354
+ )
4355
+ try:
4356
+ subweeks = _cctally()._compute_subscription_weeks(
4357
+ conn, range_start, range_end, account_key=account_key,
4358
+ )
4359
+ except Exception:
4360
+ # A malformed anchor must not take the panel down; the Monday
4361
+ # fallback still renders a coherent (if approximate) window.
4362
+ return None
4363
+ bounds: "list[tuple[dt.datetime, dt.datetime]]" = []
4364
+ for sw in subweeks:
4365
+ try:
4366
+ s = parse_iso_datetime(
4367
+ sw.start_ts, "projects week.start_ts",
4368
+ ).astimezone(dt.timezone.utc)
4369
+ e = parse_iso_datetime(
4370
+ sw.end_ts, "projects week.end_ts",
4371
+ ).astimezone(dt.timezone.utc)
4372
+ except (TypeError, ValueError):
4373
+ continue
4374
+ if e > s:
4375
+ bounds.append((s, e))
4376
+ if not bounds:
4377
+ return None
4378
+ return _ProjectsWeekGrid(bounds)
4379
+
4380
+
4155
4381
  def _projects_week_label(week_start: "dt.datetime") -> str:
4156
4382
  """Render a `wk Mon DD` label for the trend chart x-axis.
4157
4383
 
@@ -4200,9 +4426,25 @@ def _projects_iter_session_entries(conn: "sqlite3.Connection",
4200
4426
  downstream. An ``EXPLAIN QUERY PLAN`` regression asserts the mutation_seq
4201
4427
  index seek (``tests/test_projects_envelope.py``).
4202
4428
  """
4203
- since_iso = since.astimezone(dt.timezone.utc).strftime(
4204
- "%Y-%m-%dT%H:%M:%SZ"
4205
- )
4429
+ # The SQL bounds are an outward-widened CANDIDATE filter; the real
4430
+ # membership test is the Python one every caller applies
4431
+ # (`_fold_projects_entry`'s interval gate, `_week_for`, or
4432
+ # `_fetch_delta_rows`' own pre-filter). #620 S1: the lower bound is
4433
+ # widened by one second because ingestion stores
4434
+ # `timestamp.astimezone(utc).isoformat()`, which keeps a `+00:00`
4435
+ # offset, while this predicate spells its bound `Z` — and SQLite
4436
+ # compares the column lexically, where `+` (0x2B) sorts BELOW `Z`
4437
+ # (0x5A). An entry stored at exactly `since`, or in the first second
4438
+ # after it, therefore sorts below the bound and is dropped before any
4439
+ # Python gate runs. That was unreachable while a week always started at
4440
+ # Monday 00:00 UTC, which carries no entries; a real subscription week
4441
+ # starts at the reset instant, and an entry lands on it routinely. The
4442
+ # widening admits at most one extra second of candidates, which the
4443
+ # Python gate then rejects, so no caller's result changes except the one
4444
+ # that was silently losing the boundary entry.
4445
+ since_iso = (
4446
+ since.astimezone(dt.timezone.utc) - dt.timedelta(seconds=1)
4447
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
4206
4448
  until_iso = until.astimezone(dt.timezone.utc).strftime(
4207
4449
  "%Y-%m-%dT%H:%M:%SZ"
4208
4450
  )
@@ -4393,6 +4635,25 @@ def _shared_range_row_to_usage_entry(row):
4393
4635
  )
4394
4636
 
4395
4637
 
4638
+ def _shared_range_row_to_priced_usage_entry(row):
4639
+ """Prepare one shared row's effective cost once for both range folds."""
4640
+ entry = _shared_range_row_to_usage_entry(row)
4641
+ if entry.model == "<synthetic>":
4642
+ return entry
4643
+ return _cctally().UsageEntry(
4644
+ timestamp=entry.timestamp,
4645
+ model=entry.model,
4646
+ usage=entry.usage,
4647
+ cost_usd=_calculate_entry_cost(
4648
+ entry.model,
4649
+ entry.usage,
4650
+ mode="auto",
4651
+ cost_usd=entry.cost_usd,
4652
+ ),
4653
+ source_path=entry.source_path,
4654
+ )
4655
+
4656
+
4396
4657
  def _fold_prepared_daily_entries(
4397
4658
  accumulators, entries, *, display_tz=None, mode: str = "auto",
4398
4659
  ):
@@ -4512,7 +4773,9 @@ def _fold_projects_entry(
4512
4773
  *,
4513
4774
  resolver_cache: dict,
4514
4775
  week_start: "dt.datetime | None",
4776
+ week_end: "dt.datetime | None" = None,
4515
4777
  prepared_daily_entries: "list | None" = None,
4778
+ priced_entry=None,
4516
4779
  ) -> "float | None":
4517
4780
  """Fold ONE ``_projects_iter_session_entries`` row onto ``mut`` (the shared
4518
4781
  per-row body, #271 §20 Codex-P1a).
@@ -4524,6 +4787,15 @@ def _fold_projects_entry(
4524
4787
  row is filtered out (``<synthetic>`` model, or its Monday-anchored week ≠
4525
4788
  ``week_start``) — the caller then skips ``week_total`` / ``tail`` advance.
4526
4789
 
4790
+ The membership gate is the half-open interval ``[week_start, week_end)``
4791
+ (#620 S1 D1). It was a ``_projects_week_start_monday_utc(ts) ==
4792
+ week_start`` equality, which is the SAME predicate whenever ``week_start``
4793
+ is a Monday 00:00 UTC and ``week_end`` is ``week_start + 7d`` — so every
4794
+ Monday-anchored caller is byte-unchanged — but the interval form also
4795
+ admits a real subscription week that neither starts on a Monday nor runs
4796
+ a full seven days. ``week_end`` defaults to ``week_start + 7d`` for a
4797
+ caller that has not been threaded through yet.
4798
+
4527
4799
  ``mut[bp]`` is the running mutable dict ``{"cost_usd": float,
4528
4800
  "sessions": set, "first_seen": dt, "last_seen": dt, "first_order": ts_iso,
4529
4801
  "first_id": int, "first_key": ProjectKey}``. The first row seen for a
@@ -4546,32 +4818,43 @@ def _fold_projects_entry(
4546
4818
  if model == "<synthetic>":
4547
4819
  return None
4548
4820
  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
- )
4821
+ if week_start is not None:
4822
+ w_end = (
4823
+ week_end if week_end is not None
4824
+ else week_start + dt.timedelta(days=7)
4825
+ )
4826
+ if not (week_start <= ts < w_end):
4827
+ return None
4828
+ if priced_entry is None:
4829
+ usage = claude_usage_dict( # #195 chokepoint
4830
+ input_tokens=input_tok,
4831
+ output_tokens=output_tok,
4832
+ cache_creation_tokens=cache_create,
4833
+ cache_read_tokens=cache_read,
4834
+ cache_1h_tokens=cache_1h,
4835
+ speed=speed,
4836
+ )
4837
+ entry_cost = _calculate_entry_cost(
4838
+ model,
4839
+ usage,
4840
+ mode="auto",
4841
+ cost_usd=cost_raw,
4842
+ )
4843
+ else:
4844
+ usage = priced_entry.usage
4845
+ entry_cost = priced_entry.cost_usd
4565
4846
  if prepared_daily_entries is not None:
4566
4847
  # #567: preserve the canonical daily entry and aggregator while
4567
4848
  # 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
- ))
4849
+ prepared_daily_entries.append(
4850
+ priced_entry if priced_entry is not None else c.UsageEntry(
4851
+ timestamp=dt.datetime.fromisoformat(ts_iso),
4852
+ model=model,
4853
+ usage=usage,
4854
+ cost_usd=entry_cost,
4855
+ source_path=source_path,
4856
+ )
4857
+ )
4575
4858
  pkey = c._resolve_project_key(project_path, "git-root", resolver_cache)
4576
4859
  bp = pkey.bucket_path
4577
4860
  a = mut.get(bp)
@@ -4600,6 +4883,7 @@ def _fold_projects_entry(
4600
4883
 
4601
4884
  def fold_projects_over_range(
4602
4885
  rows, *, resolver_cache=None, prepared_daily_entries=None,
4886
+ priced_entries=None,
4603
4887
  ) -> "dict[str, dict]":
4604
4888
  """Fold an ALREADY-MATERIALISED candidate stream into per-bucket totals.
4605
4889
 
@@ -4622,13 +4906,17 @@ def fold_projects_over_range(
4622
4906
  """
4623
4907
  mut: "dict[str, dict]" = {}
4624
4908
  cache = {} if resolver_cache is None else resolver_cache
4625
- for row in rows:
4909
+ if priced_entries is not None and len(priced_entries) != len(rows):
4910
+ raise ValueError("priced shared-range entries do not match source rows")
4911
+ for index, row in enumerate(rows):
4626
4912
  _fold_projects_entry(
4627
4913
  mut,
4628
4914
  row,
4629
4915
  resolver_cache=cache,
4630
4916
  week_start=None,
4631
4917
  prepared_daily_entries=prepared_daily_entries,
4918
+ priced_entry=(
4919
+ priced_entries[index] if priced_entries is not None else None),
4632
4920
  )
4633
4921
  return mut
4634
4922
 
@@ -4900,25 +5188,35 @@ def _shared_range_cache_payload(
4900
5188
  }
4901
5189
 
4902
5190
 
4903
- def build_cached_claude_range_aggregates(
5191
+ @dataclass(frozen=True)
5192
+ class ClaudeRangeAggregateCapture:
5193
+ """Cache-owned inputs for one post-transaction Claude range fold."""
5194
+
5195
+ base: tuple
5196
+ max_entry_id: int
5197
+ entry_mutation_seq: int
5198
+ shared_end_exclusive: object
5199
+ prior: object
5200
+ delta_rows: tuple
5201
+ full_rows: tuple | None
5202
+
5203
+
5204
+ def capture_cached_claude_range_aggregates(
4904
5205
  conn,
4905
5206
  *,
4906
5207
  shared_start,
4907
5208
  shared_end_exclusive,
4908
- now_utc,
4909
5209
  display_tz,
4910
- legacy_labels,
4911
5210
  max_entry_id: "int | None" = None,
4912
5211
  entry_mutation_seq: "int | None" = None,
4913
5212
  generation: int = 0,
4914
5213
  ):
4915
- """Build or increment the one-snapshot Claude range folds (#567).
5214
+ """Capture only cache-backed inputs for the #567 append accumulator.
4916
5215
 
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.
5216
+ This half may run under `_tui_build_source_bundle`'s pinned transaction. It
5217
+ performs no deepcopy, pricing, project fold, daily fold, payload assembly,
5218
+ or memo mutation. The returned rows are ordinary immutable SQLite tuples,
5219
+ so the caller can end the read transaction before consuming them.
4922
5220
  """
4923
5221
  if max_entry_id is None or entry_mutation_seq is None:
4924
5222
  observed_id, observed_seq = _shared_range_entry_signature(conn)
@@ -4935,7 +5233,8 @@ def build_cached_claude_range_aggregates(
4935
5233
  generation=generation,
4936
5234
  )
4937
5235
  prior = _CLAUDE_RANGE_AGGREGATE_MEMO.get("state")
4938
- state = None
5236
+ delta_rows: tuple = ()
5237
+ full_rows: tuple | None = None
4939
5238
  if isinstance(prior, dict) and prior.get("base") == base:
4940
5239
  monotone = (
4941
5240
  max_entry_id >= prior["max_entry_id"]
@@ -4951,9 +5250,6 @@ def build_cached_claude_range_aggregates(
4951
5250
  )
4952
5251
  )
4953
5252
  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
5253
  delta_by_id = {}
4958
5254
  for row in _shared_range_entries_after_id(
4959
5255
  conn, prior["max_entry_id"],
@@ -4971,85 +5267,198 @@ def build_cached_claude_range_aggregates(
4971
5267
  ):
4972
5268
  if row[0] <= prior["max_entry_id"]:
4973
5269
  delta_by_id[row[0]] = row
4974
- delta_rows = sorted(
5270
+ delta_rows = tuple(sorted(
4975
5271
  delta_by_id.values(),
4976
5272
  key=lambda row: (row[1], row[0]),
4977
- )
5273
+ ))
4978
5274
  prior_tail = prior["tail"]
4979
- if prior_tail is None or all(
5275
+ if not (prior_tail is None or all(
4980
5276
  (row[1], row[0]) > prior_tail
4981
5277
  for row in delta_rows
4982
5278
  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(
5279
+ )):
5280
+ # An out-of-order append cannot be folded onto the accumulator.
5281
+ # Capture the cold carrier while the same snapshot is pinned;
5282
+ # discovering this after rollback would require a second read
5283
+ # generation.
5284
+ full_rows = tuple(iter_shared_range_entries(
5285
+ conn, start=shared_start,
5286
+ end_exclusive=shared_end_exclusive,
5287
+ ))
5288
+ delta_rows = ()
5289
+ else:
5290
+ full_rows = tuple(iter_shared_range_entries(
5291
+ conn, start=shared_start,
5292
+ end_exclusive=shared_end_exclusive,
5293
+ ))
5294
+ else:
5295
+ full_rows = tuple(iter_shared_range_entries(
5017
5296
  conn, start=shared_start, end_exclusive=shared_end_exclusive,
5018
5297
  ))
5019
- prepared = []
5020
- resolver_cache = {}
5021
- project_mut = fold_projects_over_range(
5022
- rows,
5023
- resolver_cache=resolver_cache,
5024
- prepared_daily_entries=prepared,
5298
+ return ClaudeRangeAggregateCapture(
5299
+ base=base,
5300
+ max_entry_id=max_entry_id,
5301
+ entry_mutation_seq=entry_mutation_seq,
5302
+ shared_end_exclusive=shared_end_exclusive,
5303
+ prior=prior,
5304
+ delta_rows=delta_rows,
5305
+ full_rows=full_rows,
5306
+ )
5307
+
5308
+
5309
+ def build_cached_claude_range_aggregates_from_capture(
5310
+ capture: ClaudeRangeAggregateCapture,
5311
+ *,
5312
+ now_utc,
5313
+ display_tz,
5314
+ legacy_labels,
5315
+ tolerate_leg_failures: bool = False,
5316
+ ):
5317
+ """Fold and publish one captured Claude range accumulator outside the pin.
5318
+
5319
+ ``tolerate_leg_failures`` is the source-bundle path's typed degradation
5320
+ seam. Project identity resolution and the daily calendar are independent
5321
+ folds over the same captured rows, so a project-only fault must not discard
5322
+ a valid daily result. The one-shot compatibility wrapper keeps the legacy
5323
+ raise-on-any-fault contract by leaving it false.
5324
+ """
5325
+ prior = capture.prior
5326
+ incremental = capture.full_rows is None and isinstance(prior, dict)
5327
+ rows = capture.delta_rows if incremental else (capture.full_rows or ())
5328
+ payload: dict[str, object] = {}
5329
+ outcomes = {
5330
+ "projects": {"state": "ok"},
5331
+ "daily": {"state": "ok"},
5332
+ }
5333
+ failures: dict[str, Exception] = {}
5334
+ project_mut = None
5335
+ resolver_cache = None
5336
+ daily_accumulators = None
5337
+
5338
+ try:
5339
+ prepared = tuple(
5340
+ _shared_range_row_to_priced_usage_entry(row) for row in rows)
5341
+ except Exception as exc:
5342
+ prepared = ()
5343
+ failures["projects"] = exc
5344
+ failures["daily"] = exc
5345
+ outcomes["projects"] = {
5346
+ "state": "failed", "code": "claude_fold_failed"}
5347
+ outcomes["daily"] = {
5348
+ "state": "failed", "code": "claude_fold_failed"}
5349
+
5350
+ try:
5351
+ if "projects" in failures:
5352
+ raise failures["projects"]
5353
+ if incremental:
5354
+ project_mut = copy.deepcopy(prior["project_mut"])
5355
+ resolver_cache = dict(prior["resolver_cache"])
5356
+ for row, priced_entry in zip(rows, prepared):
5357
+ _fold_projects_entry(
5358
+ project_mut,
5359
+ row,
5360
+ resolver_cache=resolver_cache,
5361
+ week_start=None,
5362
+ priced_entry=priced_entry,
5363
+ )
5364
+ else:
5365
+ resolver_cache = {}
5366
+ project_mut = fold_projects_over_range(
5367
+ rows,
5368
+ resolver_cache=resolver_cache,
5369
+ priced_entries=prepared,
5370
+ )
5371
+ payload["projects"] = _project_aggregate_rows_from_folded(
5372
+ project_mut, legacy_labels)
5373
+ except Exception as exc:
5374
+ failures["projects"] = exc
5375
+ outcomes["projects"] = {
5376
+ "state": "failed", "code": "claude_fold_failed"}
5377
+
5378
+ try:
5379
+ if "daily" in failures:
5380
+ raise failures["daily"]
5381
+ daily_accumulators = (
5382
+ copy.deepcopy(prior["daily_accumulators"])
5383
+ if incremental else {}
5025
5384
  )
5026
- daily_accumulators = {}
5027
5385
  _fold_prepared_daily_entries(
5028
- daily_accumulators, prepared, display_tz=display_tz,
5029
- )
5386
+ daily_accumulators, prepared, display_tz=display_tz)
5387
+ daily_buckets = _finalize_daily_accumulators(daily_accumulators)
5388
+ daily_rows = _build_daily_aggregate_rows_from_buckets(
5389
+ daily_buckets, now_utc=now_utc, display_tz=display_tz)
5390
+ c = _cctally()
5391
+ payload["daily"] = [
5392
+ c.daily_panel_row_to_wire(row) for row in daily_rows]
5393
+ except Exception as exc:
5394
+ failures["daily"] = exc
5395
+ outcomes["daily"] = {
5396
+ "state": "failed", "code": "claude_fold_failed"}
5397
+
5398
+ if incremental:
5399
+ tail = prior["tail"]
5400
+ real_delta = [
5401
+ row for row in capture.delta_rows if row[2] != "<synthetic>"
5402
+ ]
5403
+ if real_delta:
5404
+ last = real_delta[-1]
5405
+ tail = (last[1], last[0])
5406
+ else:
5030
5407
  real_rows = [row for row in rows if row[2] != "<synthetic>"]
5031
5408
  tail = None
5032
5409
  if real_rows:
5033
5410
  last = real_rows[-1]
5034
5411
  tail = (last[1], last[0])
5412
+
5413
+ if not failures:
5035
5414
  state = {
5036
- "base": base,
5037
- "max_entry_id": max_entry_id,
5038
- "entry_mutation_seq": entry_mutation_seq,
5039
- "end_exclusive": shared_end_exclusive,
5415
+ "base": capture.base,
5416
+ "max_entry_id": capture.max_entry_id,
5417
+ "entry_mutation_seq": capture.entry_mutation_seq,
5418
+ "end_exclusive": capture.shared_end_exclusive,
5040
5419
  "tail": tail,
5041
5420
  "project_mut": project_mut,
5042
5421
  "daily_accumulators": daily_accumulators,
5043
5422
  "resolver_cache": resolver_cache,
5044
5423
  }
5045
- payload = _shared_range_cache_payload(
5046
- state,
5047
- legacy_labels=legacy_labels,
5424
+ _cctally()._load_sibling("_lib_snapshot_cache")._assert_owner()
5425
+ _CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
5426
+
5427
+ if failures and not tolerate_leg_failures:
5428
+ raise next(iter(failures.values()))
5429
+ if tolerate_leg_failures:
5430
+ return payload, outcomes
5431
+ return payload
5432
+
5433
+
5434
+ def build_cached_claude_range_aggregates(
5435
+ conn,
5436
+ *,
5437
+ shared_start,
5438
+ shared_end_exclusive,
5439
+ now_utc,
5440
+ display_tz,
5441
+ legacy_labels,
5442
+ max_entry_id: "int | None" = None,
5443
+ entry_mutation_seq: "int | None" = None,
5444
+ generation: int = 0,
5445
+ ):
5446
+ """One-shot compatibility wrapper for non-pinned focused callers."""
5447
+ capture = capture_cached_claude_range_aggregates(
5448
+ conn,
5449
+ shared_start=shared_start,
5450
+ shared_end_exclusive=shared_end_exclusive,
5451
+ display_tz=display_tz,
5452
+ max_entry_id=max_entry_id,
5453
+ entry_mutation_seq=entry_mutation_seq,
5454
+ generation=generation,
5455
+ )
5456
+ return build_cached_claude_range_aggregates_from_capture(
5457
+ capture,
5048
5458
  now_utc=now_utc,
5049
5459
  display_tz=display_tz,
5460
+ legacy_labels=legacy_labels,
5050
5461
  )
5051
- _CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
5052
- return payload
5053
5462
 
5054
5463
 
5055
5464
  def _aggregate_projects_week_raw(
@@ -5080,7 +5489,8 @@ def _aggregate_projects_week_raw(
5080
5489
  conn, since=week_start, until=week_end,
5081
5490
  ):
5082
5491
  entry_cost = _fold_projects_entry(
5083
- mut, row, resolver_cache=resolver_cache, week_start=week_start,
5492
+ mut, row, resolver_cache=resolver_cache,
5493
+ week_start=week_start, week_end=week_end,
5084
5494
  )
5085
5495
  if entry_cost is None:
5086
5496
  continue
@@ -5143,7 +5553,7 @@ def _aggregate_projects_week(
5143
5553
  def _assemble_projects_via_cache(
5144
5554
  conn: "sqlite3.Connection",
5145
5555
  *,
5146
- weeks_full: "list[dt.datetime]",
5556
+ week_bounds: "list[tuple[dt.datetime, dt.datetime]]",
5147
5557
  cw_start: "dt.datetime",
5148
5558
  cw_end: "dt.datetime",
5149
5559
  cur_max_id: int,
@@ -5218,33 +5628,47 @@ def _assemble_projects_via_cache(
5218
5628
  if r[2] == "<synthetic>": # r[2] = model
5219
5629
  continue
5220
5630
  ts = parse_iso_datetime(r[1], "session_entries.timestamp_utc")
5221
- if _projects_week_start_monday_utc(ts) != cw_start:
5631
+ if not (cw_start <= ts < cw_end):
5222
5632
  continue
5223
5633
  out.append(r)
5224
5634
  out.sort(key=lambda r: (r[1], r[0])) # (ts_iso, id)
5225
5635
  return out
5226
5636
 
5227
- for w in weeks_full:
5637
+ # The cache identity below is `(start, end)`, while the spec named
5638
+ # "account, exact start, exact end". The account axis is omitted
5639
+ # deliberately, not by oversight: this panel always folds merged
5640
+ # (`_projects_week_grid` is called here with `account_key=None`), so
5641
+ # every entry in this cache was produced by the one merged read and two
5642
+ # scopes cannot collide in it. Adding a constant third component would
5643
+ # be a key that never varies. If the panel ever gains an account scope,
5644
+ # the axis has to be added at the same time — an account-scoped fold
5645
+ # served from a merged slot is a wrong answer, not a stale one.
5646
+ for w, w_end in week_bounds:
5228
5647
  if w == cw_start:
5229
5648
  week_buckets, week_total = sc.accumulate_projects_current_week(
5230
- week_key=sc.projects_env_week_key(cw_start),
5649
+ # #620 S1: the accumulator's identity is the INTERVAL. An
5650
+ # early reset that moves the current week's bounds must
5651
+ # cold-refold the slot rather than keep appending to a
5652
+ # running aggregate folded over the old window.
5653
+ week_key=sc.projects_env_week_key(cw_start, cw_end),
5231
5654
  cur_max_id=cur_max_id,
5232
5655
  cur_max_seq=cur_max_seq,
5233
5656
  fetch_all_raw=_fetch_all_raw,
5234
5657
  fetch_delta_rows=_fetch_delta_rows,
5235
5658
  finalize=_finalize_projects_mut,
5236
5659
  fold=lambda mut, row: _fold_projects_entry(
5237
- mut, row, resolver_cache=resolver_cache, week_start=cw_start,
5660
+ mut, row, resolver_cache=resolver_cache,
5661
+ week_start=cw_start, week_end=cw_end,
5238
5662
  ),
5239
5663
  )
5240
5664
  else:
5241
- week_iso = sc.projects_env_week_key(w)
5665
+ week_iso = sc.projects_env_week_key(w, w_end)
5242
5666
  hit = sc.projects_env_week_get(week_iso)
5243
5667
  if hit is not None:
5244
5668
  week_buckets, week_total = hit
5245
5669
  else:
5246
5670
  week_buckets, week_total = _aggregate_projects_week(
5247
- conn, week_start=w, week_end=w + dt.timedelta(days=7),
5671
+ conn, week_start=w, week_end=w_end,
5248
5672
  resolver_cache=resolver_cache,
5249
5673
  )
5250
5674
  sc.projects_env_week_put(week_iso, week_buckets, week_total)
@@ -5268,14 +5692,21 @@ def _build_projects_envelope(
5268
5692
  shape from spec §5.2 (no per-model breakdowns, no first/last seen
5269
5693
  per session, no per-row $/1%; just cost / attributed_pct / sessions).
5270
5694
 
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.
5695
+ Week boundaries are the real subscription intervals (#620 S1 D1):
5696
+ ``_projects_week_grid`` derives them from ``_compute_subscription_weeks``,
5697
+ the same kernel ``cmd_project`` buckets by, so the two surfaces attribute
5698
+ the same projects over the same weeks. A ``weekly_usage_snapshots`` row
5699
+ is matched onto an interval by its ``week_start_at`` anchor, falling back
5700
+ to ``week_start_date`` for a legacy row that carries no anchor; a row
5701
+ that matches no interval contributes nothing, leaving ``attributed_pct``
5702
+ None rather than attributing over a mismatched population. Only a store
5703
+ with no anchored snapshot at all falls back to
5704
+ ``_projects_week_start_monday_utc``, which is what that function was
5705
+ written for.
5706
+
5707
+ ``current_week`` is passed through opaquely — if non-None and carrying a
5708
+ ``.week_start_at`` UTC datetime, that instant selects which interval is
5709
+ the current week. None (the default) uses ``now_utc``.
5279
5710
 
5280
5711
  Determinism: same conn + same ``now_utc`` ⇒ byte-identical JSON
5281
5712
  (R-PROJ5 invariant). Per-tick memoized on
@@ -5330,33 +5761,51 @@ def _build_projects_envelope(
5330
5761
  return cached
5331
5762
 
5332
5763
  # ---- 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/`` +
5764
+ # #620 S1 D1. The panel buckets cost into the REAL subscription
5765
+ # intervals — the ones `_compute_subscription_weeks` derives from the
5766
+ # retained reset anchors, and the ones `cmd_project` already buckets by.
5767
+ # `_projects_week_start_monday_utc` remains what its own docstring says
5768
+ # it is: the fallback for when no anchor is available. It used to be
5769
+ # applied to the anchor itself, which discarded the very thing it was
5770
+ # written to defer to, so the cost window and the quota window described
5771
+ # different intervals for every account whose reset is not exactly
5772
+ # Monday midnight UTC — in practice almost all of them.
5773
+ #
5774
+ # ``TuiCurrentWeek.week_start_at`` after ``_apply_midweek_reset_override``
5775
+ # is the in-week reset instant rather than the week's start. It is used
5776
+ # here only to locate the containing interval, never as a bucket key, so
5777
+ # a shifted value resolves to the same week the entry walk uses and no
5778
+ # activity is stranded. Regression:
5779
+ # ``tests/fixtures/dashboard/reset-week/`` +
5344
5780
  # ``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)
5781
+ anchor_instant = cw_key if cw_key is not None else now_utc
5782
+ grid = _projects_week_grid(
5783
+ conn, anchor_utc=anchor_instant, weeks_back=weeks_back,
5784
+ )
5785
+ cw_start = grid.week_for(anchor_instant) if grid is not None else None
5786
+ if cw_start is None:
5787
+ # No anchor covers `now` — the genuine fallback path, byte-identical
5788
+ # to the pre-#620 behaviour for a store with no anchored snapshots.
5789
+ grid = None
5790
+ cw_start = _projects_week_start_monday_utc(anchor_instant)
5791
+ cw_end = cw_start + dt.timedelta(days=7)
5792
+ week_bounds = [
5793
+ (
5794
+ cw_start - dt.timedelta(days=7 * (weeks_back - 1 - i)),
5795
+ cw_start - dt.timedelta(days=7 * (weeks_back - 2 - i)),
5796
+ )
5797
+ for i in range(weeks_back)
5798
+ ]
5347
5799
  else:
5348
- cw_start = _projects_week_start_monday_utc(now_utc)
5800
+ cw_end = grid.end_for(cw_start)
5801
+ week_bounds = grid.window_ending_at(cw_start, weeks_back)
5349
5802
 
5350
- # Build a list of canonical Monday-anchored week starts ending with
5351
- # cw_start, oldest → newest, of length ``weeks_back``. Clamping to
5803
+ # Week starts, oldest → newest, of length ``weeks_back``. Clamping to
5352
5804
  # actual history happens after the entry walk reveals what weeks
5353
5805
  # 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]
5806
+ weeks_full = [b[0] for b in week_bounds]
5807
+ end_by_week = {s: e for s, e in week_bounds}
5808
+ since_dt = week_bounds[0][0]
5360
5809
  until_dt = cw_end # exclusive end; SQL is `>= since AND <= until`
5361
5810
 
5362
5811
  # ---- Bucket entries per (ProjectKey, week_start) --------------------
@@ -5371,7 +5820,7 @@ def _build_projects_envelope(
5371
5820
  # HTTP-drill): the original single full-window walk, byte-unchanged.
5372
5821
  if use_projects_env_cache:
5373
5822
  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,
5823
+ conn, week_bounds=week_bounds, cw_start=cw_start, cw_end=cw_end,
5375
5824
  cur_max_id=max_id, cur_max_seq=entry_mutation_seq,
5376
5825
  )
5377
5826
  else:
@@ -5387,8 +5836,13 @@ def _build_projects_envelope(
5387
5836
  key_by_bucket = {}
5388
5837
 
5389
5838
  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]:
5839
+ if grid is not None:
5840
+ wstart = grid.week_for(ts)
5841
+ if wstart is None:
5842
+ return None
5843
+ else:
5844
+ wstart = _projects_week_start_monday_utc(ts)
5845
+ if wstart not in end_by_week:
5392
5846
  return None
5393
5847
  return wstart
5394
5848
 
@@ -5484,7 +5938,7 @@ def _build_projects_envelope(
5484
5938
  weekly_pct_by_week: dict[dt.datetime, float] = {}
5485
5939
  try:
5486
5940
  cur = conn.execute(
5487
- "SELECT week_start_date, weekly_percent "
5941
+ "SELECT week_start_date, week_start_at, weekly_percent "
5488
5942
  "FROM weekly_usage_snapshots "
5489
5943
  "ORDER BY captured_at_utc ASC, id ASC"
5490
5944
  )
@@ -5493,19 +5947,40 @@ def _build_projects_envelope(
5493
5947
  # No weekly_usage_snapshots table — leaves attributed_pct = None
5494
5948
  # throughout (acceptable per spec §2.7).
5495
5949
  rows = []
5496
- for week_date_str, weekly_pct in rows:
5950
+ for week_date_str, week_start_at, weekly_pct in rows:
5497
5951
  try:
5498
5952
  wd = dt.date.fromisoformat(week_date_str)
5499
5953
  except (TypeError, ValueError):
5500
5954
  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)
5955
+ wstart: "dt.datetime | None" = None
5956
+ if grid is not None:
5957
+ # #620 S1 D1. Resolve the row onto the SAME interval the entry
5958
+ # walk buckets into, so the numerator, the denominator and this
5959
+ # percentage are all taken from one half-open window before the
5960
+ # multiplication below.
5961
+ if week_start_at:
5962
+ try:
5963
+ anchor = parse_iso_datetime(
5964
+ week_start_at, "weekly_usage_snapshots.week_start_at",
5965
+ ).astimezone(dt.timezone.utc)
5966
+ except (TypeError, ValueError):
5967
+ anchor = None
5968
+ if anchor is not None:
5969
+ wstart = grid.week_for(anchor)
5970
+ if wstart is None:
5971
+ # A legacy row carrying only the date-only boundary. One
5972
+ # shared interval still serves both the cost and the
5973
+ # percentage; taking them from different intervals is never
5974
+ # acceptable, so an unresolvable row contributes nothing and
5975
+ # `attributed_pct` stays None (#620 S1 A3).
5976
+ wstart = grid.start_for_date(wd)
5977
+ if wstart is None:
5978
+ continue
5979
+ else:
5980
+ # No anchor anywhere in the store: the genuine Monday fallback.
5981
+ wstart = _projects_week_start_monday_utc(dt.datetime.combine(
5982
+ wd, dt.time(0, 0, 0), tzinfo=dt.timezone.utc,
5983
+ ))
5509
5984
  if weekly_pct is not None:
5510
5985
  weekly_pct_by_week[wstart] = float(weekly_pct)
5511
5986
 
@@ -5532,12 +6007,11 @@ def _build_projects_envelope(
5532
6007
  if weeks_with_activity:
5533
6008
  # Window = inclusive [oldest_active_week, cw_start]. Always emits
5534
6009
  # cw_start (panel + trend share the same current_week column).
6010
+ # Keyed by the SAME rule as the current week (#620 S1 D1) — the
6011
+ # subscription intervals themselves, not a seven-day walk, because
6012
+ # a mixed keying inside one panel is the defect restated.
5535
6013
  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)
6014
+ trend_weeks = [w for w in weeks_full if oldest <= w <= cw_start]
5541
6015
  else:
5542
6016
  trend_weeks = [cw_start]
5543
6017
 
@@ -5729,13 +6203,74 @@ def _project_detail_for_window(
5729
6203
  if bucket_path is None:
5730
6204
  return None
5731
6205
 
5732
- # ---- Window bounds (Monday-anchored UTC fallback, like the builder) -
6206
+ # ---- Window bounds, from the envelope's own current-week anchor ----
6207
+ # That anchor is the account's real subscription week start; the
6208
+ # Monday-midnight snap is only the no-anchor fallback (#620).
5733
6209
  cw_start = parse_iso_datetime(
5734
6210
  env["current_week"]["week_start_at"],
5735
6211
  "projects.current_week.week_start_at",
6212
+ ).astimezone(dt.timezone.utc)
6213
+
6214
+ # The drill resolves the SAME interval its panel resolved, by rebuilding
6215
+ # the panel's grid rather than stepping back in seven-day multiples.
6216
+ # `_ProjectsWeekGrid` exists because a drifted reset day produces a
6217
+ # genuinely short week: on `non-monday-anchor` at `weeks_back=4` the grid
6218
+ # starts the window at 2026-03-27T09:00Z while a seven-day walk yields
6219
+ # 2026-03-26T09:00Z, and an early reset that shortens the current week
6220
+ # would likewise leave a `cw_start + 7d` end counting cost past the
6221
+ # week's real end. `window_start_at` / `window_end_at` publish these
6222
+ # bounds to the client as authoritative, so a divergence here renders a
6223
+ # window the panel never computed.
6224
+ #
6225
+ # The grid is rebuilt from the PANEL'S OWN anchor, not from `cw_start`.
6226
+ # `_projects_week_grid` derives its provisional range from an ISO-Monday
6227
+ # snap of whatever anchor it is handed, and `cw_start` is the interval
6228
+ # START while the panel anchors on `current_week.week_start_at` — which
6229
+ # after `_apply_midweek_reset_override` is the in-week reset instant and
6230
+ # can sit up to a week later. The two therefore snap to different Mondays
6231
+ # and `_compute_subscription_weeks` can pick a different extrapolation
6232
+ # anchor for each, so window equality would rest on a coincidence rather
6233
+ # than on the two surfaces asking the same question. `cw_start` remains
6234
+ # the anchor `window_ending_at` walks back from, because that walk needs
6235
+ # an interval start.
6236
+ panel_anchor = getattr(current_week, "week_start_at", None)
6237
+ if not isinstance(panel_anchor, dt.datetime):
6238
+ panel_anchor = now_utc
6239
+ detail_grid = _projects_week_grid(
6240
+ conn, anchor_utc=panel_anchor, weeks_back=weeks_back,
6241
+ )
6242
+ detail_bounds = (
6243
+ detail_grid.window_ending_at(cw_start, weeks_back)
6244
+ if detail_grid is not None else []
5736
6245
  )
5737
- since_dt = cw_start - dt.timedelta(days=7 * (weeks_back - 1))
5738
- until_dt = cw_start + dt.timedelta(days=7)
6246
+ if detail_bounds:
6247
+ since_dt = detail_bounds[0][0]
6248
+ until_dt = detail_bounds[-1][1]
6249
+ else:
6250
+ # No anchor covers this start — the same no-anchor tail the panel
6251
+ # falls back to, where the seven-day assumption is the right one.
6252
+ since_dt = cw_start - dt.timedelta(days=7 * (weeks_back - 1))
6253
+ until_dt = cw_start + dt.timedelta(days=7)
6254
+ since_iso = since_dt.astimezone(dt.timezone.utc).strftime(
6255
+ "%Y-%m-%dT%H:%M:%SZ"
6256
+ )
6257
+ until_iso = until_dt.astimezone(dt.timezone.utc).strftime(
6258
+ "%Y-%m-%dT%H:%M:%SZ"
6259
+ )
6260
+ # SQL candidate bound, NOT the published one. Ingestion stores
6261
+ # `timestamp_utc` as `…+00:00` while these bounds are spelled `…Z`, and
6262
+ # SQLite compares that column lexically with `+` (0x2B) below `Z` (0x5A).
6263
+ # So the lower bound drops an entry sitting exactly on it and the upper
6264
+ # bound admits one sitting exactly on it — an asymmetry in both
6265
+ # directions. Widening the lower bound by a second makes SQL an outward
6266
+ # candidate filter at both ends; the half-open membership test is then
6267
+ # enforced on the PARSED datetime in the entry loop, which is the only
6268
+ # place it can be stated honestly.
6269
+ since_sql_iso = (
6270
+ since_dt.astimezone(dt.timezone.utc) - dt.timedelta(seconds=1)
6271
+ ).strftime("%Y-%m-%dT%H:%M:%SZ")
6272
+ until_dt_utc = until_dt.astimezone(dt.timezone.utc)
6273
+ since_dt_utc = since_dt.astimezone(dt.timezone.utc)
5739
6274
 
5740
6275
  # ---- Build bucket → source_paths map for SQL-side scoping ----------
5741
6276
  # Walk session_files (~8k rows) once instead of session_entries
@@ -5787,6 +6322,8 @@ def _project_detail_for_window(
5787
6322
  "key": project_key,
5788
6323
  "bucket_path": bucket_path,
5789
6324
  "window_weeks": weeks_back,
6325
+ "window_start_at": since_iso,
6326
+ "window_end_at": until_iso,
5790
6327
  "window_cost_usd": 0.0,
5791
6328
  "window_attributed_pct": None,
5792
6329
  "models": [],
@@ -5810,13 +6347,6 @@ def _project_detail_for_window(
5810
6347
  [(p,) for p in bucket_source_paths],
5811
6348
  )
5812
6349
 
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
6350
  # ---- Walk session_entries (project-scoped) once -------------------
5821
6351
  # INNER JOIN to _drill_paths drops every row whose source_path
5822
6352
  # doesn't belong to this bucket. The Python-side filter that
@@ -5832,7 +6362,7 @@ def _project_detail_for_window(
5832
6362
  "LEFT JOIN session_files sf ON sf.path = e.source_path "
5833
6363
  "WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
5834
6364
  "ORDER BY e.timestamp_utc ASC, e.id ASC",
5835
- (since_iso, until_iso),
6365
+ (since_sql_iso, until_iso),
5836
6366
  )
5837
6367
 
5838
6368
  # Per-model rollup: {model -> {cost_usd, sessions, in, out, cache_*}}
@@ -5855,6 +6385,14 @@ def _project_detail_for_window(
5855
6385
  # on _drill_paths already restricted the result set to entries
5856
6386
  # whose source_path belongs to this bucket.
5857
6387
  ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
6388
+ # The half-open membership test. The SQL bounds above are a widened
6389
+ # candidate filter that admits a second on each side, because the
6390
+ # column's stored offset spelling and the bound's spelling do not
6391
+ # compare the way the interval means; this is where the interval is
6392
+ # actually decided.
6393
+ ts_utc = ts.astimezone(dt.timezone.utc)
6394
+ if not (since_dt_utc <= ts_utc < until_dt_utc):
6395
+ continue
5858
6396
  entry_cost = _calculate_entry_cost(
5859
6397
  model,
5860
6398
  claude_usage_dict( # #195 chokepoint
@@ -5977,6 +6515,8 @@ def _project_detail_for_window(
5977
6515
  "key": project_key,
5978
6516
  "bucket_path": bucket_path,
5979
6517
  "window_weeks": weeks_back,
6518
+ "window_start_at": since_iso,
6519
+ "window_end_at": until_iso,
5980
6520
  "window_cost_usd": window_cost,
5981
6521
  "window_attributed_pct": win_pct,
5982
6522
  "models": models_out,
@@ -8098,10 +8638,17 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
8098
8638
  return
8099
8639
 
8100
8640
  if axis == "weekly":
8641
+ # Mirrors the CLI `alerts test --axis weekly` branch: the preview
8642
+ # carries the reset INSTANT a real crossing carries, so it renders
8643
+ # the instant form rather than the day-granularity fallback.
8644
+ preview_week_start = synthetic_preview_week_start()
8101
8645
  payload = _build_alert_payload_weekly(
8102
8646
  threshold=threshold,
8103
8647
  crossed_at_utc=now_utc_iso(),
8104
- week_start_date=dt.date.today().isoformat(),
8648
+ week_start_date=preview_week_start.date().isoformat(),
8649
+ week_start_at=preview_week_start.isoformat().replace(
8650
+ "+00:00", "Z"
8651
+ ),
8105
8652
  cumulative_cost_usd=1.23,
8106
8653
  dollars_per_percent=0.01,
8107
8654
  )