cctally 1.100.0 → 1.102.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +8 -2
  3. package/bin/_cctally_alerts.py +13 -2
  4. package/bin/_cctally_cache.py +3 -1
  5. package/bin/_cctally_cache_report.py +103 -6
  6. package/bin/_cctally_dashboard.py +1140 -282
  7. package/bin/_cctally_dashboard_conversation.py +12 -0
  8. package/bin/_cctally_dashboard_envelope.py +53 -61
  9. package/bin/_cctally_dashboard_share.py +101 -29
  10. package/bin/_cctally_dashboard_sources.py +663 -192
  11. package/bin/_cctally_diagnosis.py +1172 -0
  12. package/bin/_cctally_diagnosis_sources.py +4054 -0
  13. package/bin/_cctally_diff.py +20 -0
  14. package/bin/_cctally_forecast.py +329 -111
  15. package/bin/_cctally_milestone_history.py +10 -2
  16. package/bin/_cctally_parser.py +84 -0
  17. package/bin/_cctally_project.py +155 -47
  18. package/bin/_cctally_quota.py +14 -0
  19. package/bin/_cctally_record.py +151 -71
  20. package/bin/_cctally_refresh.py +105 -93
  21. package/bin/_cctally_share.py +9 -2
  22. package/bin/_cctally_source_analytics.py +40 -4
  23. package/bin/_cctally_statusline.py +8 -1
  24. package/bin/_cctally_tui.py +425 -234
  25. package/bin/_lib_alert_scope.py +685 -0
  26. package/bin/_lib_alerts_payload.py +112 -7
  27. package/bin/_lib_blocks.py +12 -0
  28. package/bin/_lib_cache_report.py +110 -1
  29. package/bin/_lib_codex_conversation.py +14 -0
  30. package/bin/_lib_codex_conversation_query.py +22 -8
  31. package/bin/_lib_codex_pools.py +20 -8
  32. package/bin/_lib_conversation.py +6 -3
  33. package/bin/_lib_conversation_query.py +256 -69
  34. package/bin/_lib_dashboard_sources.py +212 -24
  35. package/bin/_lib_diagnosis.py +1261 -0
  36. package/bin/_lib_forecast.py +62 -4
  37. package/bin/_lib_perf.py +12 -0
  38. package/bin/_lib_pricing.py +8 -7
  39. package/bin/_lib_readme_refresh.py +26 -5
  40. package/bin/_lib_render.py +31 -3
  41. package/bin/_lib_share_templates.py +150 -55
  42. package/bin/_lib_snapshot_cache.py +71 -13
  43. package/bin/_lib_source_identity.py +50 -2
  44. package/bin/_lib_subscription_weeks.py +65 -0
  45. package/bin/cctally +103 -16
  46. package/bin/cctally-explain +5 -0
  47. package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
  48. package/dashboard/static/assets/index-Di2hljvB.css +1 -0
  49. package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
  53. package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
@@ -20,10 +20,60 @@ from __future__ import annotations
20
20
 
21
21
  import datetime as dt
22
22
  from dataclasses import dataclass
23
+ from enum import Enum
23
24
 
24
25
  from _lib_budget import project_linear
25
26
 
26
27
 
28
+ class ForecastConfidenceCause(str, Enum):
29
+ """The closed set of reasons a forecast is low-confidence.
30
+
31
+ The four values are a wire contract `--json` consumers read, so they are
32
+ the exact strings `_assess_forecast_confidence` has always emitted.
33
+ """
34
+
35
+ ELAPSED_HOURS = "elapsed_hours<24"
36
+ PERCENT = "percent<2"
37
+ SNAPSHOTS = "snapshots<3"
38
+ NO_SAMPLE_GE_24H = "no_sample_ge_24h"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ForecastConfidenceAssessment:
43
+ confidence: str # "high" | "low"
44
+ reasons: tuple[str, ...]
45
+
46
+
47
+ def assess_forecast_confidence(
48
+ elapsed_hours: float,
49
+ percent: float,
50
+ snapshot_count: int,
51
+ *,
52
+ has_sample_ge_24h: bool,
53
+ ) -> ForecastConfidenceAssessment:
54
+ """The complete confidence predicate, including the fourth trigger.
55
+
56
+ `no_sample_ge_24h` used to be appended by `_load_forecast_inputs` after
57
+ it called the three-trigger predicate, which meant glue could add reasons
58
+ the predicate did not know about (#620 S2 E3, closes F19). Reason order
59
+ is `elapsed_hours<24`, `percent<2`, `snapshots<3`, `no_sample_ge_24h` —
60
+ the order the old call-plus-append produced.
61
+ """
62
+ reasons: list[str] = []
63
+ if elapsed_hours < 24:
64
+ reasons.append(ForecastConfidenceCause.ELAPSED_HOURS.value)
65
+ if percent < 2:
66
+ reasons.append(ForecastConfidenceCause.PERCENT.value)
67
+ if snapshot_count < 3:
68
+ reasons.append(ForecastConfidenceCause.SNAPSHOTS.value)
69
+ if not has_sample_ge_24h:
70
+ reasons.append(ForecastConfidenceCause.NO_SAMPLE_GE_24H.value)
71
+ return ForecastConfidenceAssessment(
72
+ confidence="low" if reasons else "high",
73
+ reasons=tuple(reasons),
74
+ )
75
+
76
+
27
77
  @dataclass
