cctally 1.6.3 → 1.7.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.
@@ -171,6 +171,106 @@ _PROJECT_COLUMNS = (
171
171
  )
172
172
 
173
173
 
174
+ # --- Cross-tab Detail-template helpers (issue #33, spec §6.1) ---
175
+ _CROSS_TAB_OTHER_KEY = "_other"
176
+
177
+
178
+ def _aggregate_breakdowns(
179
+ breakdowns: list[dict[str, float]],
180
+ ) -> list[tuple[str, float]]:
181
+ """Aggregate per-row breakdowns into window-wide totals.
182
+
183
+ Returns a list of (label, total) tuples sorted by total desc, ties
184
+ broken lex ascending. Deterministic for goldens.
185
+ """
186
+ totals: dict[str, float] = {}
187
+ for br in breakdowns:
188
+ for k, v in br.items():
189
+ totals[k] = totals.get(k, 0.0) + float(v or 0.0)
190
+ return sorted(totals.items(), key=lambda p: (-p[1], p[0]))
191
+
192
+
193
+ def _cross_tab_columns(
194
+ row_label_col: "_LS.ColumnSpec",
195
+ members: list[tuple[str, float]],
196
+ top_n: int,
197
+ has_other_residual: bool,
198
+ *,
199
+ kind: str, # "project" | "model"
200
+ ) -> tuple[tuple, tuple[str, ...], bool]:
201
+ """Build (columns, top_k_labels, has_other) for a cross-tab table.
202
+
203
+ Column keys are stable synthetic identifiers (m_0..m_K, _other) so
204
+ project paths with awkward characters or post-scrub labels never
205
+ affect renderer `row.cells.get(col.key)` lookups.
206
+
207
+ `has_other` is True iff either:
208
+ - len(members) > top_n (overflow case), OR
209
+ - has_other_residual is True (partial-coverage case).
210
+
211
+ Caller computes `has_other_residual` via `_detect_residual` over the
212
+ provisional top-K labels. Spec §4.2.
213
+ """
214
+ top = members[:top_n]
215
+ has_other_cap = len(members) > top_n
216
+ has_other = has_other_cap or has_other_residual
217
+ cols: list = [
218
+ row_label_col,
219
+ _LS.ColumnSpec(key="total", label="$", align="right", emphasis=True),
220
+ ]
221
+ for i, (lbl, _total) in enumerate(top):
222
+ cols.append(_LS.ColumnSpec(
223
+ key=f"m_{i}", label=lbl, align="right", kind=kind,
224
+ ))
225
+ if has_other:
226
+ # kind=None: "Other" rollup is never a project name; scrubber skips.
227
+ cols.append(_LS.ColumnSpec(
228
+ key=_CROSS_TAB_OTHER_KEY, label="Other", align="right",
229
+ ))
230
+ return tuple(cols), tuple(t[0] for t in top), has_other
231
+
232
+
233
+ def _cross_tab_row(
234
+ *,
235
+ row_label_key: str,
236
+ row_label_cell,
237
+ row_total: float,
238
+ breakdown: dict[str, float],
239
+ top_k_labels: tuple[str, ...],
240
+ has_other: bool,
241
+ ):
242
+ """One cross-tab row. Other = clamp(row_total - SUM(top_k cells), 0)."""
243
+ cells: dict = {
244
+ row_label_key: row_label_cell,
245
+ "total": _LS.MoneyCell(usd=row_total),
246
+ }
247
+ other_sum = row_total
248
+ for i, lbl in enumerate(top_k_labels):
249
+ v = float(breakdown.get(lbl, 0.0))
250
+ cells[f"m_{i}"] = _LS.MoneyCell(usd=v)
251
+ other_sum -= v
252
+ if has_other:
253
+ cells[_CROSS_TAB_OTHER_KEY] = _LS.MoneyCell(usd=max(0.0, other_sum))
254
+ return _LS.Row(cells=cells)
255
+
256
+
257
+ def _detect_residual(
258
+ rows_and_breakdowns: list[tuple[float, dict[str, float]]],
259
+ top_k_labels: tuple[str, ...],
260
+ *,
261
+ epsilon: float = 1e-9,
262
+ ) -> bool:
263
+ """Return True if any row's residual (row_total - SUM(top-K cells))
264
+ exceeds epsilon. Short-circuits at the first violating row. Drives
265
+ `has_other_residual` in `_cross_tab_columns`.
266
+ """
267
+ for row_total, breakdown in rows_and_breakdowns:
268
+ top_k_sum = sum(float(breakdown.get(lbl, 0.0)) for lbl in top_k_labels)
269
+ if abs(row_total - top_k_sum) > epsilon:
270
+ return True
271
+ return False
272
+
273
+
174
274
  def _utc_now() -> _dt.datetime:
