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.
- package/CHANGELOG.md +48 -0
- package/bin/_cctally_cache.py +55 -6
- package/bin/_cctally_codex.py +35 -3
- package/bin/_cctally_config.py +108 -2
- package/bin/_cctally_core.py +37 -8
- package/bin/_cctally_dashboard.py +6 -0
- package/bin/_cctally_dashboard_share.py +744 -137
- package/bin/_cctally_dashboard_sources.py +9 -0
- package/bin/_cctally_db.py +315 -40
- package/bin/_cctally_doctor.py +188 -3
- package/bin/_cctally_five_hour.py +39 -12
- package/bin/_cctally_forecast.py +29 -47
- package/bin/_cctally_journal.py +79 -7
- package/bin/_cctally_parser.py +71 -1
- package/bin/_cctally_project.py +0 -2
- package/bin/_cctally_record.py +18 -0
- package/bin/_cctally_reporting.py +0 -9
- package/bin/_cctally_retention.py +2890 -0
- package/bin/_cctally_share.py +97 -92
- package/bin/_cctally_source_analytics.py +44 -16
- package/bin/_cctally_store.py +194 -14
- package/bin/_lib_artifact_retention.py +1645 -0
- package/bin/_lib_display_tz.py +18 -0
- package/bin/_lib_doctor.py +378 -24
- package/bin/_lib_share.py +1359 -183
- package/bin/_lib_share_templates.py +181 -69
- package/bin/_lib_view_models.py +12 -0
- package/bin/cctally +36 -4
- package/dashboard/static/assets/{index-HlIK7k8Q.js → index-CaUQziq_.js} +48 -48
- package/dashboard/static/assets/index-DUQjFSX7.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +0 -1
|
@@ -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
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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 =
|
|
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=
|
|
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=
|
|
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
|
|
517
|
-
end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else
|
|
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=
|
|
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
|
|
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=
|
|
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
|
|
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=
|
|
637
|
-
|
|
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 =
|
|
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
|
|
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=
|
|
742
|
-
|
|
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 =
|
|
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=
|
|
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 =
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
|
1026
|
-
end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else
|
|
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=
|
|
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
|
|
1059
|
-
end_anchor = _parse_iso_utc(weeks[-1]["start_date"]) if weeks else
|
|
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=
|
|
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
|
|
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=
|
|
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
|
|
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=
|
|
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
|
|
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=
|
|
1216
|
-
|
|
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
|
|
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=
|
|
1284
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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=
|
|
1412
|
-
|
|
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
|
|
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=
|
|
1477
|
-
|
|
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(
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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(
|
|
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(
|
|
1869
|
+
subtitle=_projects_subtitle(len(rows)),
|
|
1758
1870
|
period=_projects_period(panel_data, options),
|
|
1759
|
-
columns=
|
|
1760
|
-
rows=(),
|
|
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(
|
|
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),
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -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
|
|