28
78
  class ForecastInputs:
29
79
  now_utc: dt.datetime
@@ -42,9 +92,11 @@ class ForecastInputs:
42
92
  # Rate inputs
43
93
  p_24h_ago: float | None
44
94
  t_24h_actual_hours: float | None
45
- # $/1% selection
46
- dollars_per_percent: float
47
- dollars_per_percent_source: str # "this_week" | "trailing_4wk_median" | "this_week_sparse"
95
+ # $/1% selection. `None` when no usage has been observed (#620 S1 D5) —
96
+ # every dollar-derived output is then unavailable rather than zero, and
97
+ # `dollars_per_percent_source` carries the cause.
98
+ dollars_per_percent: "float | None"
99
+ dollars_per_percent_source: str # "this_week" | "trailing_4wk_median" | "this_week_sparse" | "no_usage_observed"
48
100
  # Confidence
49
101
  confidence: str # "high" | "low"
50
102
  low_confidence_reasons: list[str]
@@ -121,7 +173,13 @@ def _compute_forecast(inputs: ForecastInputs, targets: list[int]) -> ForecastOut
121
173
  budgets.append(BudgetRow(target_percent=t, pct_headroom=None,
122
174
  dollars_per_day=None, percent_per_day=None))
123
175
  continue
124
- dollars_day = (headroom * inputs.dollars_per_percent) / inputs.remaining_days
176
+ # #620 S1 D5: with no observed usage there is no rate, so the
177
+ # dollar budget is unavailable. The percent budget does not
178
+ # depend on the rate and is still published.
179
+ dollars_day = (
180
+ None if inputs.dollars_per_percent is None
181
+ else (headroom * inputs.dollars_per_percent) / inputs.remaining_days
182
+ )
125
183
  pct_day = headroom / inputs.remaining_days
