cctally 1.97.0 → 1.99.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.
@@ -262,6 +262,7 @@ from __future__ import annotations
262
262
 
263
263
  import argparse
264
264
  import bisect
265
+ import copy
265
266
  import contextlib
266
267
  import dataclasses
267
268
  import datetime as dt
@@ -383,7 +384,14 @@ from _lib_display_tz import (
383
384
  normalize_display_tz_value,
384
385
  _compute_display_block,
385
386
  )
386
- from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
387
+ from _lib_aggregators import (
388
+ _aggregate_daily,
389
+ _aggregate_monthly,
390
+ _aggregate_weekly,
391
+ _new_bucket_acc,
392
+ _fold_entry,
393
+ _finalize_bucket,
394
+ )
387
395
  from _lib_fmt import stable_sum
388
396
  from _lib_pricing import (_calculate_entry_cost, _chip_for_model,
389
397
  _short_model_name, claude_usage_dict)
@@ -1019,17 +1027,128 @@ def _claude_project_key_for_path(snapshot, project_path: object) -> str | None:
1019
1027
  return None
1020
1028
 
1021
1029
 
1030
+ def _claude_aggregate_project_rows(snapshot) -> "tuple":
1031
+ """The published bounded project rows, or an empty tuple.
1032
+
1033
+ They live on the frozen source bundle rather than on the legacy envelope,
1034
+ and a provider whose bounded fold failed publishes none.
1035
+ """
1036
+ bundle = getattr(snapshot, "source_bundle", None)
1037
+ try:
1038
+ data = bundle.sources["claude"].data
1039
+ except (AttributeError, KeyError, TypeError):
1040
+ return ()
1041
+ if not isinstance(data, Mapping):
1042
+ return ()
1043
+ projects = data.get("projects")
1044
+ aggregate = projects.get("aggregate") if isinstance(projects, Mapping) else None
1045
+ rows = aggregate.get("rows") if isinstance(aggregate, Mapping) else None
1046
+ return tuple(rows) if isinstance(rows, (list, tuple)) else ()
1047
+
1048
+
1049
+ # The window sizes `GET /api/source/<source>/project/<key>?weeks=N` accepts.
1050
+ PROJECT_WINDOW_WEEKS_CHOICES = (1, 4, 8, 12)
1051
+
1052
+
1053
+ def resolve_aggregate_project_window_weeks(
1054
+ requested: int, *, shared_start_at: object, current_week_start_at: object,
1055
+ ) -> int:
1056
+ """The drill window a row published by the All ranking must resolve with.
1057
+
1058
+ The two windows are anchored differently and one does not contain the
1059
+ other. The ranking is ``n`` calendar days ending today. The drill is
1060
+ ``weeks_back`` weeks anchored at the CURRENT MONDAY —
1061
+ ``[cw_start - 7 * (weeks_back - 1), cw_start + 7)`` — so at the default four
1062
+ weeks it reaches twenty-one days before that Monday, which is twenty-one to
1063
+ twenty-seven days before today. Against a thirty-day ranking that leaves up
1064
+ to nine days at the start of the ranking window ranked and undrillable: a
1065
+ row published at a real dollar figure opens a modal reporting
1066
+ ``window_cost_usd: 0.0`` and no sessions.
1067
+
1068
+ So the window is the SMALLEST accepted choice whose span reaches the
1069
+ resolved shared start, and never narrower than the caller asked for. When
1070
+ NO accepted choice reaches it — a shared start further back than twelve
1071
+ weeks before the current Monday — the widest choice is returned instead,
1072
+ which is the closest the accepted set can come rather than a span that
1073
+ reaches. It is not silent either way: ``window_weeks`` is in the payload
1074
+ and the detail states the resolved span it covered, so a window that falls
1075
+ short says so rather than implying it reached. When either bound is
1076
+ unresolvable the caller's value stands, because guessing a wider window
1077
+ over an unknown range would state a span nothing established.
1078
+
1079
+ Pure. ``_build_claude_source_detail`` supplies the two bounds.
1080
+ """
1081
+ if not isinstance(shared_start_at, str) or not isinstance(
1082
+ current_week_start_at, str,
1083
+ ):
1084
+ return requested
1085
+ try:
1086
+ shared_start = parse_iso_datetime(
1087
+ shared_start_at, "aggregates.range.start_at",
1088
+ )
1089
+ cw_start = parse_iso_datetime(
1090
+ current_week_start_at, "projects.current_week.week_start_at",
1091
+ )
1092
+ except ValueError:
1093
+ return requested
1094
+ covering = [
1095
+ weeks for weeks in PROJECT_WINDOW_WEEKS_CHOICES
1096
+ if cw_start - dt.timedelta(days=7 * (weeks - 1)) <= shared_start
1097
+ ]
1098
+ if not covering:
1099
+ return max(PROJECT_WINDOW_WEEKS_CHOICES[-1], requested)
1100
+ return max(min(covering), requested)
1101
+
1102
+
1103
+ def _published_aggregate_start_at(snapshot) -> object:
1104
+ """``sources.all.data.aggregates.range.start_at``, or ``None``."""
1105
+ bundle = getattr(snapshot, "source_bundle", None)
1106
+ try:
1107
+ data = bundle.sources["all"].data
1108
+ except (AttributeError, KeyError, TypeError):
1109
+ return None
1110
+ if not isinstance(data, Mapping):
1111
+ return None
1112
+ aggregates = data.get("aggregates")
1113
+ published = (
1114
+ aggregates.get("range") if isinstance(aggregates, Mapping) else None
1115
+ )
1116
+ return published.get("start_at") if isinstance(published, Mapping) else None
1117
+
1118
+
1119
+ def _project_window_weeks_for_key(snapshot, key: str, requested: int) -> int:
1120
+ """Widen the drill window for a key the All ranking published.
1121
+
1122
+ A key absent from the published aggregate rows was not ranked over the
1123
+ shared range, so nothing about that range describes it and the caller's
1124
+ window stands unchanged.
1125
+ """
1126
+ if not any(
1127
+ isinstance(row, Mapping) and row.get("key") == key
1128
+ for row in _claude_aggregate_project_rows(snapshot)
1129
+ ):
1130
+ return requested
1131
+ env = getattr(snapshot, "projects_envelope", None)
1132
+ current = env.get("current_week") if isinstance(env, Mapping) else None
1133
+ return resolve_aggregate_project_window_weeks(
1134
+ requested,
1135
+ shared_start_at=_published_aggregate_start_at(snapshot),
1136
+ current_week_start_at=(
1137
+ current.get("week_start_at") if isinstance(current, Mapping) else None
1138
+ ),
1139
+ )
1140
+
1141
+
1022
1142
  def _claude_project_key_for_source_key(snapshot, key: str) -> str | None:
1023
1143
  env = getattr(snapshot, "projects_envelope", None)
