cctally 1.96.2 → 1.98.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.
@@ -1019,17 +1019,128 @@ def _claude_project_key_for_path(snapshot, project_path: object) -> str | None:
1019
1019
  return None
1020
1020
 
1021
1021
 
1022
+ def _claude_aggregate_project_rows(snapshot) -> "tuple":
1023
+ """The published bounded project rows, or an empty tuple.
1024
+
1025
+ They live on the frozen source bundle rather than on the legacy envelope,
1026
+ and a provider whose bounded fold failed publishes none.
1027
+ """
1028
+ bundle = getattr(snapshot, "source_bundle", None)
1029
+ try:
1030
+ data = bundle.sources["claude"].data
1031
+ except (AttributeError, KeyError, TypeError):
1032
+ return ()
1033
+ if not isinstance(data, Mapping):
1034
+ return ()
1035
+ projects = data.get("projects")
1036
+ aggregate = projects.get("aggregate") if isinstance(projects, Mapping) else None
1037
+ rows = aggregate.get("rows") if isinstance(aggregate, Mapping) else None
1038
+ return tuple(rows) if isinstance(rows, (list, tuple)) else ()
1039
+
1040
+
1041
+ # The window sizes `GET /api/source/<source>/project/<key>?weeks=N` accepts.
1042
+ PROJECT_WINDOW_WEEKS_CHOICES = (1, 4, 8, 12)
1043
+
1044
+
1045
+ def resolve_aggregate_project_window_weeks(
1046
+ requested: int, *, shared_start_at: object, current_week_start_at: object,
1047
+ ) -> int:
1048
+ """The drill window a row published by the All ranking must resolve with.
1049
+
1050
+ The two windows are anchored differently and one does not contain the
1051
+ other. The ranking is ``n`` calendar days ending today. The drill is
1052
+ ``weeks_back`` weeks anchored at the CURRENT MONDAY —
1053
+ ``[cw_start - 7 * (weeks_back - 1), cw_start + 7)`` — so at the default four
1054
+ weeks it reaches twenty-one days before that Monday, which is twenty-one to
1055
+ twenty-seven days before today. Against a thirty-day ranking that leaves up
1056
+ to nine days at the start of the ranking window ranked and undrillable: a
1057
+ row published at a real dollar figure opens a modal reporting
1058
+ ``window_cost_usd: 0.0`` and no sessions.
1059
+
1060
+ So the window is the SMALLEST accepted choice whose span reaches the
1061
+ resolved shared start, and never narrower than the caller asked for. When
1062
+ NO accepted choice reaches it — a shared start further back than twelve
1063
+ weeks before the current Monday — the widest choice is returned instead,
1064
+ which is the closest the accepted set can come rather than a span that
1065
+ reaches. It is not silent either way: ``window_weeks`` is in the payload
1066
+ and the detail states the resolved span it covered, so a window that falls
1067
+ short says so rather than implying it reached. When either bound is
1068
+ unresolvable the caller's value stands, because guessing a wider window
1069
+ over an unknown range would state a span nothing established.
1070
+
1071
+ Pure. ``_build_claude_source_detail`` supplies the two bounds.
1072
+ """
1073
+ if not isinstance(shared_start_at, str) or not isinstance(
1074
+ current_week_start_at, str,
1075
+ ):
1076
+ return requested
1077
+ try:
1078
+ shared_start = parse_iso_datetime(
1079
+ shared_start_at, "aggregates.range.start_at",
1080
+ )
1081
+ cw_start = parse_iso_datetime(
1082
+ current_week_start_at, "projects.current_week.week_start_at",
1083
+ )
1084
+ except ValueError:
1085
+ return requested
1086
+ covering = [
1087
+ weeks for weeks in PROJECT_WINDOW_WEEKS_CHOICES
1088
+ if cw_start - dt.timedelta(days=7 * (weeks - 1)) <= shared_start
1089
+ ]
1090
+ if not covering:
1091
+ return max(PROJECT_WINDOW_WEEKS_CHOICES[-1], requested)
1092
+ return max(min(covering), requested)
1093
+
1094
+
1095
+ def _published_aggregate_start_at(snapshot) -> object:
1096
+ """``sources.all.data.aggregates.range.start_at``, or ``None``."""
1097
+ bundle = getattr(snapshot, "source_bundle", None)
1098
+ try:
1099
+ data = bundle.sources["all"].data
1100
+ except (AttributeError, KeyError, TypeError):
1101
+ return None
1102
+ if not isinstance(data, Mapping):
1103
+ return None
1104
+ aggregates = data.get("aggregates")
1105
+ published = (
1106
+ aggregates.get("range") if isinstance(aggregates, Mapping) else None
1107
+ )
1108
+ return published.get("start_at") if isinstance(published, Mapping) else None
1109
+
1110
+
1111
+ def _project_window_weeks_for_key(snapshot, key: str, requested: int) -> int:
1112
+ """Widen the drill window for a key the All ranking published.
1113
+
1114
+ A key absent from the published aggregate rows was not ranked over the
1115
+ shared range, so nothing about that range describes it and the caller's
1116
+ window stands unchanged.
1117
+ """
1118
+ if not any(
1119
+ isinstance(row, Mapping) and row.get("key") == key
1120
+ for row in _claude_aggregate_project_rows(snapshot)
1121
+ ):
1122
+ return requested
1123
+ env = getattr(snapshot, "projects_envelope", None)
1124
+ current = env.get("current_week") if isinstance(env, Mapping) else None
1125
+ return resolve_aggregate_project_window_weeks(
1126
+ requested,
1127
+ shared_start_at=_published_aggregate_start_at(snapshot),
1128
+ current_week_start_at=(
1129
+ current.get("week_start_at") if isinstance(current, Mapping) else None
1130
+ ),
1131
+ )
1132
+
1133
+
1022
1134
  def _claude_project_key_for_source_key(snapshot, key: str) -> str | None:
1023
1135
  env = getattr(snapshot, "projects_envelope", None)
1024
- if not isinstance(env, Mapping):
1025
- return None
1026
1136
  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 ())
1137
+ if isinstance(env, Mapping):
1138
+ current = env.get("current_week")
1139
+ if isinstance(current, Mapping):
1140
+ candidates.extend(current.get("rows") or ())
1141
+ trend = env.get("trend")
1142
+ if isinstance(trend, Mapping):
1143
+ candidates.extend(trend.get("projects") or ())
1033
1144
  for row in candidates:
1034
1145
  if not isinstance(row, Mapping):
1035
1146
  continue
@@ -1038,6 +1149,16 @@ def _claude_project_key_for_source_key(snapshot, key: str) -> str | None:
1038
1149
  dashboard_resource_key("project", "claude", project_key) == key
1039
1150
  ):
1040
1151
  return project_key
1152
+ # #556 S2 remediation: there is deliberately NO fallback to the published
1153
+ # aggregate rows here. A retained bundle outlives the per-tick envelope
1154
+ # rebuild, so its labels can be stale, and `_project_detail_for_window`
1155
+ # resolves whatever this returns against the CURRENT envelope. Labels are
1156
+ # population-sensitive, so a stale label either misses there anyway — the
1157
+ # same 404, reached one step later — or names a DIFFERENT bucket and serves
1158
+ # another project's sessions under this key. A guarded fallback would be
1159
+ # dead code besides: the aggregate key is minted from its label by the same
1160
+ # rule the loop above inverts, so any label the current envelope still
1161
+ # carries has already matched.
1041
1162
  return None
1042
1163
 
1043
1164
 