126
184
  budgets.append(BudgetRow(
127
185
  target_percent=t,
package/bin/_lib_perf.py CHANGED
@@ -244,6 +244,18 @@ def current_root():
244
244
  return getattr(_tls, "root", None)
245
245
 
246
246
 
247
+ def in_phase() -> bool:
248
+ """Whether a phase is currently OPEN on this thread.
249
+
250
+ `current_root()` answers a different question — it names the last root
251
+ that CLOSED — so a caller deciding whether to open a root of its own
252
+ cannot use it. A pure fold that opens child phases needs this one: with no
253
+ root open its children would be emitted into whatever phase happens to be
254
+ on the stack, or into none at all.
255
+ """
256
+ return bool(_stack())
257
+
258
+
247
259
  def reset_thread():
248
260
  """Start a fresh root scope on this thread, capturing the armed state.
249
261
 
@@ -736,15 +736,16 @@ _unknown_codex_model_warnings: set[str] = set()
736
736
  # directly comparable.
737
737
  CODEX_LEGACY_FALLBACK_MODEL = "gpt-5"
738
738
 
739
- # OpenAI's model catalog describes ``codex-auto-review`` as the hidden model
740
- # used by the Guardian approval-review subagent, not as a public billable model
741
- # with its own rate card. The canonical ccusage Codex adapter resolves that
742
- # identifier to the model current when it appeared; its 2026-04-23-and-later
743
- # mapping is gpt-5.5, which covers every retained auto-review event observed for
744
- # issue #535. Keep this as a normalization map rather than a duplicate pricing
745
- # row so pricing drift continues to have one source of truth per rate card.
739
+ # Runtime identifiers without their own rate card normalize here so pricing
740
+ # drift retains one source of truth per card. OpenAI's live pricing page states
741
+ # that ``daybreak-blue-latest`` currently points to ``gpt-5.6-sol`` and inherits
742
+ # the underlying model's pricing; Codex emits the prefixed runtime identifier
743
+ # retained below. ``codex-auto-review`` is the hidden Guardian model and maps to
744
+ # the model current when it appeared, covering every retained event observed
745
+ # for issue #535.
746
746
  CODEX_MODEL_ALIASES: dict[str, str] = {
747
747
  "codex-auto-review": "gpt-5.5",
748
+ "gpt-daybreak-blue-latest": "gpt-5.6-sol",
748
749
  }
749
750
 
750
751
  # Per-model fast-tier price multipliers, ported from ryoppippi/ccusage
@@ -8,7 +8,9 @@ implementation:
8
8
  1. ``extract_highlights`` — pull up to N lint-clean highlight bullets from a
9
9
  specific ``## [X.Y.Z] - YYYY-MM-DD`` CHANGELOG section (exact version match,
10
10
  Added > Changed > Fixed priority, multiline flattening, terminal issue-ref
11
- stripping, fenced-code skipping).
11
+ stripping, fenced-code skipping). ``render_latest_stable_block`` keeps that
12
+ compact target-section sample honest by naming and linking the complete
13
+ previous-stable-to-target upgrade span.
12
14
  2. ``normalize_highlight`` — deterministically rewrite a bullet so it satisfies
13
15
  the copy lint by construction (em/en dashes with surrounding spaces become
14
16
  ``, ``; doubled separators collapse; leading/trailing separators trim).
@@ -297,14 +299,33 @@ def extract_highlights(
297
299
 
298
300
 
299
301
  def render_latest_stable_block(
300
- version: str, date: str, bullets: "list[str]"
302
+ version: str,
303
+ date: str,
304
+ bullets: "list[str]",
305
+ *,
306
+ previous_stable: "str | None" = None,
307
+ release_url: "str | None" = None,
301
308
  ) -> str:
302
309
  """Render the auto-maintained latest-stable block (no trailing newline
303
- after the last bullet; version+date only when *bullets* is empty)."""
310
+ after the last line; initial baselines retain the single-release shape)."""
304
311
  base = f"**Latest stable: v{version}** ({date})\n"
312
+ if previous_stable is None:
313
+ if bullets:
314
+ return base + "\n" + "\n".join(f"- {b}" for b in bullets)
315
+ return base
316
+ if release_url is None:
317
+ raise ValueError("stable-span README block requires a release URL")
318
+ blocks = [
319
+ base.rstrip("\n"),
320
+ f"Highlights from the `v{previous_stable}` to `v{version}` "
321
+ "stable upgrade:",
322
+ ]
305
323
  if bullets:
306
- return base + "\n" + "\n".join(f"- {b}" for b in bullets)
307
- return base
324
+ blocks.append("\n".join(f"- {b}" for b in bullets))
325
+ blocks.append(
326
+ f"[See every change in this stable upgrade]({release_url})"
327
+ )
328
+ return "\n\n".join(blocks)
308
329
 
309
330
 
310
331
  # --------------------------------------------------------------------------
@@ -2795,7 +2795,8 @@ def _render_project_table(
2795
2795
  """Render project rollup as a ccusage-style ANSI table.
2796
2796
 
2797
2797
  Columns: Project | Sessions | First Seen | Last Seen | Input |
2798
- Cache Create | Cache Read | Output | Cost (USD) | Used % | $/1%
2798
+ Cache Create | Cache Read | Output | Cost (USD) |
2799
+ Cost Share | Used % | $/1%
2799
2800
 
2800
2801
  Parent rows show all columns; breakdown child rows show per-model
2801
2802
  aggregates with blank Sessions/Used%/$/1% cells (those only make
@@ -2822,12 +2823,12 @@ def _render_project_table(
2822
2823
  headers = [
2823
2824
  "Project", "Sessions", "First Seen", "Last Seen",
2824
2825
  "Input", "Cache Create", "Cache Read", "Output",
2825
- "Cost (USD)", "Used %", "$/1%",
2826
+ "Cost (USD)", "Cost Share", "Used %", "$/1%",
2826
2827
  ]
2827
2828
  aligns = [
2828
2829
  "left", "right", "left", "left",
2829
2830
  "right", "right", "right", "right",
2830
- "right", "right", "right",
2831
+ "right", "right", "right", "right",
2831
2832
  ]
2832
2833
  num_cols = len(headers)
2833
2834
 
@@ -2891,6 +2892,19 @@ def _render_project_table(
2891
2892
  return ("\u2014", _gray)
2892
2893
  return (f"${cpp:.2f}", None)
2893
2894
 
2895
+ # #620 S1 D13 (CLI half): each project's share of the cost of the
2896
+ # projects this table lists. That denominator is NOT the quota
2897
+ # percentage `Used %` reports, so the two columns are independent and
2898
+ # the footer below names this one explicitly — the terminal has no
2899
+ # hover, so the disclosure the dashboard makes in a tooltip has to be a
2900
+ # rendered line here.
2901
+ cost_share_total = stable_sum(float(r["cost_usd"]) for r in rows)
2902
+
2903
+ def _cost_share_cell(cost_usd: float) -> tuple[str, Any]:
2904
+ if cost_share_total <= 0:
2905
+ return ("\u2014", _gray)
2906
+ return (f"{100.0 * float(cost_usd) / cost_share_total:.1f}%", None)
2907
+
2894
2908
  arrow = " \u2514\u2500" if unicode_ok else " |_"
2895
2909
 
2896
2910
  ROW_DATA, ROW_BREAKDOWN = "data", "breakdown"
@@ -2910,6 +2924,7 @@ def _render_project_table(
2910
2924
  (_fmt_num(r["cache_read"]), None),
2911
2925
  (_fmt_num(r["output"]), None),
2912
2926
  (f"${r['cost_usd']:.2f}", None),
2927
+ _cost_share_cell(r["cost_usd"]),
2913
2928
  (used_text, used_cfn),
2914
2929
  (cpp_text, cpp_cfn),
2915
2930
  ]
@@ -2928,6 +2943,10 @@ def _render_project_table(
2928
2943
  (_fmt_num(mb["cache_read"]), _gray),
2929
2944
  (_fmt_num(mb["output"]), _gray),
2930
2945
  (f"${mb['cost_usd']:.2f}", _gray),
2946
+ # Cost Share is blank on a model row for the same reason
2947
+ # Used % and $/1% are: a model's share of its project is a
2948
+ # different denominator from the one the column names.
2949
+ ("", None),
2931
2950
  ("", None),
2932
2951
  ("", None),
2933
2952
  ]
@@ -3062,6 +3081,15 @@ def _render_project_table(
3062
3081
  )
3063
3082
  )
3064
3083
 
3084
+ project_plural = "s" if len(rows) != 1 else ""
3085
+ out.append(
3086
+ _dim(
3087
+ f"Cost Share: each project's share of ${cost_share_total:,.2f} "
3088
+ f"\u2014 the total cost of the {len(rows)} "
3089
+ f"project{project_plural} listed."
3090
+ )
3091
+ )
3092
+
3065
3093
  return "\n".join(out)
3066
3094
 
3067
3095
 
@@ -199,6 +199,108 @@ def _kpi_strip(*items: tuple[str, str]) -> tuple:
199
199
  return tuple(_LS.Totalled(label=lbl, value=val) for lbl, val in items)
200
200
 
201
201
 
202
+ def _optional_money(value, spec: str = ".2f") -> str:
203
+ """A dollar figure, or `n/a` when the value was withheld.
204
+
205
+ #620 S1 D5/A5. A KPI strip renders through an f-string, so a `None`
206
+ raises and a substituted zero prints `$0.000` — which is the fabrication
207
+ the contract removes. `n/a` is the same word `_build_forecast_snapshot`
208
+ already uses for an absent $/1%, so the two share surfaces say the same
209
+ thing about the same absence.
210
+ """
211
+ if value is None:
212
+ return "n/a"
213
+ return f"${value:{spec}}"
214
+
215
+
216
+ def _optional_money_cell(value):
217
+ """A `MoneyCell`, or a `TextCell` reading `n/a` when the value was
218
+ withheld.
219
+
220
+ #620 S1 D5/A5, table half. `_optional_money` covers the KPI strip, which
221
+ is already a string; a table cell is a tagged-union member, and
222
+ `MoneyCell` takes a float. Forcing a withheld figure through `float(...)`
223
+ raises `TypeError`, and the dashboard share handler catches that and
224
+ returns a share FAILURE — so the coercion turned a fabricated `$0.000`
225
+ into no artifact at all. `n/a` is the word `_optional_money` and
226
+ `_build_forecast_snapshot` already use for the same absence.
227
+ """
228
+ if value is None:
229
+ return _LS.TextCell("n/a")
230
+ return _LS.MoneyCell(float(value))
231
+
232
+
233
+ def _optional_pct(value, *, scale: float = 100.0, spec: str = ".1f") -> str:
234
+ """A percentage, or `n/a` when the value was withheld.
235
+
236
+ #620 S1. `TuiTrendRow.used_pct` and `WeeklyPeriodRow.used_pct` are both
237
+ `float | None` — `None` when the week carries a cost snapshot but no
238
+ usage snapshot. A KPI strip formats through an f-string, so a `None`
239
+ raises and the zero it replaced printed `0.0%`, which a reader cannot
240
+ tell from a week that really used nothing. `n/a` is the word the money
241
+ helpers above already use for the same absence.
242
+ """
243
+ if value is None:
244
+ return "n/a"
245
+ return f"{float(value) * scale:{spec}}%"
246
+
247
+
248
+ def _optional_pct_cell(value, *, scale: float = 100.0):
249
+ """A `PercentCell`, or a `TextCell` reading `n/a` when withheld.
250
+
251
+ The table half of `_optional_pct`, mirroring `_optional_money_cell`:
252
+ `PercentCell` takes a float, so forcing a withheld percentage through
253
+ `float(...)` raises `TypeError`, which the dashboard share handler turns
254
+ into a share FAILURE rather than an artifact.
255
+ """
256
+ if value is None:
257
+ return _LS.TextCell("n/a")
258
+ return _LS.PercentCell(float(value) * scale)
259
+
260
+
261
+ def _optional_days(value, spec: str = ".1f") -> str:
262
+ """A number of days, or `n/a` when the distance is not defined.
263
+
264
+ #620 S1. `days_to_90pct` / `days_to_100pct` are withheld when no rate was
265
+ observed, because a ceiling is not reachable on any timeline the data
266
+ describes. `float(... or 0.0)` printed `0.0`, which says the ceiling has
267
+ already been reached — the opposite fact.
268
+ """
269
+ if value is None:
270
+ return "n/a"
271
+ return f"{float(value):{spec}}"
272
+
273
+
274
+ def _optional_signed_pct(value) -> str:
275
+ """A signed percentage change, or `n/a` when it could not be computed."""
276
+ if value is None:
277
+ return "n/a"
278
+ return f"{float(value) * 100:+.1f}%"
279
+
280
+
281
+ def _optional_signed_money(value) -> str:
282
+ """A signed dollar change, or `n/a` when it could not be computed."""
283
+ if value is None:
284
+ return "n/a"
285
+ return f"${float(value):+,.2f}"
286
+
287
+
288
+ def _optional_chart_points(items, *, x_label_key, y_key):
289
+ """Chart points for `items`, omitting any whose `y_key` was withheld.
290
+
291
+ A withheld value plotted at zero draws a cliff to the axis that reads as
292
+ a measured collapse. The x position stays the item's original index, so
293
+ the remaining points keep their spacing and the gap sits where the
294
+ missing week actually is.
295
+ """
296
+ return tuple(
297
+ _LS.ChartPoint(x_label=item[x_label_key], x_value=float(i),
298
+ y_value=float(item[y_key]))
299
+ for i, item in enumerate(items)
300
+ if item.get(y_key) is not None
301
+ )
302
+
303
+
202
304
  def _top_projects_rows(top_projects, cap: int) -> tuple:
203
305
  """Build `Row` tuple with ProjectCell + MoneyCell from a list of
204
306
  `(project_path, cost_usd)` pairs.
@@ -499,8 +601,8 @@ def _build_weekly_recap(*, panel_data, options):
499
601
  "weeks": [
500
602
  {"start_date": "YYYY-MM-DD", # ISO date string
501
603
  "cost_usd": float,
502
- "pct_used": float, # fraction 0..1
503
- "dollar_per_pct": float,
604
+ "pct_used": float | None, # fraction 0..1; None = withheld
605
+ "dollar_per_pct": float | None, # None = withheld
504
606
  "top_projects": [(path, cost), ...]},
505
607
  ... up to 8 weeks, chronological ...
506
608
  ],
@@ -530,8 +632,8 @@ def _build_weekly_recap(*, panel_data, options):
530
632
  ),
531
633
  totals=_kpi_strip(
532
634
  ("$ spent", f"${w['cost_usd']:.2f}"),
533
- ("% used", f"{w['pct_used']*100:.1f}%"),
534
- ("$/% rate", f"${w['dollar_per_pct']:.3f}"),
635
+ ("% used", _optional_pct(w['pct_used'])),
636
+ ("$/% rate", _optional_money(w['dollar_per_pct'], ".3f")),
535
637
  ),
536
638
  notes=(),
537
639
  generated_at=_utc_now(),
@@ -549,7 +651,7 @@ def _build_current_week_recap(*, panel_data, options):
549
651
  {
550
652
  "kpi_cost_usd": float,
551
653
  "kpi_pct_used": float, # fraction 0..1
552
- "kpi_dollar_per_pct": float,
654
+ "kpi_dollar_per_pct": float | None, # None = withheld
553
655
  "kpi_days_remaining": float,
554
656
  "daily_progression": [{"date": "YYYY-MM-DD",
555
657
  "cost_usd": float}, ...], # ≤7
@@ -583,7 +685,8 @@ def _build_current_week_recap(*, panel_data, options):
583
685
  totals=_kpi_strip(
584
686
  ("$ spent", f"${panel_data['kpi_cost_usd']:.2f}"),
585
687
  ("% used", f"{panel_data['kpi_pct_used']*100:.1f}%"),
586
- ("$/% rate", f"${panel_data['kpi_dollar_per_pct']:.3f}"),
688
+ ("$/% rate", _optional_money(
689
+ panel_data.get("kpi_dollar_per_pct"), ".3f")),
587
690
  ("Days remaining", f"{panel_data['kpi_days_remaining']:.1f}"),
588
691
  ),
589
692
  notes=(),
@@ -602,8 +705,8 @@ def _build_trend_recap(*, panel_data, options):
602
705
  "weeks": [
603
706
  {"start_date": "YYYY-MM-DD",
604
707
  "cost_usd": float,
605
- "pct_used": float,
606
- "dollar_per_pct": float}, ... 8 entries, chronological ...
708
+ "pct_used": float | None, # None = withheld
709
+ "dollar_per_pct": float | None}, ... 8 entries, chronological ...
607
710
  ],
608
711
  "delta_3_weeks": {
609
712
  "dpp_change_pct": float, # +ve = $/% trending up
@@ -631,24 +734,21 @@ def _build_trend_recap(*, panel_data, options):
631
734
  rows=tuple(
632
735
  _LS.Row(cells={
633
736
  "week": _LS.TextCell(w["start_date"]),
634
- "cost": _LS.MoneyCell(float(w["cost_usd"])),
635
- "pct": _LS.PercentCell(float(w["pct_used"]) * 100.0),
636
- "dpp": _LS.MoneyCell(float(w["dollar_per_pct"])),
737
+ "cost": _optional_money_cell(w["cost_usd"]),
738
+ "pct": _optional_pct_cell(w["pct_used"]),
739
+ "dpp": _optional_money_cell(w["dollar_per_pct"]),
637
740
  })
638
741
  for w in weeks
639
742
  ),
640
743
  chart=_LS.LineChart(
641
- points=tuple(
642
- _LS.ChartPoint(x_label=w["start_date"], x_value=float(i),
643
- y_value=float(w["dollar_per_pct"]))
644
- for i, w in enumerate(weeks)
645
- ),
744
+ points=_optional_chart_points(
745
+ weeks, x_label_key="start_date", y_key="dollar_per_pct"),
646
746
  y_label="$ / 1%",
647
747
  reference_lines=(),
648
748
  ) if weeks else None,
649
749
  totals=_kpi_strip(
650
- ("Δ $/% (3wk)", f"{float(delta.get('dpp_change_pct') or 0.0)*100:+.1f}%"),
651
- ("Δ $ (3wk)", f"${float(delta.get('cost_change_usd') or 0.0):+,.2f}"),
750
+ ("Δ $/% (3wk)", _optional_signed_pct(delta.get('dpp_change_pct'))),
751
+ ("Δ $ (3wk)", _optional_signed_money(delta.get('cost_change_usd'))),
652
752
  ),
653
753
  notes=(),
654
754
  generated_at=_utc_now(),
@@ -813,8 +913,8 @@ def _build_forecast_recap(*, panel_data, options):
813
913
  Expected panel_data shape:
814
914
  {
815
915
  "projected_end_pct": float, # fraction 0..1+
816
- "days_to_100pct": float,
817
- "days_to_90pct": float,
916
+ "days_to_100pct": float | None, # None = no rate observed
917
+ "days_to_90pct": float | None, # None = no rate observed
818
918
  "daily_budgets": {
819
919
  "avg": float, # $/day to-date
820
920
  "recent_24h": float,
@@ -846,13 +946,13 @@ def _build_forecast_recap(*, panel_data, options):
846
946
  ),
847
947
  rows=(
848
948
  _LS.Row(cells={"metric": _LS.TextCell("Avg to-date"),
849
- "value": _LS.MoneyCell(float(budgets.get("avg") or 0.0))}),
949
+ "value": _optional_money_cell(budgets.get("avg"))}),
850
950
  _LS.Row(cells={"metric": _LS.TextCell("Recent 24h"),
851
- "value": _LS.MoneyCell(float(budgets.get("recent_24h") or 0.0))}),
951
+ "value": _optional_money_cell(budgets.get("recent_24h"))}),
852
952
  _LS.Row(cells={"metric": _LS.TextCell("Budget to 90%"),
853
- "value": _LS.MoneyCell(float(budgets.get("until_90pct") or 0.0))}),
953
+ "value": _optional_money_cell(budgets.get("until_90pct"))}),
854
954
  _LS.Row(cells={"metric": _LS.TextCell("Budget to 100%"),
855
- "value": _LS.MoneyCell(float(budgets.get("until_100pct") or 0.0))}),
955
+ "value": _optional_money_cell(budgets.get("until_100pct"))}),
856
956
  ),