175
275
  """Override-aware UTC now (per `CCTALLY_AS_OF` env hook for fixture tests)."""
176
276
  s = os.environ.get("CCTALLY_AS_OF")
@@ -727,29 +827,51 @@ def _build_weekly_visual(*, panel_data, options):
727
827
 
728
828
 
729
829
  def _build_weekly_detail(*, panel_data, options):
730
- """Weekly detail — full per-week × per-project table (spec §9.5).
731
-
732
- Same panel_data shape as `_build_weekly_recap`; Detail uses
733
- `top_n=50` (or higher) and includes all projects as table rows alongside
734
- the chart.
830
+ """Weekly detail — per-week × per-model cross-tab (spec §9.5).
735
831
 
736
- NOTE: ships as per-project table; spec §9.5 calls for per-week × per-model
737
- cross-tab deferred until `_build_weekly_share_panel_data` carries the
738
- cross-tab series (see issue #33).
832
+ `panel_data["weeks"][i].models: dict[model_name, cost_usd]` carries
833
+ each week's per-model breakdown. Window-wide top-K + `Other` rollup
834
+ is computed at render time via `_aggregate_breakdowns` (spec §4.2).
739
835
  """
740
836
  weeks = panel_data["weeks"]
741
837
  idx = panel_data.get("current_week_index", 0)
742
838
  w = weeks[idx]
743
839
  start = _parse_iso_utc(w["start_date"])
744
840
  end = start + _dt.timedelta(days=6)
745
- top_n = max(int(options.get("top_n", 50)), 1)
841
+ top_n = max(int(options.get("top_n", 5)), 1)
842
+
843
+ breakdowns = [dict(week.get("models") or {}) for week in weeks]
844
+ members = _aggregate_breakdowns(breakdowns)
845
+ top_k_labels_provisional = tuple(m[0] for m in members[:top_n])
846
+ rows_and_breakdowns = [
847
+ (float(week["cost_usd"]), dict(week.get("models") or {}))
848
+ for week in weeks
849
+ ]
850
+ has_other_residual = _detect_residual(
851
+ rows_and_breakdowns, top_k_labels_provisional,
852
+ )
853
+ columns, top_k, has_other = _cross_tab_columns(
854
+ _LS.ColumnSpec(key="week", label="Week", align="left"),
855
+ members, top_n, has_other_residual, kind="model",
856
+ )
857
+ rows = tuple(
858
+ _cross_tab_row(
859
+ row_label_key="week",
860
+ row_label_cell=_LS.TextCell(week["start_date"]),
861
+ row_total=float(week["cost_usd"]),
862
+ breakdown=dict(week.get("models") or {}),
863
+ top_k_labels=top_k,
864
+ has_other=has_other,
865
+ )
866
+ for week in weeks
867
+ )
746
868
  return _LS.ShareSnapshot(
747
869
  cmd="weekly",
748
870
  title=f"Weekly detail — week of {w['start_date']}",
749
871
  subtitle=None,
750
872
  period=_period(start, end, label="This week", display_tz=_display_tz(options)),
751
- columns=_PROJECT_COLUMNS,
752
- rows=_top_projects_rows(w.get("top_projects") or [], top_n),
873
+ columns=columns,
874
+ rows=rows,
753
875
  chart=_LS.LineChart(
754
876
  points=tuple(
755
877
  _LS.ChartPoint(x_label=w2["start_date"], x_value=float(i),
@@ -949,25 +1071,50 @@ def _build_daily_visual(*, panel_data, options):
949
1071
 
950
1072
 
951
1073
  def _build_daily_detail(*, panel_data, options):
952
- """Daily detail — per-day × per-project full table (spec §9.5).
1074
+ """Daily detail — per-day × per-project cross-tab (spec §9.5).
953
1075
 
954
- NOTE: ships as per-project table; spec §9.5 calls for per-day × per-project
955
- cross-tab deferred until `_build_daily_share_panel_data` carries
956
- per-day per-project cells (see issue #33).
1076
+ `panel_data["days"][i].projects: dict[project_path, cost_usd]`
1077
+ carries each day's per-project breakdown.
957
1078
  """
958
1079
  days = panel_data.get("days") or []
959
1080
  start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
960
1081
  end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
961
1082
  end = end_anchor + _dt.timedelta(days=1)
962
1083
  sum_cost = sum(float(d["cost_usd"]) for d in days)
963
- top_n = max(int(options.get("top_n", 50)), 1)
1084
+ top_n = max(int(options.get("top_n", 5)), 1)
1085
+
1086
+ breakdowns = [dict(d.get("projects") or {}) for d in days]
1087
+ members = _aggregate_breakdowns(breakdowns)
1088
+ top_k_labels_provisional = tuple(m[0] for m in members[:top_n])
1089
+ rows_and_breakdowns = [
1090
+ (float(d["cost_usd"]), dict(d.get("projects") or {}))
1091
+ for d in days
1092
+ ]
1093
+ has_other_residual = _detect_residual(
1094
+ rows_and_breakdowns, top_k_labels_provisional,
1095
+ )
1096
+ columns, top_k, has_other = _cross_tab_columns(
1097
+ _LS.ColumnSpec(key="date", label="Day", align="left"),
1098
+ members, top_n, has_other_residual, kind="project",
1099
+ )
1100
+ rows = tuple(
1101
+ _cross_tab_row(
1102
+ row_label_key="date",
1103
+ row_label_cell=_LS.TextCell(d["date"]),
1104
+ row_total=float(d["cost_usd"]),
1105
+ breakdown=dict(d.get("projects") or {}),
1106
+ top_k_labels=top_k,
1107
+ has_other=has_other,
1108
+ )
1109
+ for d in days
1110
+ )
964
1111
  return _LS.ShareSnapshot(
965
1112
  cmd="daily",
966
1113
  title=f"Daily detail — last {len(days)} day{'s' if len(days) != 1 else ''}",
967
1114
  subtitle=None,
968
1115
  period=_period(start, end, label="Last 7 days", display_tz=_display_tz(options)),
969
- columns=_PROJECT_COLUMNS,
970
- rows=_top_projects_rows(panel_data.get("top_projects") or [], top_n),
1116
+ columns=columns,
1117
+ rows=rows,
971
1118
  chart=_LS.BarChart(
972
1119
  points=tuple(
973
1120
  _LS.ChartPoint(x_label=d["date"], x_value=float(i),
@@ -1028,12 +1175,7 @@ def _build_monthly_visual(*, panel_data, options):
1028
1175
 
1029
1176
 
1030
1177
  def _build_monthly_detail(*, panel_data, options):
1031
- """Monthly detail — per-month × per-project full table (spec §9.5).
1032
-
1033
- NOTE: ships as per-project table; spec §9.5 calls for per-month × per-project
1034
- cross-tab — deferred until `_build_monthly_share_panel_data` carries
1035
- per-month per-project cells (see issue #33).
1036
- """
1178
+ """Monthly detail — per-month × per-model cross-tab (spec §9.5)."""
1037
1179
  months = panel_data.get("months") or []
1038
1180
 
1039
1181
  def _month_start(s):
@@ -1047,15 +1189,41 @@ def _build_monthly_detail(*, panel_data, options):
1047
1189
  else:
1048
1190
  end = start
1049
1191
  sum_cost = sum(float(m["cost_usd"]) for m in months)
1050
- top_n = max(int(options.get("top_n", 50)), 1)
1192
+ top_n = max(int(options.get("top_n", 5)), 1)
1193
+
1194
+ breakdowns = [dict(m.get("models") or {}) for m in months]
1195
+ members = _aggregate_breakdowns(breakdowns)
1196
+ top_k_labels_provisional = tuple(m[0] for m in members[:top_n])
1197
+ rows_and_breakdowns = [
1198
+ (float(m["cost_usd"]), dict(m.get("models") or {}))
1199
+ for m in months
1200
+ ]
1201
+ has_other_residual = _detect_residual(
1202
+ rows_and_breakdowns, top_k_labels_provisional,
1203
+ )
1204
+ columns, top_k, has_other = _cross_tab_columns(
1205
+ _LS.ColumnSpec(key="month", label="Month", align="left"),
1206
+ members, top_n, has_other_residual, kind="model",
1207
+ )
1208
+ rows = tuple(
1209
+ _cross_tab_row(
1210
+ row_label_key="month",
1211
+ row_label_cell=_LS.TextCell(m["month"]),
1212
+ row_total=float(m["cost_usd"]),
1213
+ breakdown=dict(m.get("models") or {}),
1214
+ top_k_labels=top_k,
1215
+ has_other=has_other,
1216
+ )
1217
+ for m in months
1218
+ )
1051
1219
  return _LS.ShareSnapshot(
1052
1220
  cmd="monthly",
1053
1221
  title=f"Monthly detail — last {len(months)} month{'s' if len(months) != 1 else ''}",
1054
1222
  subtitle=None,
1055
1223
  period=_period(start, end, label="Recent months",
1056
1224
  display_tz=_display_tz(options)),
1057
- columns=_PROJECT_COLUMNS,
1058
- rows=_top_projects_rows(panel_data.get("top_projects") or [], top_n),
1225
+ columns=columns,
1226
+ rows=rows,
1059
1227
  chart=_LS.BarChart(
1060
1228
  points=tuple(
1061
1229
  _LS.ChartPoint(x_label=m["month"], x_value=float(i),
@@ -1108,25 +1276,46 @@ def _build_blocks_visual(*, panel_data, options):
1108
1276
 
1109
1277
 
1110
1278
  def _build_blocks_detail(*, panel_data, options):
1111
- """Blocks detail — full per-project rows + recent-blocks chart (spec §9.5).
1112
-
1113
- NOTE: ships as per-project table; spec §9.5 calls for per-block × per-project
1114
- cross-tab — deferred until `_build_blocks_share_panel_data` carries
1115
- per-block per-project cells (see issue #33).
1116
- """
1279
+ """Blocks detail — per-block × per-project cross-tab (spec §9.5)."""
1117
1280
  cb = panel_data.get("current_block") or {}
1118
1281
  recent = panel_data.get("recent_blocks") or []
1119
1282
  start = _parse_iso_utc(cb["start_at"]) if cb.get("start_at") else _utc_now()
1120
1283
  end = _parse_iso_utc(cb["end_at"]) if cb.get("end_at") else start + _dt.timedelta(hours=5)
1121
- top_n = max(int(options.get("top_n", 50)), 1)
1284
+ top_n = max(int(options.get("top_n", 5)), 1)
1285
+
1286
+ breakdowns = [dict(b.get("projects") or {}) for b in recent]
1287
+ members = _aggregate_breakdowns(breakdowns)
1288
+ top_k_labels_provisional = tuple(m[0] for m in members[:top_n])
1289
+ rows_and_breakdowns = [
1290
+ (float(b["cost_usd"]), dict(b.get("projects") or {}))
1291
+ for b in recent
1292
+ ]
1293
+ has_other_residual = _detect_residual(
1294
+ rows_and_breakdowns, top_k_labels_provisional,
1295
+ )
1296
+ columns, top_k, has_other = _cross_tab_columns(
1297
+ _LS.ColumnSpec(key="block", label="Block (start)", align="left"),
1298
+ members, top_n, has_other_residual, kind="project",
1299
+ )
1300
+ rows = tuple(
1301
+ _cross_tab_row(
1302
+ row_label_key="block",
1303
+ row_label_cell=_LS.TextCell(b["start_at"]),
1304
+ row_total=float(b["cost_usd"]),
1305
+ breakdown=dict(b.get("projects") or {}),
1306
+ top_k_labels=top_k,
1307
+ has_other=has_other,
1308
+ )
1309
+ for b in recent
1310
+ )
1122
1311
  return _LS.ShareSnapshot(
1123
1312
  cmd="five-hour-blocks",
1124
1313
  title="Current 5-hour block — detail",
1125
1314
  subtitle=None,
1126
1315
  period=_period(start, end, label="Current block",
1127
1316
  display_tz=_display_tz(options)),
1128
- columns=_PROJECT_COLUMNS,
1129
- rows=_top_projects_rows(panel_data.get("top_projects") or [], top_n),
1317
+ columns=columns,
1318
+ rows=rows,
1130
1319
  chart=_LS.LineChart(
1131
1320
  points=tuple(
1132
1321
  _LS.ChartPoint(x_label=b["start_at"], x_value=float(i),
@@ -1443,8 +1632,8 @@ _VISUAL = (
1443
1632
 
1444
1633
  _DETAIL = (
1445
1634
  ShareTemplate(id="weekly-detail", panel="weekly", label="Detail",
1446
- description="Per-week × per-project full table",
1447
- default_options={"top_n": 50, "show_chart": True, "show_table": True},
1635
+ description="Per-week × per-model cross-tab",
1636
+ default_options={"top_n": 5, "show_chart": True, "show_table": True},
1448
1637
  builder=_build_weekly_detail),
1449
1638
  ShareTemplate(id="current-week-detail", panel="current-week", label="Detail",
1450
1639
  description="Per-project table + sidebar chart",
@@ -1455,16 +1644,16 @@ _DETAIL = (
1455
1644
  default_options={"top_n": 50, "show_chart": True, "show_table": True},
1456
1645
  builder=_build_trend_detail),
1457
1646
  ShareTemplate(id="daily-detail", panel="daily", label="Detail",
1458
- description="Per-day × per-project full table",
1459
- default_options={"top_n": 50, "show_chart": True, "show_table": True},
1647
+ description="Per-day × per-project cross-tab",
1648
+ default_options={"top_n": 5, "show_chart": True, "show_table": True},
1460
1649
  builder=_build_daily_detail),
1461
1650
  ShareTemplate(id="monthly-detail", panel="monthly", label="Detail",
1462
- description="Per-month × per-project full table",
1463
- default_options={"top_n": 50, "show_chart": True, "show_table": True},
1651
+ description="Per-month × per-model cross-tab",
1652
+ default_options={"top_n": 5, "show_chart": True, "show_table": True},
1464
1653
  builder=_build_monthly_detail),
1465
1654
  ShareTemplate(id="blocks-detail", panel="blocks", label="Detail",
1466
- description="Per-block × per-project rows",
1467
- default_options={"top_n": 50, "show_chart": True, "show_table": True},
1655
+ description="Per-block × per-project cross-tab",
1656
+ default_options={"top_n": 5, "show_chart": True, "show_table": True},
1468
1657
  builder=_build_blocks_detail),
1469
1658
  ShareTemplate(id="forecast-detail", panel="forecast", label="Detail",
1470
1659
  description="Per-day forecast table with $/% budget",