cctally 1.93.0 → 1.94.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.
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  import os
20
20
  from collections.abc import Callable, Mapping
21
+ from zoneinfo import ZoneInfo as _ZoneInfo
21
22
  from dataclasses import dataclass
22
23
  from typing import Any
23
24
 
@@ -37,9 +38,13 @@ from typing import Any
37
38
  # --- Panel set ---
38
39
  #
39
40
  # Share-capable panels are the 9 data-view panels in the dashboard.
40
- # RecentAlertsPanel ('alerts') is intentionally excluded: it's a
41
- # notification stream, not a data view — shipping share templates over
42
- # alerts would conflate the two concepts (spec §6.1, §9.5).
41
+ # TWO panels are excluded. RecentAlertsPanel ('alerts') is a notification
42
+ # stream, not a data view — shipping share templates over alerts would
43
+ # conflate the two concepts (spec §6.1, §9.5). The Cache Report panel
44
+ # ('cache-report') is a data view, but no share kernel was ever written
45
+ # for it, so it has no template here and no share icon in the dashboard.
46
+ # The client mirror of this set is `SHARE_PANEL_MATRIX` in
47
+ # `dashboard/web/src/share/types.ts`.
43
48
  SHARE_CAPABLE_PANELS: frozenset[str] = frozenset({
44
49
  "current-week",
45
50
  "trend",
@@ -381,12 +386,105 @@ def _parse_iso_utc(s: str) -> _dt.datetime:
381
386
  return parsed.astimezone(_dt.timezone.utc)
382
387
 
383
388
 
384
- def _period(start, end, *, label: str, display_tz: str):
389
+ def _period(start, end, *, label: str, display_tz: str,
390
+ civil_bucket: bool = False):
391
+ """Build a `PeriodSpec`, declaring which KIND of boundary it carries.
392
+
393
+ Two kinds reach this function and they must not be treated alike
394
+ (#503 S2 D7):
395
+
396
+ - CIVIL BUCKET (`civil_bucket=True`) — the boundary began life as a
397
+ calendar label (`weeks[i]["start_date"]`, `days[i]["date"]`,
398
+ `months[i]["month"]`, `projection_curve[i]["date"]`) that
399
+ `_parse_iso_utc` lifted to a UTC-midnight sentinel. `2026-05-02`
400
+ names the same day in every zone, so converting the sentinel into
401
+ `America/New_York` would report `2026-05-01`.
402
+ - INSTANT (the default) — the boundary is a real moment in time (a
403
+ 5-hour block's `start_at`, a session's `started_at`, the Projects
404
+ panel's window bounds). Its civil date is only meaningful once
405
+ converted into the labelled zone.
406
+
407
+ Callers pass the kind explicitly; `period_civil_dates` reads it.
408
+ """
385
409
  return _LS.PeriodSpec(start=start, end=end,
386
- display_tz=display_tz, label=label)
410
+ display_tz=display_tz, label=label,
411
+ civil_bucket=civil_bucket)
412
+
413
+
414
+ def _civil_period(start, end, *, label: str, display_tz: str):
415
+ """`_period` for a builder whose boundaries are calendar labels."""
416
+ return _period(start, end, label=label, display_tz=display_tz,
417
+ civil_bucket=True)
418
+
419
+
420
+ def _weekly_period_bounds(panel_data) -> tuple:
421
+ """The focal week — what the weekly panel's artifact is ABOUT.
422
+
423
+ The previous pass widened this to the eight weeks the CHART covers,
424
+ because `weekly-detail` and the two chart-bearing templates displayed
425
+ history the header did not admit to. That was the right correction
426
+ made in the wrong place: Markdown renders no chart, so all three
427
+ `weekly-*` Markdown artifacts then claimed eight weeks above one
428
+ week's spend, and `weekly-recap` stated `2026-03-16 → 2026-05-10`
429
+ above `**$ spent:** $14.27` (#503 S2 third review).
430
+
431
+ The builder declares the focal week again, and `effective_period`
432
+ completes it per FORMAT from what that format actually draws. The
433
+ title names the focal week either way.
434
+ """
435
+ weeks = panel_data["weeks"]
436
+ focal = weeks[panel_data.get("current_week_index", 0)]
437
+ start = _parse_iso_utc(focal["start_date"])
438
+ return start, start + _dt.timedelta(days=6)
439
+
440
+
441
+ def _blocks_period_bounds(panel_data) -> tuple:
442
+ """The current 5-hour block — what the blocks panel is ABOUT.
443
+
444
+ Same story as `_weekly_period_bounds`, and reverted for the same
445
+ reason: widening this to the recent blocks the chart draws made
446
+ `blocks-recap` state a thirty-four-hour period above
447
+ `$ this block: $1.84` in a format that draws no chart at all.
448
+ `effective_period` completes the span per format instead.
449
+ """
450
+ current = panel_data.get("current_block") or {}
451
+ start = (_parse_iso_utc(current["start_at"])
452
+ if current.get("start_at") else _utc_now())
453
+ end = (_parse_iso_utc(current["end_at"])
454
+ if current.get("end_at") else start + _dt.timedelta(hours=5))
455
+ return start, end
456
+
457
+
458
+ def _civil_now_bucket(options) -> _dt.datetime:
459
+ """Today's calendar date in the labelled zone, as a bucket sentinel.
460
+
461
+ The EMPTY-panel fallback for a civil-bucket builder. `_utc_now()`
462
+ returns a real instant, and marking one `civil_bucket=True` tells
463
+ `period_civil_dates` to skip the conversion — so an empty artifact
464
+ stated the UTC calendar day under another zone's name, which is the
465
+ same defect D7 exists to prevent (#503 S2 review F9). Cosmetic, since
466
+ the artifact has no rows, but wrong.
467
+
468
+ An unloadable zone name degrades to the UTC day rather than raising:
469
+ a renderer that raises is a refused artifact, not a wrong date.
470
+ """
471
+ try:
472
+ zone = _ZoneInfo(_display_tz(options))
473
+ except Exception: # noqa: BLE001 — see docstring
474
+ return _dt.datetime.combine(_utc_now().date(), _dt.time.min,
475
+ tzinfo=_dt.timezone.utc)
476
+ return _dt.datetime.combine(_utc_now().astimezone(zone).date(),
477
+ _dt.time.min, tzinfo=_dt.timezone.utc)
387
478
 
388
479
 
389
480
  def _display_tz(options) -> str:
481
+ """The concrete IANA zone the artifact's dates are stated in.
482
+
483
+ The dashboard resolves the `local` / `utc` configuration tokens before
484
+ injecting `display_tz` into `options` (#503 S2 D7), so this reads a
485
+ zone name, never a token. The `Etc/UTC` default covers the fixture and
486
+ direct-builder paths that supply no option at all.
487
+ """
390
488
  return options.get("display_tz", "Etc/UTC")
391
489
 
392
490
 
@@ -412,13 +510,13 @@ def _build_weekly_recap(*, panel_data, options):
412
510
  weeks = panel_data["weeks"]
413
511
  idx = panel_data.get("current_week_index", 0)
414
512
  w = weeks[idx]
415
- start = _parse_iso_utc(w["start_date"])
416
- end = start + _dt.timedelta(days=6)
513
+ start, end = _weekly_period_bounds(panel_data)
417
514
  return _LS.ShareSnapshot(
418
515
  cmd="weekly",
419
516
  title=f"Weekly recap — week of {w['start_date']}",
420
517
  subtitle=None,
421
- period=_period(start, end, label="This week", display_tz=_display_tz(options)),
518
+ period=_civil_period(start, end, label="This week",
519
+ display_tz=_display_tz(options)),
422
520
  columns=_PROJECT_COLUMNS,
423
521
  rows=_top_projects_rows(w.get("top_projects") or [], options.get("top_n", 5)),
424
522
  chart=_LS.LineChart(
@@ -468,7 +566,8 @@ def _build_current_week_recap(*, panel_data, options):
468
566
  cmd="current-week",
469
567
  title=f"Current week — through {today_label}",
470
568
  subtitle=None,
471
- period=_period(start, end, label="This week", display_tz=_display_tz(options)),
569
+ period=_civil_period(start, end, label="This week",
570
+ display_tz=_display_tz(options)),
472
571
  columns=_PROJECT_COLUMNS,
473
572
  rows=_top_projects_rows(panel_data.get("top_projects") or [],
474
573
  options.get("top_n", 3)),
@@ -513,15 +612,16 @@ def _build_trend_recap(*, panel_data, options):
513
612
  }
514
613
  """
515
614
  weeks = panel_data["weeks"]
516
- start = _parse_iso_utc(weeks[0]["start_date"]) if weeks else _utc_now()
517
- end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else _utc_now()
615
+ start = _parse_iso_utc(weeks[0]["start_date"]) if weeks else _civil_now_bucket(options)
616
+ end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else _civil_now_bucket(options)
518
617
  end = end_anchor + _dt.timedelta(days=6)
519
618
  delta = panel_data.get("delta_3_weeks") or {}
520
619
  return _LS.ShareSnapshot(
521
620
  cmd="report",
522
621
  title="$/% trend — last 8 weeks",
523
622
  subtitle=None,
524
- period=_period(start, end, label="Last 8 weeks", display_tz=_display_tz(options)),
623
+ period=_civil_period(start, end, label="Last 8 weeks",
624
+ display_tz=_display_tz(options)),
525
625
  columns=(
526
626
  _LS.ColumnSpec(key="week", label="Week", align="left"),
527
627
  _LS.ColumnSpec(key="cost", label="$", align="right", emphasis=True),
@@ -571,7 +671,7 @@ def _build_daily_recap(*, panel_data, options):
571
671
  }
572
672
  """
573
673
  days = panel_data.get("days") or []
574
- start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
674
+ start = _parse_iso_utc(days[0]["date"]) if days else _civil_now_bucket(options)
575
675
  end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
576
676
  end = end_anchor + _dt.timedelta(days=1)
577
677
  sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
@@ -579,7 +679,8 @@ def _build_daily_recap(*, panel_data, options):
579
679
  cmd="daily",
580
680
  title=f"Daily — last {len(days)} day{'s' if len(days) != 1 else ''}",
581
681
  subtitle=None,
582
- period=_period(start, end, label="Last 7 days", display_tz=_display_tz(options)),
682
+ period=_civil_period(start, end, label="Last 7 days",
683
+ display_tz=_display_tz(options)),
583
684
  columns=_PROJECT_COLUMNS,
584
685
  rows=_top_projects_rows(panel_data.get("top_projects") or [],
585
686
  options.get("top_n", 5)),
@@ -620,7 +721,7 @@ def _build_monthly_recap(*, panel_data, options):
620
721
  def _month_start(s):
621
722
  return _parse_iso_utc(f"{s}-01")
622
723
 
623
- start = _month_start(months[0]["month"]) if months else _utc_now()
724
+ start = _month_start(months[0]["month"]) if months else _civil_now_bucket(options)
624
725
  if months:
625
726
  last = _month_start(months[-1]["month"])
626
727
  # End of last month: simple 31-day forward, then truncate to month end.
@@ -633,8 +734,8 @@ def _build_monthly_recap(*, panel_data, options):
633
734
  cmd="monthly",
634
735
  title=f"Monthly — last {len(months)} month{'s' if len(months) != 1 else ''}",
635
736
  subtitle=None,
636
- period=_period(start, end, label="Recent months",
637
- display_tz=_display_tz(options)),
737
+ period=_civil_period(start, end, label="Recent months",
738
+ display_tz=_display_tz(options)),
638
739
  columns=_PROJECT_COLUMNS,
639
740
  rows=_top_projects_rows(panel_data.get("top_projects") or [],
640
741
  options.get("top_n", 5)),
@@ -674,8 +775,7 @@ def _build_blocks_recap(*, panel_data, options):
674
775
  """
675
776
  cb = panel_data.get("current_block") or {}
676
777
  recent = panel_data.get("recent_blocks") or []
677
- start = _parse_iso_utc(cb["start_at"]) if cb.get("start_at") else _utc_now()
678
- end = _parse_iso_utc(cb["end_at"]) if cb.get("end_at") else start + _dt.timedelta(hours=5)
778
+ start, end = _blocks_period_bounds(panel_data)
679
779
  return _LS.ShareSnapshot(
680
780
  cmd="five-hour-blocks",
681
781
  title="Current 5-hour block",
@@ -729,7 +829,7 @@ def _build_forecast_recap(*, panel_data, options):
729
829
  """
730
830
  curve = panel_data.get("projection_curve") or []
731
831
  budgets = panel_data.get("daily_budgets") or {}
732
- start = _parse_iso_utc(curve[0]["date"]) if curve else _utc_now()
832
+ start = _parse_iso_utc(curve[0]["date"]) if curve else _civil_now_bucket(options)
733
833
  end_anchor = _parse_iso_utc(curve[-1]["date"]) if curve else start
734
834
  end = end_anchor + _dt.timedelta(days=1)
735
835
  confidence = panel_data.get("confidence") or "ok"
@@ -738,8 +838,8 @@ def _build_forecast_recap(*, panel_data, options):
738
838
  cmd="forecast",
739
839
  title="Forecast — projection to ceiling",
740
840
  subtitle=None,
741
- period=_period(start, end, label="Next 7 days",
742
- display_tz=_display_tz(options)),
841
+ period=_civil_period(start, end, label="Next 7 days",
842
+ display_tz=_display_tz(options)),
743
843
  columns=(
744
844
  _LS.ColumnSpec(key="metric", label="Metric", align="left"),
745
845
  _LS.ColumnSpec(key="value", label="$/day", align="right", emphasis=True),
@@ -858,13 +958,13 @@ def _build_weekly_visual(*, panel_data, options):
858
958
  weeks = panel_data["weeks"]
859
959
  idx = panel_data.get("current_week_index", 0)
860
960
  w = weeks[idx]
861
- start = _parse_iso_utc(w["start_date"])
862
- end = start + _dt.timedelta(days=6)
961
+ start, end = _weekly_period_bounds(panel_data)
863
962
  return _LS.ShareSnapshot(
864
963
  cmd="weekly",
865
964
  title=f"Weekly visual — week of {w['start_date']}",
866
965
  subtitle=None,
867
- period=_period(start, end, label="This week", display_tz=_display_tz(options)),
966
+ period=_civil_period(start, end, label="This week",
967
+ display_tz=_display_tz(options)),
868
968
  columns=(),
869
969
  rows=(),
870
970
  chart=_LS.LineChart(
@@ -896,8 +996,7 @@ def _build_weekly_detail(*, panel_data, options):
896
996
  weeks = panel_data["weeks"]
897
997
  idx = panel_data.get("current_week_index", 0)
898
998
  w = weeks[idx]
899
- start = _parse_iso_utc(w["start_date"])
900
- end = start + _dt.timedelta(days=6)
999
+ start, end = _weekly_period_bounds(panel_data)
901
1000
  top_n = max(int(options.get("top_n", 5)), 1)
902
1001
 
903
1002
  breakdowns = [dict(week.get("models") or {}) for week in weeks]
@@ -929,7 +1028,8 @@ def _build_weekly_detail(*, panel_data, options):
929
1028
  cmd="weekly",
930
1029
  title=f"Weekly detail — week of {w['start_date']}",
931
1030
  subtitle=None,
932
- period=_period(start, end, label="This week", display_tz=_display_tz(options)),
1031
+ period=_civil_period(start, end, label="This week",
1032
+ display_tz=_display_tz(options)),
933
1033
  columns=columns,
934
1034
  rows=rows,
935
1035
  chart=_LS.LineChart(
@@ -962,7 +1062,8 @@ def _build_current_week_visual(*, panel_data, options):
962
1062
  cmd="current-week",
963
1063
  title=f"Current week visual — through {today_label}",
964
1064
  subtitle=None,
965
- period=_period(start, end, label="This week", display_tz=_display_tz(options)),
1065
+ period=_civil_period(start, end, label="This week",
1066
+ display_tz=_display_tz(options)),
966
1067
  columns=(),
967
1068
  rows=(),
968
1069
  chart=_LS.LineChart(
@@ -995,7 +1096,8 @@ def _build_current_week_detail(*, panel_data, options):
995
1096
  cmd="current-week",
996
1097
  title=f"Current week detail — through {today_label}",
997
1098
  subtitle=None,
998
- period=_period(start, end, label="This week", display_tz=_display_tz(options)),
1099
+ period=_civil_period(start, end, label="This week",
1100
+ display_tz=_display_tz(options)),
999
1101
  columns=_PROJECT_COLUMNS,
1000
1102
  rows=_top_projects_rows(panel_data.get("top_projects") or [], top_n),
1001
1103
  chart=_LS.LineChart(
@@ -1022,15 +1124,16 @@ def _build_current_week_detail(*, panel_data, options):
1022
1124
  def _build_trend_visual(*, panel_data, options):
1023
1125
  """Trend visual — $/% trend line over 8 weeks; rows=() (spec §9.5)."""
1024
1126
  weeks = panel_data["weeks"]
1025
- start = _parse_iso_utc(weeks[0]["start_date"]) if weeks else _utc_now()
1026
- end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else _utc_now()
1127
+ start = _parse_iso_utc(weeks[0]["start_date"]) if weeks else _civil_now_bucket(options)
1128
+ end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else _civil_now_bucket(options)
1027
1129
  end = end_anchor + _dt.timedelta(days=6)
1028
1130
  delta = panel_data.get("delta_3_weeks") or {}
1029
1131
  return _LS.ShareSnapshot(
1030
1132
  cmd="report",
1031
1133
  title="$/% trend visual — last 8 weeks",
1032
1134
  subtitle=None,
1033
- period=_period(start, end, label="Last 8 weeks", display_tz=_display_tz(options)),
1135
+ period=_civil_period(start, end, label="Last 8 weeks",
1136
+ display_tz=_display_tz(options)),
1034
1137
  columns=(),
1035
1138
  rows=(),
1036
1139
  chart=_LS.LineChart(
@@ -1055,15 +1158,16 @@ def _build_trend_visual(*, panel_data, options):
1055
1158
  def _build_trend_detail(*, panel_data, options):
1056
1159
  """Trend detail — full 8-week × $/%/rate table + sparkline (spec §9.5)."""
1057
1160
  weeks = panel_data["weeks"]
1058
- start = _parse_iso_utc(weeks[0]["start_date"]) if weeks else _utc_now()
1059
- end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else _utc_now()
1161
+ start = _parse_iso_utc(weeks[0]["start_date"]) if weeks else _civil_now_bucket(options)
1162
+ end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else _civil_now_bucket(options)
1060
1163
  end = end_anchor + _dt.timedelta(days=6)
1061
1164
  delta = panel_data.get("delta_3_weeks") or {}
1062
1165
  return _LS.ShareSnapshot(
1063
1166
  cmd="report",
1064
1167
  title="$/% trend detail — last 8 weeks",
1065
1168
  subtitle=None,
1066
- period=_period(start, end, label="Last 8 weeks", display_tz=_display_tz(options)),
1169
+ period=_civil_period(start, end, label="Last 8 weeks",
1170
+ display_tz=_display_tz(options)),
1067
1171
  columns=(
1068
1172
  _LS.ColumnSpec(key="week", label="Week", align="left"),
1069
1173
  _LS.ColumnSpec(key="cost", label="$", align="right", emphasis=True),
@@ -1101,7 +1205,7 @@ def _build_trend_detail(*, panel_data, options):
1101
1205
  def _build_daily_visual(*, panel_data, options):
1102
1206
  """Daily visual — 7-day cost bar, rows=() (spec §9.5)."""
1103
1207
  days = panel_data.get("days") or []
1104
- start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
1208
+ start = _parse_iso_utc(days[0]["date"]) if days else _civil_now_bucket(options)
1105
1209
  end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
1106
1210
  end = end_anchor + _dt.timedelta(days=1)
1107
1211
  sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
@@ -1109,7 +1213,8 @@ def _build_daily_visual(*, panel_data, options):
1109
1213
  cmd="daily",
1110
1214
  title=f"Daily visual — last {len(days)} day{'s' if len(days) != 1 else ''}",
1111
1215
  subtitle=None,
1112
- period=_period(start, end, label="Last 7 days", display_tz=_display_tz(options)),
1216
+ period=_civil_period(start, end, label="Last 7 days",
1217
+ display_tz=_display_tz(options)),
1113
1218
  columns=(),
1114
1219
  rows=(),
1115
1220
  chart=_LS.BarChart(
@@ -1137,7 +1242,7 @@ def _build_daily_detail(*, panel_data, options):
1137
1242
  carries each day's per-project breakdown.
1138
1243
  """
1139
1244
  days = panel_data.get("days") or []
1140
- start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
1245
+ start = _parse_iso_utc(days[0]["date"]) if days else _civil_now_bucket(options)
1141
1246
  end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
1142
1247
  end = end_anchor + _dt.timedelta(days=1)
1143
1248
  sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
@@ -1172,7 +1277,8 @@ def _build_daily_detail(*, panel_data, options):
1172
1277
  cmd="daily",
1173
1278
  title=f"Daily detail — last {len(days)} day{'s' if len(days) != 1 else ''}",
1174
1279
  subtitle=None,
1175
- period=_period(start, end, label="Last 7 days", display_tz=_display_tz(options)),
1280
+ period=_civil_period(start, end, label="Last 7 days",
1281
+ display_tz=_display_tz(options)),
1176
1282
  columns=columns,
1177
1283
  rows=rows,
1178
1284
  chart=_LS.BarChart(
@@ -1200,7 +1306,7 @@ def _build_monthly_visual(*, panel_data, options):
1200
1306
  def _month_start(s):
1201
1307
  return _parse_iso_utc(f"{s}-01")
1202
1308
 
1203
- start = _month_start(months[0]["month"]) if months else _utc_now()
1309
+ start = _month_start(months[0]["month"]) if months else _civil_now_bucket(options)
1204
1310
  if months:
1205
1311
  last = _month_start(months[-1]["month"])
1206
1312
  end_anchor = last.replace(day=28) + _dt.timedelta(days=4)
@@ -1212,8 +1318,8 @@ def _build_monthly_visual(*, panel_data, options):
1212
1318
  cmd="monthly",
1213
1319
  title=f"Monthly visual — last {len(months)} month{'s' if len(months) != 1 else ''}",
1214
1320
  subtitle=None,
1215
- period=_period(start, end, label="Recent months",
1216
- display_tz=_display_tz(options)),
1321
+ period=_civil_period(start, end, label="Recent months",
1322
+ display_tz=_display_tz(options)),
1217
1323
  columns=(),
1218
1324
  rows=(),
1219
1325
  chart=_LS.BarChart(
@@ -1241,7 +1347,7 @@ def _build_monthly_detail(*, panel_data, options):
1241
1347
  def _month_start(s):
1242
1348
  return _parse_iso_utc(f"{s}-01")
1243
1349
 
1244
- start = _month_start(months[0]["month"]) if months else _utc_now()
1350
+ start = _month_start(months[0]["month"]) if months else _civil_now_bucket(options)
1245
1351
  if months:
1246
1352
  last = _month_start(months[-1]["month"])
1247
1353
  end_anchor = last.replace(day=28) + _dt.timedelta(days=4)
@@ -1280,8 +1386,8 @@ def _build_monthly_detail(*, panel_data, options):
1280
1386
  cmd="monthly",
1281
1387
  title=f"Monthly detail — last {len(months)} month{'s' if len(months) != 1 else ''}",
1282
1388
  subtitle=None,
1283
- period=_period(start, end, label="Recent months",
1284
- display_tz=_display_tz(options)),
1389
+ period=_civil_period(start, end, label="Recent months",
1390
+ display_tz=_display_tz(options)),
1285
1391
  columns=columns,
1286
1392
  rows=rows,
1287
1393
  chart=_LS.BarChart(
@@ -1306,8 +1412,7 @@ def _build_blocks_visual(*, panel_data, options):
1306
1412
  """Blocks visual — recent-blocks line, rows=() (spec §9.5)."""
1307
1413
  cb = panel_data.get("current_block") or {}
1308
1414
  recent = panel_data.get("recent_blocks") or []
1309
- start = _parse_iso_utc(cb["start_at"]) if cb.get("start_at") else _utc_now()
1310
- end = _parse_iso_utc(cb["end_at"]) if cb.get("end_at") else start + _dt.timedelta(hours=5)
1415
+ start, end = _blocks_period_bounds(panel_data)
1311
1416
  return _LS.ShareSnapshot(
1312
1417
  cmd="five-hour-blocks",
1313
1418
  title="Current 5-hour block — visual",
@@ -1339,8 +1444,7 @@ def _build_blocks_detail(*, panel_data, options):
1339
1444
  """Blocks detail — per-block × per-project cross-tab (spec §9.5)."""
1340
1445
  cb = panel_data.get("current_block") or {}
1341
1446
  recent = panel_data.get("recent_blocks") or []
1342
- start = _parse_iso_utc(cb["start_at"]) if cb.get("start_at") else _utc_now()
1343
- end = _parse_iso_utc(cb["end_at"]) if cb.get("end_at") else start + _dt.timedelta(hours=5)
1447
+ start, end = _blocks_period_bounds(panel_data)
1344
1448
  top_n = max(int(options.get("top_n", 5)), 1)
1345
1449
 
1346
1450
  breakdowns = [dict(b.get("projects") or {}) for b in recent]
@@ -1399,7 +1503,7 @@ def _build_blocks_detail(*, panel_data, options):
1399
1503
  def _build_forecast_visual(*, panel_data, options):
1400
1504
  """Forecast visual — projection chart with 90/100% ceilings, rows=() (spec §9.5)."""
1401
1505
  curve = panel_data.get("projection_curve") or []
1402
- start = _parse_iso_utc(curve[0]["date"]) if curve else _utc_now()
1506
+ start = _parse_iso_utc(curve[0]["date"]) if curve else _civil_now_bucket(options)
1403
1507
  end_anchor = _parse_iso_utc(curve[-1]["date"]) if curve else start
1404
1508
  end = end_anchor + _dt.timedelta(days=1)
1405
1509
  confidence = panel_data.get("confidence") or "ok"
@@ -1408,8 +1512,8 @@ def _build_forecast_visual(*, panel_data, options):
1408
1512
  cmd="forecast",
1409
1513
  title="Forecast visual — projection to ceiling",
1410
1514
  subtitle=None,
1411
- period=_period(start, end, label="Next 7 days",
1412
- display_tz=_display_tz(options)),
1515
+ period=_civil_period(start, end, label="Next 7 days",
1516
+ display_tz=_display_tz(options)),
1413
1517
  columns=(),
1414
1518
  rows=(),
1415
1519
  chart=_LS.LineChart(
@@ -1444,7 +1548,7 @@ def _build_forecast_detail(*, panel_data, options):
1444
1548
  """
1445
1549
  curve = panel_data.get("projection_curve") or []
1446
1550
  budgets = panel_data.get("daily_budgets") or {}
1447
- start = _parse_iso_utc(curve[0]["date"]) if curve else _utc_now()
1551
+ start = _parse_iso_utc(curve[0]["date"]) if curve else _civil_now_bucket(options)
1448
1552
  end_anchor = _parse_iso_utc(curve[-1]["date"]) if curve else start
1449
1553
  end = end_anchor + _dt.timedelta(days=1)
1450
1554
  confidence = panel_data.get("confidence") or "ok"
@@ -1473,8 +1577,8 @@ def _build_forecast_detail(*, panel_data, options):
1473
1577
  cmd="forecast",
1474
1578
  title="Forecast detail — projection to ceiling",
1475
1579
  subtitle=None,
1476
- period=_period(start, end, label="Next 7 days",
1477
- display_tz=_display_tz(options)),
1580
+ period=_civil_period(start, end, label="Next 7 days",
1581
+ display_tz=_display_tz(options)),
1478
1582
  columns=(
1479
1583
  _LS.ColumnSpec(key="metric", label="Metric", align="left"),
1480
1584
  _LS.ColumnSpec(key="value", label="$/day", align="right", emphasis=True),
@@ -1704,14 +1808,16 @@ def _projects_period(panel_data: dict, options: dict):
1704
1808
  display_tz=_display_tz(options))
1705
1809
 
1706
1810
 
1707
- def _projects_subtitle(options: dict, total_rows: int) -> str:
1708
- # FAIL CLOSED (#503 S1 F3): an omitted field made the Projects panel
1709
- # print the claim "real projects" about its own contents.
1710
- reveal = options.get("reveal_projects", False)
1711
- return " · ".join([
1712
- f"{total_rows} project{'' if total_rows == 1 else 's'}",
1713
- "real projects" if reveal else "projects anonymized",
1714
- ])
1811
+ def _projects_subtitle(total_rows: int) -> str:
1812
+ """The project COUNT, and nothing else.
1813
+
1814
+ The privacy mode it used to append is now stated by the facts strip,
1815
+ which reads the provenance marker preparation stamped rather than an
1816
+ option — so this cannot claim "real projects" about a scrubbed
1817
+ snapshot (#503 S1 F3, #503 S2 D5). The count is a genuine semantic
1818
+ fact and stays.
1819
+ """
1820
+ return f"{total_rows} project{'' if total_rows == 1 else 's'}"
1715
1821
 
1716
1822
 
1717
1823
  def _build_projects_recap(*, panel_data, options):
@@ -1726,7 +1832,7 @@ def _build_projects_recap(*, panel_data, options):
1726
1832
  return _LS.ShareSnapshot(
1727
1833
  cmd="projects",
1728
1834
  title=f"Projects recap — top {min(cap, len(rows))}",
1729
- subtitle=_projects_subtitle(options, len(rows)),
1835
+ subtitle=_projects_subtitle(len(rows)),
1730
1836
  period=_projects_period(panel_data, options),
1731
1837
  columns=_PROJECTS_TABLE_COLUMNS,
1732
1838
  rows=_projects_rows_for_template(rows, cap),
@@ -1747,6 +1853,12 @@ def _build_projects_visual(*, panel_data, options):
1747
1853
  Mirrors `weekly-visual` / `current-week-visual` — chart-led, no
1748
1854
  table. Spec §7.6 routes the visual archetype to a HorizontalBarChart
1749
1855
  of the top-12 projects.
1856
+
1857
+ #503 S2 F17: this docstring said "no table" while the builder passed
1858
+ a full column set with zero rows, so every format emitted a
1859
+ header-only table with an empty body. Fixed HERE rather than by
1860
+ teaching the renderers a row-count rule, because columns with zero
1861
+ rows is a legitimate empty-result schema elsewhere.
1750
1862
  """
1751
1863
  rows = panel_data.get("rows") or []
1752
1864
  cap_chart = 12
@@ -1754,10 +1866,10 @@ def _build_projects_visual(*, panel_data, options):
1754
1866
  return _LS.ShareSnapshot(
1755
1867
  cmd="projects",
1756
1868
  title=f"Projects — top {min(cap_chart, len(rows))} by cost",
1757
- subtitle=_projects_subtitle(options, len(rows)),
1869
+ subtitle=_projects_subtitle(len(rows)),
1758
1870
  period=_projects_period(panel_data, options),
1759
- columns=_PROJECTS_TABLE_COLUMNS,
1760
- rows=(), # visual archetype: empty table
1871
+ columns=(), # visual archetype: no table
1872
+ rows=(),
1761
1873
  chart=_projects_chart_for_template(rows, cap=cap_chart),
1762
1874
  totals=_kpi_strip(
1763
1875
  ("$ spent", f"${total:,.2f}"),
@@ -1786,7 +1898,7 @@ def _build_projects_detail(*, panel_data, options):
1786
1898
  return _LS.ShareSnapshot(
1787
1899
  cmd="projects",
1788
1900
  title=f"Projects detail — {len(rows)} project{'' if len(rows) == 1 else 's'}",
1789
- subtitle=_projects_subtitle(options, len(rows)),
1901
+ subtitle=_projects_subtitle(len(rows)),
1790
1902
  period=_projects_period(panel_data, options),
1791
1903
  columns=_PROJECTS_TABLE_COLUMNS,
1792
1904
  rows=_projects_rows_for_template(rows, cap),
@@ -1733,6 +1733,13 @@ class CodexDailyView:
1733
1733
  period_start: "dt.datetime | None" = None
1734
1734
  period_end: "dt.datetime | None" = None
1735
1735
  display_tz_label: str = ""
1736
+ # `period_start` came from a CALENDAR BUCKET LABEL, not from an
1737
+ # instant (#503 S2 D7). The share builder needs the distinction: a
1738
+ # label must be re-grounded at midnight in the zone the artifact is
1739
+ # labelled with, while `period_end` (always `now_utc`) converts. The
1740
+ # value alone cannot say which — an empty view's `period_start` is a
1741
+ # real instant that happens to sit at whatever time "now" is.
1742
+ period_civil_bucket: bool = False
1736
1743
 
1737
1744
 
1738
1745
  @dataclass(frozen=True)
@@ -1749,6 +1756,7 @@ class CodexMonthlyView:
1749
1756
  period_start: "dt.datetime | None" = None
1750
1757
  period_end: "dt.datetime | None" = None
1751
1758
  display_tz_label: str = ""
1759
+ period_civil_bucket: bool = False # see `CodexDailyView`
1752
1760
 
1753
1761
 
1754
1762
  @dataclass(frozen=True)
@@ -1766,6 +1774,7 @@ class CodexWeeklyView:
1766
1774
  period_start: "dt.datetime | None" = None
1767
1775
  period_end: "dt.datetime | None" = None
1768
1776
  display_tz_label: str = ""
1777
+ period_civil_bucket: bool = False # see `CodexDailyView`
1769
1778
 
1770
1779
 
1771
1780
  @dataclass(frozen=True)
@@ -1849,6 +1858,7 @@ def build_codex_daily_view(entries, *, now_utc, tz_name=None, speed="standard"):
1849
1858
  period_start=_codex_period_start_from_date_bucket(buckets),
1850
1859
  period_end=now_utc,
1851
1860
  display_tz_label=_codex_tz_label(tz_name),
1861
+ period_civil_bucket=bool(buckets),
1852
1862
  )
1853
1863
 
1854
1864
 
@@ -1869,6 +1879,7 @@ def build_codex_monthly_view(entries, *, now_utc, tz_name=None, speed="standard"
1869
1879
  period_start=_codex_period_start_from_month_bucket(buckets),
1870
1880
  period_end=now_utc,
1871
1881
  display_tz_label=_codex_tz_label(tz_name),
1882
+ period_civil_bucket=bool(buckets),
1872
1883
  )
1873
1884
 
1874
1885
 
@@ -1891,6 +1902,7 @@ def build_codex_weekly_view(entries, *, now_utc, tz_name=None,
1891
1902
  period_start=_codex_period_start_from_date_bucket(buckets),
1892
1903
  period_end=now_utc,
1893
1904
  display_tz_label=_codex_tz_label(tz_name),
1905
+ period_civil_bucket=bool(buckets),
1894
1906
  )
1895
1907
 
1896
1908