857
957
  chart=_LS.LineChart(
858
958
  points=tuple(
@@ -867,8 +967,8 @@ def _build_forecast_recap(*, panel_data, options):
867
967
  ),
868
968
  ) if curve else None,
869
969
  totals=_kpi_strip(
870
- ("Days→90%", f"{float(panel_data.get('days_to_90pct') or 0.0):.1f}"),
871
- ("Days→100%", f"{float(panel_data.get('days_to_100pct') or 0.0):.1f}"),
970
+ ("Days→90%", _optional_days(panel_data.get('days_to_90pct'))),
971
+ ("Days→100%", _optional_days(panel_data.get('days_to_100pct'))),
872
972
  ("End %", f"{float(panel_data.get('projected_end_pct') or 0.0)*100:.1f}%"),
873
973
  ),
874
974
  notes=notes,
@@ -978,7 +1078,7 @@ def _build_weekly_visual(*, panel_data, options):
978
1078
  ),
979
1079
  totals=_kpi_strip(
980
1080
  ("$ spent", f"${w['cost_usd']:.2f}"),
981
- ("% used", f"{w['pct_used']*100:.1f}%"),
1081
+ ("% used", _optional_pct(w['pct_used'])),
982
1082
  ),
983
1083
  notes=(),
984
1084
  generated_at=_utc_now(),