1024
- if not isinstance(env, Mapping):
1025
- return None
1026
1144
  candidates: list[object] = []
1027
- current = env.get("current_week")
1028
- if isinstance(current, Mapping):
1029
- candidates.extend(current.get("rows") or ())
1030
- trend = env.get("trend")
1031
- if isinstance(trend, Mapping):
1032
- candidates.extend(trend.get("projects") or ())
1145
+ if isinstance(env, Mapping):
1146
+ current = env.get("current_week")
1147
+ if isinstance(current, Mapping):
1148
+ candidates.extend(current.get("rows") or ())
1149
+ trend = env.get("trend")
1150
+ if isinstance(trend, Mapping):
1151
+ candidates.extend(trend.get("projects") or ())
1033
1152
  for row in candidates:
1034
1153
  if not isinstance(row, Mapping):
1035
1154
  continue
@@ -1038,6 +1157,16 @@ def _claude_project_key_for_source_key(snapshot, key: str) -> str | None:
1038
1157
  dashboard_resource_key("project", "claude", project_key) == key
1039
1158
  ):
1040
1159
  return project_key
1160
+ # #556 S2 remediation: there is deliberately NO fallback to the published
1161
+ # aggregate rows here. A retained bundle outlives the per-tick envelope
1162
+ # rebuild, so its labels can be stale, and `_project_detail_for_window`
1163
+ # resolves whatever this returns against the CURRENT envelope. Labels are
1164
+ # population-sensitive, so a stale label either misses there anyway — the
1165
+ # same 404, reached one step later — or names a DIFFERENT bucket and serves
1166
+ # another project's sessions under this key. A guarded fallback would be
1167
+ # dead code besides: the aggregate key is minted from its label by the same
1168
+ # rule the loop above inverts, so any label the current envelope still
1169
+ # carries has already matched.
1041
1170
  return None
1042
1171
 
1043
1172
 