@@ -1094,6 +1215,13 @@ def _build_claude_source_detail(
1094
1215
  project_key = _claude_project_key_for_source_key(snapshot, key)
1095
1216
  if project_key is None:
1096
1217
  raise SourceResourceNotFound()
1218
+ # #556 S2 remediation: a row the All ranking published states a figure
1219
+ # over the shared range, so its detail has to reach that range. The
1220
+ # default four-week drill is anchored at the current Monday and misses
1221
+ # up to nine days at the start of a thirty-day ranking.
1222
+ window_weeks = _project_window_weeks_for_key(
1223
+ snapshot, key, window_weeks,
1224
+ )
1097
1225
  conn = open_db()
1098
1226
  try:
1099
1227
  conn.execute("ATTACH DATABASE ? AS cache_db", (str(_cctally_core.CACHE_DB_PATH),))
@@ -2976,6 +3104,87 @@ def _group_a_daily_buckets(now_utc, *, n, display_tz):
2976
3104
  cache_conn.close()
2977
3105
 
2978
3106
 
3107
+ def materialise_daily_calendar(
3108
+ view_rows,
3109
+ *,
3110
+ now_utc: "dt.datetime",
3111
+ n: int = 30,
3112
+ display_tz: "ZoneInfo | None" = None,
3113
+ ) -> "list[DailyPanelRow]":
3114
+ """Materialize the contiguous ``n``-day calendar. Pure, no I/O.
3115
+
3116
+ ``view_rows`` is ``build_daily_view``'s gap-free newest-first row sequence,
3117
+ which carries the data-plane fields and leaves ``label`` and
3118
+ ``intensity_bucket`` at dataclass defaults (spec §4.4). This adapter
3119
+ overlays those rows onto the calendar window, adds a zero-cost row for
3120
+ every gap day so the heatmap shows a faded cell, and fills the two
3121
+ presentation-only fields.
3122
+
3123
+ #556 S2 §6.3a extracted this from ``_dashboard_build_daily_panel``, which
3124
+ opens its own connection through Group A or ``get_entries`` and cannot be
3125
+ called from the pinned source-bundle fold. Unlike that function, this one
3126
+ emits a COMPLETE shape for an empty provider rather than nothing: under All
3127
+ an empty provider is a zero leg, and a zero leg still has a shape, so the
3128
+ client never needs a second source for the calendar it renders.
3129
+ """
3130
+ rows_by_date = {r.date: r for r in (view_rows or ())}
3131
+ today_local = (
3132
+ now_utc.astimezone(display_tz) if display_tz is not None
3133
+ # internal fallback: host-local intentional
3134
+ else now_utc.astimezone()
3135
+ ).date()
3136
+
3137
+ rows: list[DailyPanelRow] = []
3138
+ for i in range(n):
3139
+ d = today_local - dt.timedelta(days=i)
3140
+ date_str = d.isoformat()
3141
+ existing = rows_by_date.get(date_str)
3142
+ if existing is not None:
3143
+ # Use the view-model row but fill the presentation-only
3144
+ # ``label`` (intensity_bucket is set by
3145
+ # ``_compute_intensity_buckets`` below).
3146
+ rows.append(dataclasses.replace(existing, label=date_str[5:]))
3147
+ else:
3148
+ # Zero-cost gap day: tokens default to 0, cache_hit_pct to None
3149
+ # (avoids /0 and signals 'no data' cleanly to the modal tile).
3150
+ rows.append(DailyPanelRow(
3151
+ date=date_str,
3152
+ label=date_str[5:],
3153
+ cost_usd=0.0,
3154
+ is_today=(d == today_local),
3155
+ intensity_bucket=0,
3156
+ models=[],
3157
+ ))
3158
+
3159
+ _compute_intensity_buckets(rows)
3160
+ return rows
3161
+
3162
+
3163
+ def daily_panel_row_to_wire(row: "DailyPanelRow") -> dict:
3164
+ """One wire shape for a daily row, whichever sibling published it.
3165
+
3166
+ ``snapshot_to_envelope``'s ``_daily_row_to_dict`` delegates here, so the
3167
+ All-only ``periods.daily_aggregate.rows`` sibling and the legacy
3168
+ ``periods.daily.rows`` cannot drift into two shapes the client would have
3169
+ to tell apart.
3170
+ """
3171
+ return {
3172
+ "date": row.date,
3173
+ "label": row.label,
3174
+ "cost_usd": row.cost_usd,
3175
+ "is_today": row.is_today,
3176
+ "intensity_bucket": row.intensity_bucket,
3177
+ "models": list(row.models),
3178
+ # ---- v2.3 additions ----
3179
+ "input_tokens": row.input_tokens,
3180
+ "output_tokens": row.output_tokens,
3181
+ "cache_creation_tokens": row.cache_creation_tokens,
3182
+ "cache_read_tokens": row.cache_read_tokens,
3183
+ "total_tokens": row.total_tokens,
3184
+ "cache_hit_pct": row.cache_hit_pct,
3185
+ }
3186
+
3187
+
2979
3188
  def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
2980
3189
  now_utc: "dt.datetime",
2981
3190
  *,
@@ -3050,42 +3259,13 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
3050
3259
  if not view.rows:
3051
3260
  return []
3052
3261
 
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
3262
+ # Materialize the contiguous N-day window. #556 S2 §6.3a extracted the
3263
+ # block below into ``materialise_daily_calendar`` so the All-only Claude
3264
+ # daily leg can reach it without this function's I/O; the Claude tab's
3265
+ # behaviour is unchanged, including the empty-provider early return above.
3266
+ return materialise_daily_calendar(
3267
+ view.rows, now_utc=now_utc, n=n, display_tz=display_tz,
3268
+ )
3089
3269
 
3090
3270
 
3091
3271
  # --- Projects panel / modal (spec 2026-05-19-projects-panel-design.md) ------
@@ -3217,6 +3397,202 @@ def _projects_iter_session_entries(conn: "sqlite3.Connection",
3217
3397
  yield row
3218
3398
 
3219
3399
 
3400
+ # === #556 S2 — the shared cross-provider aggregate range (spec §3.2, §3.4) ===
3401
+ #
3402
+ # One UTC interval, resolved once per tick and passed immutably to both Claude
3403
+ # folds. Membership is ``shared_start <= parsed_timestamp < shared_end_exclusive``
3404
+ # enforced on PARSED datetimes with microseconds preserved.
3405
+ #
3406
+ # ``_projects_iter_session_entries`` deliberately cannot serve this read. It
3407
+ # implements an inclusive ``[since, until]`` query, truncates both bounds to
3408
+ # whole seconds and spells them with a ``Z`` suffix, and compares with ``<=``.
3409
+ # Production ingestion writes ``timestamp.astimezone(utc).isoformat()``
3410
+ # (``bin/_cctally_cache.py``), so a stored value keeps its ``+00:00`` offset and
3411
+ # its microseconds. SQLite compares that TEXT column lexically, and ``+`` (0x2B)
3412
+ # sorts BELOW ``Z`` (0x5A) — so an entry exactly at the lower bound would be
3413
+ # dropped, while an entry inside the closing second would be admitted past the
3414
+ # exclusive upper bound. This iterator therefore uses the SQL predicate only as
3415
+ # an outward-widened CANDIDATE filter and enforces the real comparison in Python.
3416
+ #
3417
+ # The published range's `kind` and `label` are NOT declared here. They live once,
3418
+ # beside the canonicaliser that emits them, as `AGGREGATE_RANGE_KIND` and
3419
+ # `AGGREGATE_RANGE_LABEL` in `bin/_lib_dashboard_sources.py`.
3420
+
3421
+
3422
+ def resolve_shared_range(daily_panel, *, now_utc, display_tz, n: int = 30):
3423
+ """Return ``(shared_start, shared_end_exclusive)`` for the All aggregates.
3424
+
3425
+ ``shared_start`` is the earliest day of the already-built ``n``-day daily
3426
+ panel, taken at midnight in the resolved display timezone.
3427
+ ``shared_end_exclusive`` is ``now_utc + 1 microsecond``, which is the bound
3428
+ the Codex projects read already applies, so the Codex tab stays
3429
+ byte-stable.
3430
+
3431
+ BOTH branches floor to display-timezone midnight, and the fallback branch
3432
+ resolves the same calendar day the panel branch would have named **provided
3433
+ the panel is exactly ``n`` contiguous days ending today**. Every production
3434
+ panel has that shape (``materialise_daily_calendar`` gap-fills a contiguous
3435
+ calendar), so the two branches agree there. They do NOT agree in general: a
3436
+ caller that hands in a sparse or shorter panel gets that panel's own oldest
3437
+ day from the first branch and ``today - (n - 1) days`` from the fallback.
3438
+ Two separate defects require the flooring, and neither is visible with a
3439
+ pinned bound:
3440
+
3441
+ 1. The start is folded into version material at DAY granularity
3442
+ (``aggregate_scope_identity``), while ``compose_all_aggregates`` compares
3443
+ the two providers' carriers on the exact canonical string. A start that
3444
+ moves within a day therefore passes the reuse gate for an unchanged
3445
+ provider while a rebuilt provider records the newer instant, and the two
3446
+ carriers then disagree. The old fallback, ``now_utc - 30 days``, is a
3447
+ microsecond-precise instant that advances on every tick, so on a
3448
+ Claude-inactive, Codex-active install every tick withheld both
3449
+ aggregates as ``retained_range_mismatch`` and published no range at all.
3450
+ 2. ``daily_aggregate.rows`` is ``n`` CALENDAR days ending today
3451
+ (``materialise_daily_calendar``). A rolling ``n * 24h`` fallback started
3452
+ part-way through a further day, so the published range stated a span the
3453
+ row table did not cover.
3454
+ """
3455
+ now_utc = now_utc.astimezone(dt.timezone.utc)
3456
+ if daily_panel:
3457
+ # The panel is newest-first, so the oldest of its FIRST `n` rows is the
3458
+ # `n`-day floor. Reading `daily_panel[-1]` unconditionally ignored `n`,
3459
+ # so a caller asking for a shorter window than the panel it passed got
3460
+ # the panel's full extent from this branch and the shorter window from
3461
+ # the fallback — two branches describing different spans under one
3462
+ # parameter.
3463
+ earliest_day = dt.date.fromisoformat(
3464
+ daily_panel[min(max(n, 1), len(daily_panel)) - 1].date,
3465
+ )
3466
+ else:
3467
+ today_local = (
3468
+ now_utc.astimezone(display_tz) if display_tz is not None
3469
+ # internal fallback: host-local intentional
3470
+ else now_utc.astimezone()
3471
+ ).date()
3472
+ earliest_day = today_local - dt.timedelta(days=n - 1)
3473
+ if display_tz is not None:
3474
+ start = dt.datetime.combine(
3475
+ earliest_day, dt.time.min, tzinfo=display_tz,
3476
+ ).astimezone(dt.timezone.utc)
3477
+ else:
3478
+ # internal fallback: host-local intentional
3479
+ start = dt.datetime.combine(
3480
+ earliest_day, dt.time.min,
3481
+ ).astimezone(dt.timezone.utc)
3482
+ return start, now_utc + dt.timedelta(microseconds=1)
3483
+
3484
+
3485
+ def _shared_range_candidate_bounds(start, end_exclusive):
3486
+ """Whole-second SQL bounds widened OUTWARD on both ends.
3487
+
3488
+ One second of slack in each direction is what makes the candidate set a
3489
+ strict superset regardless of how a stored timestamp spells its offset
3490
+ (``+00:00`` or ``Z``) or whether it carries a fractional part.
3491
+ """
3492
+ low = start.astimezone(dt.timezone.utc).replace(
3493
+ microsecond=0,
3494
+ ) - dt.timedelta(seconds=1)
3495
+ high = end_exclusive.astimezone(dt.timezone.utc).replace(
3496
+ microsecond=0,
3497
+ ) + dt.timedelta(seconds=1)
3498
+ return low.isoformat(), high.isoformat()
3499
+
3500
+
3501
+ def iter_shared_range_entries(conn, *, start, end_exclusive):
3502
+ """Yield ``session_entries`` rows inside ``[start, end_exclusive)``.
3503
+
3504
+ Column order matches ``_projects_iter_session_entries`` exactly, so
3505
+ ``_fold_projects_entry``'s per-row arithmetic consumes these rows unchanged
3506
+ (spec §3.4 — reuse the arithmetic, not the week-bucket gate).
3507
+
3508
+ Two stages: an indexed candidate ``SELECT`` over widened whole-second
3509
+ bounds, then the authoritative half-open comparison on parsed datetimes.
3510
+ """
3511
+ since_iso, until_iso = _shared_range_candidate_bounds(start, end_exclusive)
3512
+ cur = conn.execute(
3513
+ "SELECT e.id, e.timestamp_utc, e.model, e.input_tokens, "
3514
+ " e.output_tokens, e.cache_create_tokens, e.cache_read_tokens, "
3515
+ " e.cost_usd_raw, e.source_path, "
3516
+ " sf.session_id, sf.project_path, "
3517
+ " e.cache_create_1h_tokens, e.speed "
3518
+ "FROM session_entries e "
3519
+ "LEFT JOIN session_files sf ON sf.path = e.source_path "
3520
+ "WHERE e.timestamp_utc >= ? AND e.timestamp_utc <= ? "
3521
+ "ORDER BY e.timestamp_utc ASC, e.id ASC",
3522
+ (since_iso, until_iso),
3523
+ )
3524
+ for row in cur:
3525
+ ts = parse_iso_datetime(row[1], "session_entries.timestamp_utc")
3526
+ if start <= ts < end_exclusive:
3527
+ yield row
3528
+
3529
+
3530
+ def _shared_range_row_to_usage_entry(row):
3531
+ """Rebuild the ``UsageEntry`` the daily aggregator consumes.
3532
+
3533
+ Field-for-field identical to ``_cctally_cache.iter_entries``, including
3534
+ ``datetime.fromisoformat`` rather than ``parse_iso_datetime`` — the daily
3535
+ aggregate has to reproduce what the legacy daily panel would compute over
3536
+ the same rows, so this construction must not diverge from that one.
3537
+ """
3538
+ (_entry_id, ts_iso, model, input_tok, output_tok,
3539
+ cache_create, cache_read, cost_raw, source_path,
3540
+ _session_id, _project_path, cache_1h, speed) = row
3541
+ return _cctally().UsageEntry(
3542
+ timestamp=dt.datetime.fromisoformat(ts_iso),
3543
+ model=model,
3544
+ usage=claude_usage_dict( # #195 chokepoint
3545
+ input_tokens=input_tok,
3546
+ output_tokens=output_tok,
3547
+ cache_creation_tokens=cache_create,
3548
+ cache_read_tokens=cache_read,
3549
+ cache_1h_tokens=cache_1h,
3550
+ speed=speed,
3551
+ ),
3552
+ cost_usd=cost_raw,
3553
+ source_path=source_path,
3554
+ )
3555
+
3556
+
3557
+ def fold_daily_over_range(rows, *, display_tz=None, mode: str = "auto"):
3558
+ """Fold the shared candidate stream into per-day ``BucketUsage``.
3559
+
3560
+ Consumes the SAME already-materialised sequence the projects fold reads
3561
+ (spec §3.4 — one candidate read, two folds). ``_aggregate_daily`` skips
3562
+ ``<synthetic>`` rows itself, so both folds share that policy.
3563
+ """
3564
+ return _aggregate_daily(
3565
+ [_shared_range_row_to_usage_entry(row) for row in rows],
3566
+ mode=mode,
3567
+ tz=display_tz,
3568
+ )
3569
+
3570
+
3571
+ def build_daily_aggregate_rows(
3572
+ rows,
3573
+ *,
3574
+ now_utc: "dt.datetime",
3575
+ display_tz=None,
3576
+ n: int = 30,
3577
+ mode: str = "auto",
3578
+ ) -> "list[DailyPanelRow]":
3579
+ """The complete canonical thirty-day shape for the All Daily aggregate.
3580
+
3581
+ Pure apart from the caller's already-completed read. Folds the shared
3582
+ candidate stream, runs it through the same ``build_daily_view`` data plane
3583
+ the legacy panel uses, then materializes the contiguous calendar — so an
3584
+ empty Claude provider still publishes a full zero-cost shape (§6.3a).
3585
+ """
3586
+ buckets = fold_daily_over_range(rows, display_tz=display_tz, mode=mode)
3587
+ view = _cctally().build_daily_view(
3588
+ (), now_utc=now_utc, display_tz=display_tz, mode=mode,
3589
+ aggregated_override=buckets,
3590
+ )
3591
+ return materialise_daily_calendar(
3592
+ view.rows, now_utc=now_utc, n=n, display_tz=display_tz,
3593
+ )
3594
+
3595
+
3220
3596
  class _ProjWeekBucket(NamedTuple):
3221
3597
  """One (bucket_path, week) immutable aggregate for the projects-envelope
3222
3598
  per-week cache (#269 §14 Win 2).
@@ -3248,7 +3624,7 @@ def _fold_projects_entry(
3248
3624
  row: tuple,
3249
3625
  *,
3250
3626
  resolver_cache: dict,
3251
- week_start: "dt.datetime",
3627
+ week_start: "dt.datetime | None",
3252
3628
  ) -> "float | None":
3253
3629
  """Fold ONE ``_projects_iter_session_entries`` row onto ``mut`` (the shared
3254
3630
  per-row body, #271 §20 Codex-P1a).
@@ -3267,6 +3643,13 @@ def _fold_projects_entry(
3267
3643
  order-safe for the warm append because every delta row sorts strictly after
3268
3644
  ``tail`` (#271 §20), so a delta row is never the first-seen of a bucket that
3269
3645
  already exists in ``mut``.
3646
+
3647
+ ``week_start=None`` (#556 S2 §3.4) disables ONLY the week-bucket gate, for
3648
+ the range-native fold that ranks projects over one absolute interval. Every
3649
+ other caller passes a real week start and is byte-unchanged. The gate is the
3650
+ single line below; the cost, identity, session and first/last-seen
3651
+ arithmetic beneath it is what both callers share, and sharing it is what
3652
+ keeps the dashboard reconcilable with the CLI.
3270
3653
  """
3271
3654
  c = _cctally()
3272
3655
  (entry_id, ts_iso, model, input_tok, output_tok,
@@ -3275,7 +3658,7 @@ def _fold_projects_entry(
3275
3658
  if model == "<synthetic>":
3276
3659
  return None
3277
3660
  ts = parse_iso_datetime(ts_iso, "session_entries.timestamp_utc")
3278
- if _projects_week_start_monday_utc(ts) != week_start:
3661
+ if week_start is not None and _projects_week_start_monday_utc(ts) != week_start:
3279
3662
  return None
3280
3663
  entry_cost = _calculate_entry_cost(
3281
3664
  model,
@@ -3316,6 +3699,172 @@ def _fold_projects_entry(
3316
3699
  return entry_cost
3317
3700
 
3318
3701
 
3702
+ def fold_projects_over_range(rows, *, resolver_cache=None) -> "dict[str, dict]":
3703
+ """Fold an ALREADY-MATERIALISED candidate stream into per-bucket totals.
3704
+
3705
+ #556 S2 §3.4. Takes rows rather than a connection because one candidate read
3706
+ serves both All-only Claude folds: reopening or rescanning would price the
3707
+ same thirty-day population twice and extend the pinned cache snapshot's
3708
+ WAL-holding lifetime for no gain.
3709
+
3710
+ Returns the RAW ``mut`` shape ``_fold_projects_entry`` maintains —
3711
+ ``{bucket_path: {"cost_usd", "sessions" (a set), "first_seen", "last_seen",
3712
+ "first_order", "first_id", "first_key" (the ``ProjectKey``)}}``. The
3713
+ ``ProjectKey`` is retained deliberately: §3.8 disambiguates labels over
3714
+ exactly this bounded population, and the legacy builder's collision-safe
3715
+ label has already been replaced by an opaque key by the time the source
3716
+ bundle sees ``claude_data``, so there is nothing left to capture there.
3717
+
3718
+ No ``attributed_pct`` is computed. Weekly quota attribution divides by a
3719
+ subscription week's total (``_build_projects_envelope`` does it later), which
3720
+ is not meaningful for an absolute-range ranking.
3721
+ """
3722
+ mut: "dict[str, dict]" = {}
3723
+ cache = {} if resolver_cache is None else resolver_cache
3724
+ for row in rows:
3725
+ _fold_projects_entry(mut, row, resolver_cache=cache, week_start=None)
3726
+ return mut
3727
+
3728
+
3729
+ def legacy_project_labels(projects_envelope: object) -> "dict[str, str]":
3730
+ """``bucket_path`` -> legacy display key, over the two published collections.
3731
+
3732
+ These are exactly the collections ``_project_detail_for_window`` resolves a
3733
+ requested display key against (`current_week.rows`, then `trend.projects`),
3734
+ so this is the population in which agreement between the aggregate identity
3735
+ and the legacy one is both possible and useful. A bucket outside it cannot
3736
+ be served by the drill-down under either label.
3737
+
3738
+ Pure; the caller supplies the envelope the sync thread already built.
3739
+ """
3740
+ mapped: "dict[str, str]" = {}
3741
+ env = projects_envelope if isinstance(projects_envelope, Mapping) else {}
3742
+ current = env.get("current_week")
3743
+ trend = env.get("trend")
3744
+ collections = (
3745
+ (current.get("rows") if isinstance(current, Mapping) else None),
3746
+ (trend.get("projects") if isinstance(trend, Mapping) else None),
3747
+ )
3748
+ for collection in collections:
3749
+ for row in collection or ():
3750
+ if not isinstance(row, Mapping):
3751
+ continue
3752
+ bucket_path = row.get("bucket_path")
3753
+ display_key = row.get("key")
3754
+ if isinstance(bucket_path, str) and bucket_path and (
3755
+ isinstance(display_key, str) and display_key
3756
+ ):
3757
+ mapped.setdefault(bucket_path, display_key)
3758
+ return mapped
3759
+
3760
+
3761
+ def build_project_aggregate_rows(
3762
+ rows, *, resolver_cache=None, legacy_labels=None,
3763
+ ) -> "list[dict]":
3764
+ """Published `providers.claude.projects.aggregate.rows` (spec §3.5.1).
3765
+
3766
+ Folds the shared candidate stream, labels each bucket, and publishes it as
3767
+ ``{key, label, source, cost_usd, sessions_count, drillable}``.
3768
+
3769
+ **The label is the legacy display key wherever the legacy population knows
3770
+ the bucket, and the bounded disambiguated label otherwise.** The opaque key
3771
+ is minted from whichever label is published, through the same rule the
3772
+ legacy Claude project rows use (``dashboard_resource_key("project",
3773
+ "claude", <display key>)``).
3774
+
3775
+ **The published population is not the routable one, and each row says
3776
+ which it is in.** The legacy population is not a superset of the bounded
3777
+ one on either axis. It is not a superset in COST, because ``trend_projects``
3778
+ skips a bucket whose cost is 0.0 across every trend week and
3779
+ ``current_week.rows`` carries only buckets with a current-week entry. It is
3780
+ not a superset in TIME either: the envelope walks
3781
+ ``[weeks_full[0], cw_start + 7d]`` and drops any entry whose
3782
+ Monday-anchored week falls outside ``weeks_full``, so an entry after the
3783
+ current week's nominal end is invisible to it while this fold correctly
3784
+ reaches ``now``. ``tests/fixtures/dashboard/tz-override`` is that shape: one
3785
+ priced project at exactly ``now``, published in the aggregate and absent
3786
+ from both legacy collections.
3787
+
3788
+ Filtering the published rows to the legacy population would drop that real
3789
+ row rather than fix it, and widening ``_build_projects_envelope``'s window
3790
+ is out of scope here because the Claude tab and the reconcile harness both
3791
+ read it. So the gap is resolved at the SEAM instead: ``drillable`` states,
3792
+ per row, whether the drill-down can reach that bucket, computed from the
3793
+ same collections ``_claude_project_key_for_source_key`` searches. A
3794
+ ``False`` row keeps its rank, its label and its cost — the ranking stays
3795
+ complete — and the client renders it without a drill affordance rather
3796
+ than offering an interaction that 404s.
3797
+
3798
+ ``drillable`` is exactly ``bucket_path in legacy_labels``. That is the
3799
+ condition under which the published label IS a legacy display key, so the
3800
+ minted opaque key is the one the route inverts. Deliberately NOT "the
3801
+ published label happens to appear among the legacy display keys": for a
3802
+ bucket the legacy population does not carry, a bounded label that collided
3803
+ with some OTHER bucket's legacy key would resolve, and serve that other
3804
+ project's sessions under this row's identity. A mis-route is worse than a
3805
+ withheld drill, so the predicate names the bucket, not the string.
3806
+
3807
+ The split is what makes the aggregate rows ROUTABLE. Labels are
3808
+ population-sensitive by design (`bin/_cctally_project.py`), so
3809
+ disambiguating over the bounded thirty-day population and over the legacy
3810
+ twelve-week one can produce different overrides for the same project — a
3811
+ project sharing a basename with an older root forces an override in one
3812
+ population and not in the other. Two labels means two opaque keys, and the
3813
+ drill-down resolves the requested key against the LEGACY display keys
3814
+ (`_claude_project_key_for_source_key` -> `_project_detail_for_window`), so
3815
+ a bounded-only key 404s even for a project both collections know.
3816
+ Preferring the legacy key where one exists makes the two identities agree
3817
+ by construction, and it does not weaken §9.2's twin-pair requirement:
3818
+ the legacy population disambiguates that pair too, so the two rows keep
3819
+ distinct labels and distinct identities.
3820
+
3821
+ ``legacy_labels`` is ``{bucket_path: display_key}`` from
3822
+ ``legacy_project_labels``. A bucket it does not carry falls back to the
3823
+ bounded disambiguated label. ``None`` means "no legacy population was
3824
+ supplied" and is NOT a production path: production withholds the aggregate
3825
+ when no envelope was built (`_tui_build_claude_aggregates`), because
3826
+ publishing rows against an unknown routable population is the silent
3827
+ identity change this rule exists to prevent. It is kept for kernel callers
3828
+ that fold a store with no envelope beside it.
3829
+
3830
+ No ``attributed_pct``: quota attribution divides by a subscription week's
3831
+ total and means nothing over an absolute range. No ``bucket_path``, git
3832
+ root or raw source path — opaque keys stay non-reversible.
3833
+ """
3834
+ c = _cctally()
3835
+ folded = fold_projects_over_range(rows, resolver_cache=resolver_cache)
3836
+ bucket_paths_sorted = sorted(folded)
3837
+ augmented_by_idx = c._project_disambiguate_labels(
3838
+ [{"key": folded[bp]["first_key"]} for bp in bucket_paths_sorted],
3839
+ )
3840
+ legacy = legacy_labels if isinstance(legacy_labels, Mapping) else {}
3841
+ published: "list[dict]" = []
3842
+ for idx, bucket_path in enumerate(bucket_paths_sorted):
3843
+ accumulator = folded[bucket_path]
3844
+ legacy_label = legacy.get(bucket_path)
3845
+ label = legacy_label or augmented_by_idx.get(
3846
+ idx, accumulator["first_key"].display_key,
3847
+ )
3848
+ published.append({
3849
+ "key": dashboard_resource_key("project", "claude", label),
3850
+ "label": label,
3851
+ "source": "claude",
3852
+ # Whether `GET /api/source/claude/project/<key>` can resolve this
3853
+ # row. See the docstring: the predicate names the BUCKET, so a
3854
+ # bounded label that collides with an unrelated legacy key never
3855
+ # advertises a drill that would serve the wrong project.
3856
+ "drillable": legacy_label is not None,
3857
+ # NOT rounded, for the same reason `current_week.rows` is not:
3858
+ # `round(..., 6)` introduces ~1e-6 error that breaks the 1e-9
3859
+ # reconcile tolerance.
3860
+ "cost_usd": accumulator["cost_usd"],
3861
+ "sessions_count": len(accumulator["sessions"]),
3862
+ })
3863
+ # Desc by cost, ties broken by label so the ranking is byte-stable.
3864
+ published.sort(key=lambda row: (-row["cost_usd"], row["label"]))
3865
+ return published
3866
+
3867
+
3319
3868
  def _aggregate_projects_week_raw(
3320
3869
  conn: "sqlite3.Connection",
3321
3870
  *,