@@ -1043,8 +1143,8 @@ def _build_weekly_detail(*, panel_data, options):
1043
1143
  ),
1044
1144
  totals=_kpi_strip(
1045
1145
  ("$ spent", f"${w['cost_usd']:.2f}"),
1046
- ("% used", f"{w['pct_used']*100:.1f}%"),
1047
- ("$/% rate", f"${w['dollar_per_pct']:.3f}"),
1146
+ ("% used", _optional_pct(w['pct_used'])),
1147
+ ("$/% rate", _optional_money(w['dollar_per_pct'], ".3f")),
1048
1148
  ),
1049
1149
  notes=(),
1050
1150
  generated_at=_utc_now(),
@@ -1112,7 +1212,8 @@ def _build_current_week_detail(*, panel_data, options):
1112
1212
  totals=_kpi_strip(
1113
1213
  ("$ spent", f"${panel_data['kpi_cost_usd']:.2f}"),
1114
1214
  ("% used", f"{panel_data['kpi_pct_used']*100:.1f}%"),
1115
- ("$/% rate", f"${panel_data['kpi_dollar_per_pct']:.3f}"),
1215
+ ("$/% rate", _optional_money(
1216
+ panel_data.get("kpi_dollar_per_pct"), ".3f")),
1116
1217
  ("Days remaining", f"{panel_data['kpi_days_remaining']:.1f}"),
1117
1218
  ),