@@ -1094,6 +1223,13 @@ def _build_claude_source_detail(
1094
1223
  project_key = _claude_project_key_for_source_key(snapshot, key)
1095
1224
  if project_key is None:
1096
1225
  raise SourceResourceNotFound()
1226
+ # #556 S2 remediation: a row the All ranking published states a figure
1227
+ # over the shared range, so its detail has to reach that range. The
1228
+ # default four-week drill is anchored at the current Monday and misses
1229
+ # up to nine days at the start of a thirty-day ranking.
1230
+ window_weeks = _project_window_weeks_for_key(
1231
+ snapshot, key, window_weeks,
1232
+ )
1097
1233
  conn = open_db()
1098
1234
  try:
1099
1235
  conn.execute("ATTACH DATABASE ? AS cache_db", (str(_cctally_core.CACHE_DB_PATH),))
@@ -2976,6 +3112,87 @@ def _group_a_daily_buckets(now_utc, *, n, display_tz):
2976
3112
  cache_conn.close()
2977
3113
 
2978
3114
 
3115
+ def materialise_daily_calendar(
3116
+ view_rows,
3117
+ *,
3118
+ now_utc: "dt.datetime",
3119
+ n: int = 30,
3120
+ display_tz: "ZoneInfo | None" = None,
3121
+ ) -> "list[DailyPanelRow]":
3122
+ """Materialize the contiguous ``n``-day calendar. Pure, no I/O.
3123
+
3124
+ ``view_rows`` is ``build_daily_view``'s gap-free newest-first row sequence,
3125
+ which carries the data-plane fields and leaves ``label`` and
3126
+ ``intensity_bucket`` at dataclass defaults (spec §4.4). This adapter
3127
+ overlays those rows onto the calendar window, adds a zero-cost row for
3128
+ every gap day so the heatmap shows a faded cell, and fills the two
3129
+ presentation-only fields.
3130
+
3131
+ #556 S2 §6.3a extracted this from ``_dashboard_build_daily_panel``, which
3132
+ opens its own connection through Group A or ``get_entries`` and cannot be
3133
+ called from the pinned source-bundle fold. Unlike that function, this one
3134
+ emits a COMPLETE shape for an empty provider rather than nothing: under All
3135
+ an empty provider is a zero leg, and a zero leg still has a shape, so the
3136
+ client never needs a second source for the calendar it renders.
3137
+ """
3138
+ rows_by_date = {r.date: r for r in (view_rows or ())}
3139
+ today_local = (
3140
+ now_utc.astimezone(display_tz) if display_tz is not None
3141
+ # internal fallback: host-local intentional
3142
+ else now_utc.astimezone()
3143
+ ).date()
3144
+
3145
+ rows: list[DailyPanelRow] = []
3146
+ for i in range(n):
3147
+ d = today_local - dt.timedelta(days=i)
3148
+ date_str = d.isoformat()
3149
+ existing = rows_by_date.get(date_str)
3150
+ if existing is not None:
3151
+ # Use the view-model row but fill the presentation-only
3152
+ # ``label`` (intensity_bucket is set by
3153
+ # ``_compute_intensity_buckets`` below).
3154
+ rows.append(dataclasses.replace(existing, label=date_str[5:]))
3155
+ else:
3156
+ # Zero-cost gap day: tokens default to 0, cache_hit_pct to None
3157
+ # (avoids /0 and signals 'no data' cleanly to the modal tile).
3158
+ rows.append(DailyPanelRow(
3159
+ date=date_str,
3160
+ label=date_str[5:],
3161
+ cost_usd=0.0,
3162
+ is_today=(d == today_local),
3163
+ intensity_bucket=0,
3164
+ models=[],
3165
+ ))
3166
+
3167
+ _compute_intensity_buckets(rows)
3168
+ return rows
3169
+
3170
+
3171
+ def daily_panel_row_to_wire(row: "DailyPanelRow") -> dict:
3172
+ """One wire shape for a daily row, whichever sibling published it.
3173
+
3174
+ ``snapshot_to_envelope``'s ``_daily_row_to_dict`` delegates here, so the
3175
+ All-only ``periods.daily_aggregate.rows`` sibling and the legacy
3176
+ ``periods.daily.rows`` cannot drift into two shapes the client would have
3177
+ to tell apart.
3178
+ """
3179
+ return {
3180
+ "date": row.date,
3181
+ "label": row.label,
3182
+ "cost_usd": row.cost_usd,
3183
+ "is_today": row.is_today,
3184
+ "intensity_bucket": row.intensity_bucket,
3185
+ "models": list(row.models),
3186
+ # ---- v2.3 additions ----
3187
+ "input_tokens": row.input_tokens,
3188
+ "output_tokens": row.output_tokens,
3189
+ "cache_creation_tokens": row.cache_creation_tokens,
3190
+ "cache_read_tokens": row.cache_read_tokens,
3191
+ "total_tokens": row.total_tokens,
3192
+ "cache_hit_pct": row.cache_hit_pct,
3193
+ }
3194
+
3195
+
2979
3196
  def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
2980
3197
  now_utc: "dt.datetime",
2981
3198
  *,
@@ -3050,42 +3267,13 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
3050
3267
  if not view.rows:
3051
3268
  return []
3052
3269
 
3053
- # Materialize the contiguous N-day window. ``view.rows`` is gap-free
3054
- # (newest-first) and carries the data-plane fields; the adapter
3055
- # overlays it onto the calendar window and fills the presentation-
3056
- # only ``label`` / ``intensity_bucket`` (which the builder left at
3057
- # dataclass defaults per spec §4.4).
3058
- rows_by_date = {r.date: r for r in view.rows}
3059
- today_local = (
3060
- now_utc.astimezone(display_tz) if display_tz is not None
3061
- # internal fallback: host-local intentional
3062
- else now_utc.astimezone()
3063
- ).date()
3064
-
3065
- rows: list[DailyPanelRow] = []
3066
- for i in range(n):
3067
- d = today_local - dt.timedelta(days=i)
3068
- date_str = d.isoformat()
3069
- existing = rows_by_date.get(date_str)
3070
- if existing is not None:
3071
- # Use the view-model row but fill the presentation-only
3072
- # ``label`` (intensity_bucket is set by
3073
- # ``_compute_intensity_buckets`` below).
3074
- rows.append(dataclasses.replace(existing, label=date_str[5:]))
3075
- else:
3076
- # Zero-cost gap day: tokens default to 0, cache_hit_pct to None
3077
- # (avoids /0 and signals 'no data' cleanly to the modal tile).
3078
- rows.append(DailyPanelRow(
3079
- date=date_str,
3080
- label=date_str[5:],
3081
- cost_usd=0.0,
3082
- is_today=(d == today_local),
3083
- intensity_bucket=0,
3084
- models=[],
3085
- ))
3086
-
3087
- _compute_intensity_buckets(rows)
3088
- return rows
3270
+ # Materialize the contiguous N-day window. #556 S2 §6.3a extracted the
3271
+ # block below into ``materialise_daily_calendar`` so the All-only Claude
3272
+ # daily leg can reach it without this function's I/O; the Claude tab's
3273
+ # behaviour is unchanged, including the empty-provider early return above.
3274
+ return materialise_daily_calendar(
3275
+ view.rows, now_utc=now_utc, n=n, display_tz=display_tz,
3276
+ )
3089
3277
 
3090
3278
 
3091
3279
  # --- Projects panel / modal (spec 2026-05-19-projects-panel-design.md) ------
@@ -3217,6 +3405,250 @@ def _projects_iter_session_entries(conn: "sqlite3.Connection",
3217
3405
  yield row
3218
3406
 
3219
3407
 
3408
+ # === #556 S2 — the shared cross-provider aggregate range (spec §3.2, §3.4) ===
3409
+ #
3410
+ # One UTC interval, resolved once per tick and passed immutably to both Claude
3411
+ # folds. Membership is ``shared_start <= parsed_timestamp < shared_end_exclusive``
3412
+ # enforced on PARSED datetimes with microseconds preserved.
3413
+ #
3414
+ # ``_projects_iter_session_entries`` deliberately cannot serve this read. It
3415
+ # implements an inclusive ``[since, until]`` query, truncates both bounds to
3416
+ # whole seconds and spells them with a ``Z`` suffix, and compares with ``<=``.
3417
+ # Production ingestion writes ``timestamp.astimezone(utc).isoformat()``
3418
+ # (``bin/_cctally_cache.py``), so a stored value keeps its ``+00:00`` offset and
3419
+ # its microseconds. SQLite compares that TEXT column lexically, and ``+`` (0x2B)
3420
+ # sorts BELOW ``Z`` (0x5A) — so an entry exactly at the lower bound would be
3421
+ # dropped, while an entry inside the closing second would be admitted past the
3422
+ # exclusive upper bound. This iterator therefore uses the SQL predicate only as
3423
+ # an outward-widened CANDIDATE filter and enforces the real comparison in Python.
3424
+ #
3425
+ # The published range's `kind` and `label` are NOT declared here. They live once,
3426
+ # beside the canonicaliser that emits them, as `AGGREGATE_RANGE_KIND` and
3427
+ # `AGGREGATE_RANGE_LABEL` in `bin/_lib_dashboard_sources.py`.
3428
+
3429
+
3430
+ def resolve_shared_range(daily_panel, *, now_utc, display_tz, n: int = 30):
3431
+ """Return ``(shared_start, shared_end_exclusive)`` for the All aggregates.
3432
+
3433
+ ``shared_start`` is the earliest day of the already-built ``n``-day daily
3434
+ panel, taken at midnight in the resolved display timezone.
3435
+ ``shared_end_exclusive`` is ``now_utc + 1 microsecond``, which is the bound
3436
+ the Codex projects read already applies, so the Codex tab stays
3437
+ byte-stable.
3438
+
3439
+ BOTH branches floor to display-timezone midnight, and the fallback branch
3440
+ resolves the same calendar day the panel branch would have named **provided
3441
+ the panel is exactly ``n`` contiguous days ending today**. Every production
3442
+ panel has that shape (``materialise_daily_calendar`` gap-fills a contiguous
3443
+ calendar), so the two branches agree there. They do NOT agree in general: a
3444
+ caller that hands in a sparse or shorter panel gets that panel's own oldest
3445
+ day from the first branch and ``today - (n - 1) days`` from the fallback.
3446
+ Two separate defects require the flooring, and neither is visible with a
3447
+ pinned bound:
3448
+
3449
+ 1. The start is folded into version material at DAY granularity
3450
+ (``aggregate_scope_identity``), while ``compose_all_aggregates`` compares
3451
+ the two providers' carriers on the exact canonical string. A start that
3452
+ moves within a day therefore passes the reuse gate for an unchanged
3453
+ provider while a rebuilt provider records the newer instant, and the two
3454
+ carriers then disagree. The old fallback, ``now_utc - 30 days``, is a
3455
+ microsecond-precise instant that advances on every tick, so on a
3456
+ Claude-inactive, Codex-active install every tick withheld both
3457
+ aggregates as ``retained_range_mismatch`` and published no range at all.
3458
+ 2. ``daily_aggregate.rows`` is ``n`` CALENDAR days ending today
3459
+ (``materialise_daily_calendar``). A rolling ``n * 24h`` fallback started
3460
+ part-way through a further day, so the published range stated a span the
3461
+ row table did not cover.
3462
+ """
3463
+ now_utc = now_utc.astimezone(dt.timezone.utc)
3464
+ if daily_panel:
3465
+ # The panel is newest-first, so the oldest of its FIRST `n` rows is the
3466
+ # `n`-day floor. Reading `daily_panel[-1]` unconditionally ignored `n`,
3467
+ # so a caller asking for a shorter window than the panel it passed got
3468
+ # the panel's full extent from this branch and the shorter window from
3469
+ # the fallback — two branches describing different spans under one
3470
+ # parameter.
3471
+ earliest_day = dt.date.fromisoformat(
3472
+ daily_panel[min(max(n, 1), len(daily_panel)) - 1].date,
3473
+ )
3474
+ else:
3475
+ today_local = (
3476
+ now_utc.astimezone(display_tz) if display_tz is not None
3477
+ # internal fallback: host-local intentional
3478
+ else now_utc.astimezone()
3479
+ ).date()
3480
+ earliest_day = today_local - dt.timedelta(days=n - 1)
3481
+ if display_tz is not None:
3482
+ start = dt.datetime.combine(
3483
+ earliest_day, dt.time.min, tzinfo=display_tz,
3484
+ ).astimezone(dt.timezone.utc)
3485
+ else:
3486
+ # internal fallback: host-local intentional
3487
+ start = dt.datetime.combine(
3488
+ earliest_day, dt.time.min,
3489
+ ).astimezone(dt.timezone.utc)
3490
+ return start, now_utc + dt.timedelta(microseconds=1)
3491
+
3492
+
3493
+ def _shared_range_candidate_bounds(start, end_exclusive):
3494
+ """Whole-second SQL bounds widened OUTWARD on both ends.
3495
+
3496
+ One second of slack in each direction is what makes the candidate set a
3497
+ strict superset regardless of how a stored timestamp spells its offset
3498
+ (``+00:00`` or ``Z``) or whether it carries a fractional part.
3499
+ """
3500
+ low = start.astimezone(dt.timezone.utc).replace(
3501
+ microsecond=0,
3502
+ ) - dt.timedelta(seconds=1)
3503
+ high = end_exclusive.astimezone(dt.timezone.utc).replace(
3504
+ microsecond=0,
3505
+ ) + dt.timedelta(seconds=1)
3506
+ return low.isoformat(), high.isoformat()
3507
+
3508
+
3509
+ def iter_shared_range_entries(conn, *, start, end_exclusive):
3510
+ """Yield ``session_entries`` rows inside ``[start, end_exclusive)``.
3511
+
3512
+ Column order matches ``_projects_iter_session_entries`` exactly, so
3513
+ ``_fold_projects_entry``'s per-row arithmetic consumes these rows unchanged
3514
+ (spec §3.4 — reuse the arithmetic, not the week-bucket gate).
3515
+
3516
+ Two stages: an indexed candidate ``SELECT`` over widened whole-second
3517
+ bounds, then the authoritative half-open comparison on parsed datetimes.
3518
+ """
3519
+ since_iso, until_iso = _shared_range_candidate_bounds(start, end_exclusive)
3520
+ cur = conn.execute(
3521
+ "SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
3522
+ " e.output_tokens, e.cache_create_tokens, e.cache_read_tokens, "
3523
+ " e.cost_usd_raw, e.source_path, "
3524
+ " sf.session_id, sf.project_path, "
3525
+ " e.cache_create_1h_tokens, e.speed "
3526
+ "FROM session_entries e "
3527
+ "LEFT JOIN session_files sf ON sf.path = e.source_path "
3528
+ "WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
3529
+ "ORDER BY e.timestamp_utc ASC, e.id ASC",
3530
+ (since_iso, until_iso),
3531
+ )
3532
+ for row in cur:
3533
+ ts = parse_iso_datetime(row[1], "session_entries.timestamp_utc")
3534
+ if start <= ts < end_exclusive:
3535
+ yield row
3536
+
3537
+
3538
+ def _shared_range_row_to_usage_entry(row):
3539
+ """Rebuild the ``UsageEntry`` the daily aggregator consumes.
3540
+
3541
+ Field-for-field identical to ``_cctally_cache.iter_entries``, including
3542
+ ``datetime.fromisoformat`` rather than ``parse_iso_datetime`` — the daily
3543
+ aggregate has to reproduce what the legacy daily panel would compute over
3544
+ the same rows, so this construction must not diverge from that one.
3545
+ """
3546
+ (_entry_id, ts_iso, model, input_tok, output_tok,
3547
+ cache_create, cache_read, cost_raw, source_path,
3548
+ _session_id, _project_path, cache_1h, speed) = row
3549
+ return _cctally().UsageEntry(
3550
+ timestamp=dt.datetime.fromisoformat(ts_iso),
3551
+ model=model,
3552
+ usage=claude_usage_dict( # #195 chokepoint
3553
+ input_tokens=input_tok,
3554
+ output_tokens=output_tok,
3555
+ cache_creation_tokens=cache_create,
3556
+ cache_read_tokens=cache_read,
3557
+ cache_1h_tokens=cache_1h,
3558
+ speed=speed,
3559
+ ),
3560
+ cost_usd=cost_raw,
3561
+ source_path=source_path,
3562
+ )
3563
+
3564
+
3565
+ def _fold_prepared_daily_entries(
3566
+ accumulators, entries, *, display_tz=None, mode: str = "auto",
3567
+ ):
3568
+ """Append prepared entries through the canonical daily fold primitive."""
3569
+ for entry in entries:
3570
+ if entry.model == "<synthetic>":
3571
+ continue
3572
+ key = entry.timestamp.astimezone(display_tz).strftime("%Y-%m-%d")
3573
+ accumulator = accumulators.get(key)
3574
+ if accumulator is None:
3575
+ accumulator = _new_bucket_acc()
3576
+ accumulators[key] = accumulator
3577
+ _fold_entry(accumulator, entry, mode)
3578
+
3579
+
3580
+ def _finalize_daily_accumulators(accumulators):
3581
+ return [
3582
+ _finalize_bucket(key, accumulators[key])
3583
+ for key in sorted(accumulators)
3584
+ ]
3585
+
3586
+
3587
+ def fold_daily_over_range(
3588
+ rows, *, display_tz=None, mode: str = "auto", prepared_entries=None,
3589
+ ):
3590
+ """Fold the shared candidate stream into per-day ``BucketUsage``.
3591
+
3592
+ Consumes the SAME already-materialised sequence the projects fold reads
3593
+ (spec §3.4 — one candidate read, two folds). ``_aggregate_daily`` skips
3594
+ ``<synthetic>`` rows itself, so both folds share that policy.
3595
+ """
3596
+ entries = (
3597
+ prepared_entries
3598
+ if prepared_entries is not None and mode == "auto"
3599
+ else [_shared_range_row_to_usage_entry(row) for row in rows]
3600
+ )
3601
+ return _aggregate_daily(entries, mode=mode, tz=display_tz)
3602
+
3603
+
3604
+ def build_daily_aggregate_rows(
3605
+ rows,
3606
+ *,
3607
+ now_utc: "dt.datetime",
3608
+ display_tz=None,
3609
+ n: int = 30,
3610
+ mode: str = "auto",
3611
+ prepared_entries=None,
3612
+ ) -> "list[DailyPanelRow]":
3613
+ """The complete canonical thirty-day shape for the All Daily aggregate.
3614
+
3615
+ Pure apart from the caller's already-completed read. Folds the shared
3616
+ candidate stream, runs it through the same ``build_daily_view`` data plane
3617
+ the legacy panel uses, then materializes the contiguous calendar — so an
3618
+ empty Claude provider still publishes a full zero-cost shape (§6.3a).
3619
+ """
3620
+ buckets = fold_daily_over_range(
3621
+ rows,
3622
+ display_tz=display_tz,
3623
+ mode=mode,
3624
+ prepared_entries=prepared_entries,
3625
+ )
3626
+ return _build_daily_aggregate_rows_from_buckets(
3627
+ buckets,
3628
+ now_utc=now_utc,
3629
+ display_tz=display_tz,
3630
+ n=n,
3631
+ mode=mode,
3632
+ )
3633
+
3634
+
3635
+ def _build_daily_aggregate_rows_from_buckets(
3636
+ buckets,
3637
+ *,
3638
+ now_utc,
3639
+ display_tz=None,
3640
+ n: int = 30,
3641
+ mode: str = "auto",
3642
+ ):
3643
+ view = _cctally().build_daily_view(
3644
+ (), now_utc=now_utc, display_tz=display_tz, mode=mode,
3645
+ aggregated_override=buckets,
3646
+ )
3647
+ return materialise_daily_calendar(
3648
+ view.rows, now_utc=now_utc, n=n, display_tz=display_tz,
3649
+ )
3650
+
3651
+
3220
3652
  class _ProjWeekBucket(NamedTuple):
3221
3653
  """One (bucket_path, week) immutable aggregate for the projects-envelope
3222
3654
  per-week cache (#269 §14 Win 2).
@@ -3248,7 +3680,8 @@ def _fold_projects_entry(
3248
3680
  row: tuple,
3249
3681
  *,
3250
3682
  resolver_cache: dict,
3251
- week_start: "dt.datetime",
3683
+ week_start: "dt.datetime | None",
3684
+ prepared_daily_entries: "list | None" = None,
3252
3685
  ) -> "float | None":
3253
3686
  """Fold ONE ``_projects_iter_session_entries`` row onto ``mut`` (the shared
3254
3687
  per-row body, #271 §20 Codex-P1a).
@@ -3267,6 +3700,13 @@ def _fold_projects_entry(
3267
3700
  order-safe for the warm append because every delta row sorts strictly after
3268
3701
  ``tail`` (#271 §20), so a delta row is never the first-seen of a bucket that
3269
3702
  already exists in ``mut``.
3703
+
3704
+ ``week_start=None`` (#556 S2 §3.4) disables ONLY the week-bucket gate, for
3705
+ the range-native fold that ranks projects over one absolute interval. Every
3706
+ other caller passes a real week start and is byte-unchanged. The gate is the
3707
+ single line below; the cost, identity, session and first/last-seen
3708
+ arithmetic beneath it is what both callers share, and sharing it is what
3709
+ keeps the dashboard reconcilable with the CLI.
3270
3710
  """
3271
3711
  c = _cctally()
3272
3712
  (entry_id, ts_iso, model, input_tok, output_tok,
@@ -3275,21 +3715,32 @@ def _fold_projects_entry(
3275
3715
  if model == "<synthetic>":
3276
3716
  return None
3277
3717
  ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
3278
- if _projects_week_start_monday_utc(ts) != week_start:
3718
+ if week_start is not None and _projects_week_start_monday_utc(ts) != week_start:
3279
3719
  return None
3720
+ usage = claude_usage_dict( # #195 chokepoint
3721
+ input_tokens=input_tok,
3722
+ output_tokens=output_tok,
3723
+ cache_creation_tokens=cache_create,
3724
+ cache_read_tokens=cache_read,
3725
+ cache_1h_tokens=cache_1h,
3726
+ speed=speed,
3727
+ )
3280
3728
  entry_cost = _calculate_entry_cost(
3281
3729
  model,
3282
- claude_usage_dict( # #195 chokepoint
3283
- input_tokens=input_tok,
3284
- output_tokens=output_tok,
3285
- cache_creation_tokens=cache_create,
3286
- cache_read_tokens=cache_read,
3287
- cache_1h_tokens=cache_1h,
3288
- speed=speed,
3289
- ),
3730
+ usage,
3290
3731
  mode="auto",
3291
3732
  cost_usd=cost_raw,
3292
3733
  )
3734
+ if prepared_daily_entries is not None:
3735
+ # #567: preserve the canonical daily entry and aggregator while
3736
+ # handing off the effective cost this pass already computed.
3737
+ prepared_daily_entries.append(c.UsageEntry(
3738
+ timestamp=dt.datetime.fromisoformat(ts_iso),
3739
+ model=model,
3740
+ usage=usage,
3741
+ cost_usd=entry_cost,
3742
+ source_path=source_path,
3743
+ ))
3293
3744
  pkey = c._resolve_project_key(project_path, "git-root", resolver_cache)
3294
3745
  bp = pkey.bucket_path
3295
3746
  a = mut.get(bp)
@@ -3316,6 +3767,460 @@ def _fold_projects_entry(
3316
3767
  return entry_cost
3317
3768
 
3318
3769
 
3770
+ def fold_projects_over_range(
3771
+ rows, *, resolver_cache=None, prepared_daily_entries=None,
3772
+ ) -> "dict[str, dict]":
3773
+ """Fold an ALREADY-MATERIALISED candidate stream into per-bucket totals.
3774
+
3775
+ #556 S2 §3.4. Takes rows rather than a connection because one candidate read
3776
+ serves both All-only Claude folds: reopening or rescanning would price the
3777
+ same thirty-day population twice and extend the pinned cache snapshot's
3778
+ WAL-holding lifetime for no gain.
3779
+
3780
+ Returns the RAW ``mut`` shape ``_fold_projects_entry`` maintains —
3781
+ ``{bucket_path: {"cost_usd", "sessions" (a set), "first_seen", "last_seen",
3782
+ "first_order", "first_id", "first_key" (the ``ProjectKey``)}}``. The
3783
+ ``ProjectKey`` is retained deliberately: §3.8 disambiguates labels over
3784
+ exactly this bounded population, and the legacy builder's collision-safe
3785
+ label has already been replaced by an opaque key by the time the source
3786
+ bundle sees ``claude_data``, so there is nothing left to capture there.
3787
+
3788
+ No ``attributed_pct`` is computed. Weekly quota attribution divides by a
3789
+ subscription week's total (``_build_projects_envelope`` does it later), which
3790
+ is not meaningful for an absolute-range ranking.
3791
+ """
3792
+ mut: "dict[str, dict]" = {}
3793
+ cache = {} if resolver_cache is None else resolver_cache
3794
+ for row in rows:
3795
+ _fold_projects_entry(
3796
+ mut,
3797
+ row,
3798
+ resolver_cache=cache,
3799
+ week_start=None,
3800
+ prepared_daily_entries=prepared_daily_entries,
3801
+ )
3802
+ return mut
3803
+
3804
+
3805
+ def legacy_project_labels(projects_envelope: object) -> "dict[str, str]":
3806
+ """``bucket_path`` -> legacy display key, over the two published collections.
3807
+
3808
+ These are exactly the collections ``_project_detail_for_window`` resolves a
3809
+ requested display key against (`current_week.rows`, then `trend.projects`),
3810
+ so this is the population in which agreement between the aggregate identity
3811
+ and the legacy one is both possible and useful. A bucket outside it cannot
3812
+ be served by the drill-down under either label.
3813
+
3814
+ Pure; the caller supplies the envelope the sync thread already built.
3815
+ """
3816
+ mapped: "dict[str, str]" = {}
3817
+ env = projects_envelope if isinstance(projects_envelope, Mapping) else {}
3818
+ current = env.get("current_week")
3819
+ trend = env.get("trend")
3820
+ collections = (
3821
+ (current.get("rows") if isinstance(current, Mapping) else None),
3822
+ (trend.get("projects") if isinstance(trend, Mapping) else None),
3823
+ )
3824
+ for collection in collections:
3825
+ for row in collection or ():
3826
+ if not isinstance(row, Mapping):
3827
+ continue
3828
+ bucket_path = row.get("bucket_path")
3829
+ display_key = row.get("key")
3830
+ if isinstance(bucket_path, str) and bucket_path and (
3831
+ isinstance(display_key, str) and display_key
3832
+ ):
3833
+ mapped.setdefault(bucket_path, display_key)
3834
+ return mapped
3835
+
3836
+
3837
+ def build_project_aggregate_rows(
3838
+ rows,
3839
+ *,
3840
+ resolver_cache=None,
3841
+ legacy_labels=None,
3842
+ prepared_daily_entries=None,
3843
+ ) -> "list[dict]":
3844
+ """Published `providers.claude.projects.aggregate.rows` (spec §3.5.1).
3845
+
3846
+ Folds the shared candidate stream, labels each bucket, and publishes it as
3847
+ ``{key, label, source, cost_usd, sessions_count, drillable}``.
3848
+
3849
+ **The label is the legacy display key wherever the legacy population knows
3850
+ the bucket, and the bounded disambiguated label otherwise.** The opaque key
3851
+ is minted from whichever label is published, through the same rule the
3852
+ legacy Claude project rows use (``dashboard_resource_key("project",
3853
+ "claude", <display key>)``).
3854
+
3855
+ **The published population is not the routable one, and each row says
3856
+ which it is in.** The legacy population is not a superset of the bounded
3857
+ one on either axis. It is not a superset in COST, because ``trend_projects``
3858
+ skips a bucket whose cost is 0.0 across every trend week and
3859
+ ``current_week.rows`` carries only buckets with a current-week entry. It is
3860
+ not a superset in TIME either: the envelope walks
3861
+ ``[weeks_full[0], cw_start + 7d]`` and drops any entry whose
3862
+ Monday-anchored week falls outside ``weeks_full``, so an entry after the
3863
+ current week's nominal end is invisible to it while this fold correctly
3864
+ reaches ``now``. ``tests/fixtures/dashboard/tz-override`` is that shape: one
3865
+ priced project at exactly ``now``, published in the aggregate and absent
3866
+ from both legacy collections.
3867
+
3868
+ Filtering the published rows to the legacy population would drop that real
3869
+ row rather than fix it, and widening ``_build_projects_envelope``'s window
3870
+ is out of scope here because the Claude tab and the reconcile harness both
3871
+ read it. So the gap is resolved at the SEAM instead: ``drillable`` states,
3872
+ per row, whether the drill-down can reach that bucket, computed from the
3873
+ same collections ``_claude_project_key_for_source_key`` searches. A
3874
+ ``False`` row keeps its rank, its label and its cost — the ranking stays
3875
+ complete — and the client renders it without a drill affordance rather
3876
+ than offering an interaction that 404s.
3877
+
3878
+ ``drillable`` is exactly ``bucket_path in legacy_labels``. That is the
3879
+ condition under which the published label IS a legacy display key, so the
3880
+ minted opaque key is the one the route inverts. Deliberately NOT "the
3881
+ published label happens to appear among the legacy display keys": for a
3882
+ bucket the legacy population does not carry, a bounded label that collided
3883
+ with some OTHER bucket's legacy key would resolve, and serve that other
3884
+ project's sessions under this row's identity. A mis-route is worse than a
3885
+ withheld drill, so the predicate names the bucket, not the string.
3886
+
3887
+ The split is what makes the aggregate rows ROUTABLE. Labels are
3888
+ population-sensitive by design (`bin/_cctally_project.py`), so
3889
+ disambiguating over the bounded thirty-day population and over the legacy
3890
+ twelve-week one can produce different overrides for the same project — a
3891
+ project sharing a basename with an older root forces an override in one
3892
+ population and not in the other. Two labels means two opaque keys, and the
3893
+ drill-down resolves the requested key against the LEGACY display keys
3894
+ (`_claude_project_key_for_source_key` -> `_project_detail_for_window`), so
3895
+ a bounded-only key 404s even for a project both collections know.
3896
+ Preferring the legacy key where one exists makes the two identities agree
3897
+ by construction, and it does not weaken §9.2's twin-pair requirement:
3898
+ the legacy population disambiguates that pair too, so the two rows keep
3899
+ distinct labels and distinct identities.
3900
+
3901
+ ``legacy_labels`` is ``{bucket_path: display_key}`` from
3902
+ ``legacy_project_labels``. A bucket it does not carry falls back to the
3903
+ bounded disambiguated label. ``None`` means "no legacy population was
3904
+ supplied" and is NOT a production path: production withholds the aggregate
3905
+ when no envelope was built (`_tui_build_claude_aggregates`), because
3906
+ publishing rows against an unknown routable population is the silent
3907
+ identity change this rule exists to prevent. It is kept for kernel callers
3908
+ that fold a store with no envelope beside it.
3909
+
3910
+ No ``attributed_pct``: quota attribution divides by a subscription week's
3911
+ total and means nothing over an absolute range. No ``bucket_path``, git
3912
+ root or raw source path — opaque keys stay non-reversible.
3913
+ """
3914
+ folded = fold_projects_over_range(
3915
+ rows,
3916
+ resolver_cache=resolver_cache,
3917
+ prepared_daily_entries=prepared_daily_entries,
3918
+ )
3919
+ return _project_aggregate_rows_from_folded(folded, legacy_labels)
3920
+
3921
+
3922
+ def _project_aggregate_rows_from_folded(folded, legacy_labels):
3923
+ """Finalize cached/raw project accumulators into the public row shape."""
3924
+ c = _cctally()
3925
+ bucket_paths_sorted = sorted(folded)
3926
+ augmented_by_idx = c._project_disambiguate_labels(
3927
+ [{"key": folded[bp]["first_key"]} for bp in bucket_paths_sorted],
3928
+ )
3929
+ legacy = legacy_labels if isinstance(legacy_labels, Mapping) else {}
3930
+ published: "list[dict]" = []
3931
+ for idx, bucket_path in enumerate(bucket_paths_sorted):
3932
+ accumulator = folded[bucket_path]
3933
+ legacy_label = legacy.get(bucket_path)
3934
+ label = legacy_label or augmented_by_idx.get(
3935
+ idx, accumulator["first_key"].display_key,
3936
+ )
3937
+ published.append({
3938
+ "key": dashboard_resource_key("project", "claude", label),
3939
+ "label": label,
3940
+ "source": "claude",
3941
+ # Whether `GET /api/source/claude/project/<key>` can resolve this
3942
+ # row. See the docstring: the predicate names the BUCKET, so a
3943
+ # bounded label that collides with an unrelated legacy key never
3944
+ # advertises a drill that would serve the wrong project.
3945
+ "drillable": legacy_label is not None,
3946
+ # NOT rounded, for the same reason `current_week.rows` is not:
3947
+ # `round(..., 6)` introduces ~1e-6 error that breaks the 1e-9
3948
+ # reconcile tolerance.
3949
+ "cost_usd": accumulator["cost_usd"],
3950
+ "sessions_count": len(accumulator["sessions"]),
3951
+ })
3952
+ # Desc by cost, ties broken by label so the ranking is byte-stable.
3953
+ published.sort(key=lambda row: (-row["cost_usd"], row["label"]))
3954
+ return published
3955
+
3956
+
3957
+ _CLAUDE_RANGE_AGGREGATE_MEMO: dict[str, object] = {"state": None}
3958
+
3959
+
3960
+ def reset_claude_range_aggregate_memo() -> None:
3961
+ """Drop the process-local #567 range-fold accumulator."""
3962
+ _CLAUDE_RANGE_AGGREGATE_MEMO["state"] = None
3963
+
3964
+
3965
+ def _shared_range_store_identity(conn):
3966
+ for _seq, name, path in conn.execute("PRAGMA database_list"):
3967
+ if name == "main":
3968
+ if not path:
3969
+ return (f":memory:{id(conn)}", None, None)
3970
+ try:
3971
+ stat = os.stat(path)
3972
+ except OSError:
3973
+ return (str(path), None, None)
3974
+ return (str(path), int(stat.st_dev), int(stat.st_ino))
3975
+ return f":connection:{id(conn)}"
3976
+
3977
+
3978
+ def _shared_range_entry_signature(conn) -> tuple[int, int]:
3979
+ max_id = int(conn.execute(
3980
+ "SELECT COALESCE(MAX(id), 0) FROM main.session_entries"
3981
+ ).fetchone()[0])
3982
+ try:
3983
+ max_seq = int(conn.execute(
3984
+ "SELECT COALESCE(MAX(mutation_seq), 0) FROM main.session_entries"
3985
+ ).fetchone()[0])
3986
+ except sqlite3.OperationalError:
3987
+ max_seq = 0
3988
+ return max_id, max_seq
3989
+
3990
+
3991
+ def _shared_range_session_files_signature(conn) -> tuple[int, int]:
3992
+ """Cheap identity signal for lazy session/project metadata backfills."""
3993
+ try:
3994
+ row = conn.execute(
3995
+ "SELECT COUNT(*), COALESCE(MAX(rowid), 0) FROM session_files"
3996
+ ).fetchone()
3997
+ except sqlite3.Error:
3998
+ return (0, 0)
3999
+ return int(row[0]), int(row[1])
4000
+
4001
+
4002
+ def _shared_range_entries_after_id(conn, after_id: int):
4003
+ """Yield appended rows in canonical timestamp/id fold order."""
4004
+ cur = conn.execute(
4005
+ "SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
4006
+ " e.output_tokens, e.cache_create_tokens, e.cache_read_tokens, "
4007
+ " e.cost_usd_raw, e.source_path, "
4008
+ " sf.session_id, sf.project_path, "
4009
+ " e.cache_create_1h_tokens, e.speed "
4010
+ "FROM session_entries e "
4011
+ "LEFT JOIN session_files sf ON sf.path = e.source_path "
4012
+ "WHERE e.id > ? "
4013
+ "ORDER BY e.timestamp_utc ASC, e.id ASC",
4014
+ (after_id,),
4015
+ )
4016
+ yield from cur
4017
+
4018
+
4019
+ def _shared_range_prior_row_mutated(
4020
+ conn, *, after_seq: int, through_id: int,
4021
+ ) -> bool:
4022
+ try:
4023
+ row = conn.execute(
4024
+ "SELECT 1 FROM main.session_entries "
4025
+ "WHERE mutation_seq > ? AND id <= ? LIMIT 1",
4026
+ (after_seq, through_id),
4027
+ ).fetchone()
4028
+ except sqlite3.OperationalError:
4029
+ return True
4030
+ return row is not None
4031
+
4032
+
4033
+ def _shared_range_cache_base(
4034
+ conn, *, shared_start, display_tz, generation: int,
4035
+ ):
4036
+ tz_key = getattr(display_tz, "key", None)
4037
+ if tz_key is None:
4038
+ tz_key = str(display_tz) if display_tz is not None else "local"
4039
+ return (
4040
+ _shared_range_store_identity(conn),
4041
+ shared_start.astimezone(dt.timezone.utc).isoformat(),
4042
+ tz_key,
4043
+ int(generation),
4044
+ _shared_range_session_files_signature(conn),
4045
+ int(conn.execute(
4046
+ "SELECT COALESCE(MIN(id), 0) FROM main.session_entries"
4047
+ ).fetchone()[0]),
4048
+ )
4049
+
4050
+
4051
+ def _shared_range_cache_payload(
4052
+ state,
4053
+ *,
4054
+ legacy_labels,
4055
+ now_utc,
4056
+ display_tz,
4057
+ ):
4058
+ project_rows = _project_aggregate_rows_from_folded(
4059
+ state["project_mut"], legacy_labels,
4060
+ )
4061
+ daily_buckets = _finalize_daily_accumulators(state["daily_accumulators"])
4062
+ daily_rows = _build_daily_aggregate_rows_from_buckets(
4063
+ daily_buckets, now_utc=now_utc, display_tz=display_tz,
4064
+ )
4065
+ c = _cctally()
4066
+ return {
4067
+ "projects": project_rows,
4068
+ "daily": [c.daily_panel_row_to_wire(row) for row in daily_rows],
4069
+ }
4070
+
4071
+
4072
+ def build_cached_claude_range_aggregates(
4073
+ conn,
4074
+ *,
4075
+ shared_start,
4076
+ shared_end_exclusive,
4077
+ now_utc,
4078
+ display_tz,
4079
+ legacy_labels,
4080
+ max_entry_id: "int | None" = None,
4081
+ entry_mutation_seq: "int | None" = None,
4082
+ generation: int = 0,
4083
+ ):
4084
+ """Build or increment the one-snapshot Claude range folds (#567).
4085
+
4086
+ Pure appends are folded onto the cached raw accumulators. A shifted range
4087
+ floor, backwards clock, generation or session-file identity change,
4088
+ non-monotone signature, or an id-stable mutation of an already-folded row
4089
+ falls back to one full ordered pass. The cache stores no public labels, so
4090
+ the current legacy population is reapplied on every publication.
4091
+ """
4092
+ if max_entry_id is None or entry_mutation_seq is None:
4093
+ observed_id, observed_seq = _shared_range_entry_signature(conn)
4094
+ if max_entry_id is None:
4095
+ max_entry_id = observed_id
4096
+ if entry_mutation_seq is None:
4097
+ entry_mutation_seq = observed_seq
4098
+ max_entry_id = int(max_entry_id)
4099
+ entry_mutation_seq = int(entry_mutation_seq)
4100
+ base = _shared_range_cache_base(
4101
+ conn,
4102
+ shared_start=shared_start,
4103
+ display_tz=display_tz,
4104
+ generation=generation,
4105
+ )
4106
+ prior = _CLAUDE_RANGE_AGGREGATE_MEMO.get("state")
4107
+ state = None
4108
+ if isinstance(prior, dict) and prior.get("base") == base:
4109
+ monotone = (
4110
+ max_entry_id >= prior["max_entry_id"]
4111
+ and entry_mutation_seq >= prior["entry_mutation_seq"]
4112
+ and shared_end_exclusive >= prior["end_exclusive"]
4113
+ )
4114
+ old_row_changed = (
4115
+ entry_mutation_seq != prior["entry_mutation_seq"]
4116
+ and _shared_range_prior_row_mutated(
4117
+ conn,
4118
+ after_seq=prior["entry_mutation_seq"],
4119
+ through_id=prior["max_entry_id"],
4120
+ )
4121
+ )
4122
+ if monotone and not old_row_changed:
4123
+ project_mut = copy.deepcopy(prior["project_mut"])
4124
+ daily_accumulators = copy.deepcopy(prior["daily_accumulators"])
4125
+ resolver_cache = dict(prior["resolver_cache"])
4126
+ delta_by_id = {}
4127
+ for row in _shared_range_entries_after_id(
4128
+ conn, prior["max_entry_id"],
4129
+ ):
4130
+ ts = parse_iso_datetime(
4131
+ row[1], "session_entries.timestamp_utc",
4132
+ )
4133
+ if shared_start <= ts < shared_end_exclusive:
4134
+ delta_by_id[row[0]] = row
4135
+ if shared_end_exclusive > prior["end_exclusive"]:
4136
+ for row in iter_shared_range_entries(
4137
+ conn,
4138
+ start=prior["end_exclusive"],
4139
+ end_exclusive=shared_end_exclusive,
4140
+ ):
4141
+ if row[0] <= prior["max_entry_id"]:
4142
+ delta_by_id[row[0]] = row
4143
+ delta_rows = sorted(
4144
+ delta_by_id.values(),
4145
+ key=lambda row: (row[1], row[0]),
4146
+ )
4147
+ prior_tail = prior["tail"]
4148
+ if prior_tail is None or all(
4149
+ (row[1], row[0]) > prior_tail
4150
+ for row in delta_rows
4151
+ if row[2] != "<synthetic>"
4152
+ ):
4153
+ prepared = []
4154
+ for row in delta_rows:
4155
+ _fold_projects_entry(
4156
+ project_mut,
4157
+ row,
4158
+ resolver_cache=resolver_cache,
4159
+ week_start=None,
4160
+ prepared_daily_entries=prepared,
4161
+ )
4162
+ _fold_prepared_daily_entries(
4163
+ daily_accumulators,
4164
+ prepared,
4165
+ display_tz=display_tz,
4166
+ )
4167
+ tail = prior_tail
4168
+ real_delta = [
4169
+ row for row in delta_rows if row[2] != "<synthetic>"
4170
+ ]
4171
+ if real_delta:
4172
+ last = real_delta[-1]
4173
+ tail = (last[1], last[0])
4174
+ state = {
4175
+ "base": base,
4176
+ "max_entry_id": max_entry_id,
4177
+ "entry_mutation_seq": entry_mutation_seq,
4178
+ "end_exclusive": shared_end_exclusive,
4179
+ "tail": tail,
4180
+ "project_mut": project_mut,
4181
+ "daily_accumulators": daily_accumulators,
4182
+ "resolver_cache": resolver_cache,
4183
+ }
4184
+ if state is None:
4185
+ rows = tuple(iter_shared_range_entries(
4186
+ conn, start=shared_start, end_exclusive=shared_end_exclusive,
4187
+ ))
4188
+ prepared = []
4189
+ resolver_cache = {}
4190
+ project_mut = fold_projects_over_range(
4191
+ rows,
4192
+ resolver_cache=resolver_cache,
4193
+ prepared_daily_entries=prepared,
4194
+ )
4195
+ daily_accumulators = {}
4196
+ _fold_prepared_daily_entries(
4197
+ daily_accumulators, prepared, display_tz=display_tz,
4198
+ )
4199
+ real_rows = [row for row in rows if row[2] != "<synthetic>"]
4200
+ tail = None
4201
+ if real_rows:
4202
+ last = real_rows[-1]
4203
+ tail = (last[1], last[0])
4204
+ state = {
4205
+ "base": base,
4206
+ "max_entry_id": max_entry_id,
4207
+ "entry_mutation_seq": entry_mutation_seq,
4208
+ "end_exclusive": shared_end_exclusive,
4209
+ "tail": tail,
4210
+ "project_mut": project_mut,
4211
+ "daily_accumulators": daily_accumulators,
4212
+ "resolver_cache": resolver_cache,
4213
+ }
4214
+ payload = _shared_range_cache_payload(
4215
+ state,
4216
+ legacy_labels=legacy_labels,
4217
+ now_utc=now_utc,
4218
+ display_tz=display_tz,
4219
+ )
4220
+ _CLAUDE_RANGE_AGGREGATE_MEMO["state"] = state
4221
+ return payload
4222
+
4223
+
3319
4224
  def _aggregate_projects_week_raw(
3320
4225
  conn: "sqlite3.Connection",
3321
4226
  *,