1118
1219
  notes=(),
@@ -1137,17 +1238,14 @@ def _build_trend_visual(*, panel_data, options):
1137
1238
  columns=(),
1138
1239
  rows=(),
1139
1240
  chart=_LS.LineChart(
1140
- points=tuple(
1141
- _LS.ChartPoint(x_label=w["start_date"], x_value=float(i),
1142
- y_value=float(w["dollar_per_pct"]))
1143
- for i, w in enumerate(weeks)
1144
- ),
1241
+ points=_optional_chart_points(
1242
+ weeks, x_label_key="start_date", y_key="dollar_per_pct"),
1145
1243
  y_label="$ / 1%",
1146
1244
  reference_lines=(),
1147
1245
  ) if weeks else None,
1148
1246
  totals=_kpi_strip(
1149
- ("Δ $/% (3wk)", f"{float(delta.get('dpp_change_pct') or 0.0)*100:+.1f}%"),
1150
- ("Δ $ (3wk)", f"${float(delta.get('cost_change_usd') or 0.0):+,.2f}"),
1247
+ ("Δ $/% (3wk)", _optional_signed_pct(delta.get('dpp_change_pct'))),
1248
+ ("Δ $ (3wk)", _optional_signed_money(delta.get('cost_change_usd'))),
1151
1249
  ),
1152
1250
  notes=(),
1153
1251
  generated_at=_utc_now(),
@@ -1177,24 +1275,21 @@ def _build_trend_detail(*, panel_data, options):
1177
1275
  rows=tuple(
1178
1276
  _LS.Row(cells={
1179
1277
  "week": _LS.TextCell(w["start_date"]),
1180
- "cost": _LS.MoneyCell(float(w["cost_usd"])),
1181
- "pct": _LS.PercentCell(float(w["pct_used"]) * 100.0),
1182
- "dpp": _LS.MoneyCell(float(w["dollar_per_pct"])),
1278
+ "cost": _optional_money_cell(w["cost_usd"]),
1279
+ "pct": _optional_pct_cell(w["pct_used"]),
1280
+ "dpp": _optional_money_cell(w["dollar_per_pct"]),
1183
1281
  })
1184
1282
  for w in weeks
1185
1283
  ),
1186
1284
  chart=_LS.LineChart(
1187
- points=tuple(
1188
- _LS.ChartPoint(x_label=w["start_date"], x_value=float(i),
1189
- y_value=float(w["dollar_per_pct"]))
1190
- for i, w in enumerate(weeks)
1191
- ),
1285
+ points=_optional_chart_points(
1286
+ weeks, x_label_key="start_date", y_key="dollar_per_pct"),
1192
1287
  y_label="$ / 1%",
1193
1288
  reference_lines=(),
1194
1289
  ) if weeks else None,
1195
1290
  totals=_kpi_strip(
1196
- ("Δ $/% (3wk)", f"{float(delta.get('dpp_change_pct') or 0.0)*100:+.1f}%"),
1197
- ("Δ $ (3wk)", f"${float(delta.get('cost_change_usd') or 0.0):+,.2f}"),
1291
+ ("Δ $/% (3wk)", _optional_signed_pct(delta.get('dpp_change_pct'))),
1292
+ ("Δ $ (3wk)", _optional_signed_money(delta.get('cost_change_usd'))),
1198
1293
  ),
1199
1294
  notes=(),
1200
1295
  generated_at=_utc_now(),
@@ -1529,8 +1624,8 @@ def _build_forecast_visual(*, panel_data, options):
1529
1624
  ),
1530
1625
  ) if curve else None,
1531
1626
  totals=_kpi_strip(
1532
- ("Days→90%", f"{float(panel_data.get('days_to_90pct') or 0.0):.1f}"),
1533
- ("Days→100%", f"{float(panel_data.get('days_to_100pct') or 0.0):.1f}"),
1627
+ ("Days→90%", _optional_days(panel_data.get('days_to_90pct'))),
1628
+ ("Days→100%", _optional_days(panel_data.get('days_to_100pct'))),
1534
1629
  ("End %", f"{float(panel_data.get('projected_end_pct') or 0.0)*100:.1f}%"),
1535
1630
  ),
1536
1631
  notes=notes,
@@ -1557,13 +1652,13 @@ def _build_forecast_detail(*, panel_data, options):
1557
1652
  # Budget metric rows + per-day projection rows, capped at top_n total.
1558
1653
  budget_rows = (
1559
1654
  _LS.Row(cells={"metric": _LS.TextCell("Avg to-date"),
1560
- "value": _LS.MoneyCell(float(budgets.get("avg") or 0.0))}),
1655
+ "value": _optional_money_cell(budgets.get("avg"))}),
1561
1656
  _LS.Row(cells={"metric": _LS.TextCell("Recent 24h"),
1562
- "value": _LS.MoneyCell(float(budgets.get("recent_24h") or 0.0))}),
1657
+ "value": _optional_money_cell(budgets.get("recent_24h"))}),
1563
1658
  _LS.Row(cells={"metric": _LS.TextCell("Budget to 90%"),
1564
- "value": _LS.MoneyCell(float(budgets.get("until_90pct") or 0.0))}),
1659
+ "value": _optional_money_cell(budgets.get("until_90pct"))}),
1565
1660
  _LS.Row(cells={"metric": _LS.TextCell("Budget to 100%"),
1566
- "value": _LS.MoneyCell(float(budgets.get("until_100pct") or 0.0))}),
1661
+ "value": _optional_money_cell(budgets.get("until_100pct"))}),
1567
1662
  )
1568
1663
  day_rows = tuple(
1569
1664
  _LS.Row(cells={
@@ -1597,8 +1692,8 @@ def _build_forecast_detail(*, panel_data, options):
1597
1692
  ),
1598
1693
  ) if curve else None,
1599
1694
  totals=_kpi_strip(
1600
- ("Days→90%", f"{float(panel_data.get('days_to_90pct') or 0.0):.1f}"),
1601
- ("Days→100%", f"{float(panel_data.get('days_to_100pct') or 0.0):.1f}"),
1695
+ ("Days→90%", _optional_days(panel_data.get('days_to_90pct'))),
1696
+ ("Days→100%", _optional_days(panel_data.get('days_to_100pct'))),
1602
1697
  ("End %", f"{float(panel_data.get('projected_end_pct') or 0.0)*100:.1f}%"),
1603
1698
  ),
1604
1699
  notes=notes,