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.
- package/CHANGELOG.md +73 -0
- package/README.md +8 -2
- package/bin/_cctally_alerts.py +13 -2
- package/bin/_cctally_cache.py +3 -1
- package/bin/_cctally_cache_report.py +103 -6
- package/bin/_cctally_dashboard.py +1140 -282
- package/bin/_cctally_dashboard_conversation.py +12 -0
- package/bin/_cctally_dashboard_envelope.py +53 -61
- package/bin/_cctally_dashboard_share.py +101 -29
- package/bin/_cctally_dashboard_sources.py +663 -192
- package/bin/_cctally_diagnosis.py +1172 -0
- package/bin/_cctally_diagnosis_sources.py +4054 -0
- package/bin/_cctally_diff.py +20 -0
- package/bin/_cctally_forecast.py +329 -111
- package/bin/_cctally_milestone_history.py +10 -2
- package/bin/_cctally_parser.py +84 -0
- package/bin/_cctally_project.py +155 -47
- package/bin/_cctally_quota.py +14 -0
- package/bin/_cctally_record.py +151 -71
- package/bin/_cctally_refresh.py +105 -93
- package/bin/_cctally_share.py +9 -2
- package/bin/_cctally_source_analytics.py +40 -4
- package/bin/_cctally_statusline.py +8 -1
- package/bin/_cctally_tui.py +425 -234
- package/bin/_lib_alert_scope.py +685 -0
- package/bin/_lib_alerts_payload.py +112 -7
- package/bin/_lib_blocks.py +12 -0
- package/bin/_lib_cache_report.py +110 -1
- package/bin/_lib_codex_conversation.py +14 -0
- package/bin/_lib_codex_conversation_query.py +22 -8
- package/bin/_lib_codex_pools.py +20 -8
- package/bin/_lib_conversation.py +6 -3
- package/bin/_lib_conversation_query.py +256 -69
- package/bin/_lib_dashboard_sources.py +212 -24
- package/bin/_lib_diagnosis.py +1261 -0
- package/bin/_lib_forecast.py +62 -4
- package/bin/_lib_perf.py +12 -0
- package/bin/_lib_pricing.py +8 -7
- package/bin/_lib_readme_refresh.py +26 -5
- package/bin/_lib_render.py +31 -3
- package/bin/_lib_share_templates.py +150 -55
- package/bin/_lib_snapshot_cache.py +71 -13
- package/bin/_lib_source_identity.py +50 -2
- package/bin/_lib_subscription_weeks.py +65 -0
- package/bin/cctally +103 -16
- package/bin/cctally-explain +5 -0
- package/dashboard/static/assets/dashboardStream.shared-worker-1XTMV3nr.js +1 -0
- package/dashboard/static/assets/index-Di2hljvB.css +1 -0
- package/dashboard/static/assets/index-XYCIWjVG.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +6 -1
- package/dashboard/static/assets/index-B5YfQEtn.css +0 -1
- package/dashboard/static/assets/index-Bt59nMMO.js +0 -97
package/bin/_lib_forecast.py
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
package/bin/_lib_pricing.py
CHANGED
|
@@ -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
|
-
#
|
|
740
|
-
#
|
|
741
|
-
#
|
|
742
|
-
#
|
|
743
|
-
#
|
|
744
|
-
#
|
|
745
|
-
#
|
|
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,
|
|
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
|
|
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
|
-
|
|
307
|
-
|
|
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
|
# --------------------------------------------------------------------------
|
package/bin/_lib_render.py
CHANGED
|
@@ -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) |
|
|
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,
|
|
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",
|
|
534
|
-
("$/% rate",
|
|
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",
|
|
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":
|
|
635
|
-
"pct":
|
|
636
|
-
"dpp":
|
|
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=
|
|
642
|
-
|
|
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)",
|
|
651
|
-
("Δ $ (3wk)",
|
|
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":
|
|
949
|
+
"value": _optional_money_cell(budgets.get("avg"))}),
|
|
850
950
|
_LS.Row(cells={"metric": _LS.TextCell("Recent 24h"),
|
|
851
|
-
"value":
|
|
951
|
+
"value": _optional_money_cell(budgets.get("recent_24h"))}),
|
|
852
952
|
_LS.Row(cells={"metric": _LS.TextCell("Budget to 90%"),
|
|
853
|
-
"value":
|
|
953
|
+
"value": _optional_money_cell(budgets.get("until_90pct"))}),
|
|
854
954
|
_LS.Row(cells={"metric": _LS.TextCell("Budget to 100%"),
|
|
855
|
-
"value":
|
|
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%",
|
|
871
|
-
("Days→100%",
|
|
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",
|
|
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",
|
|
1047
|
-
("$/% rate",
|
|
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",
|
|
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=
|
|
1141
|
-
|
|
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)",
|
|
1150
|
-
("Δ $ (3wk)",
|
|
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":
|
|
1181
|
-
"pct":
|
|
1182
|
-
"dpp":
|
|
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=
|
|
1188
|
-
|
|
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)",
|
|
1197
|
-
("Δ $ (3wk)",
|
|
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%",
|
|
1533
|
-
("Days→100%",
|
|
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":
|
|
1655
|
+
"value": _optional_money_cell(budgets.get("avg"))}),
|
|
1561
1656
|
_LS.Row(cells={"metric": _LS.TextCell("Recent 24h"),
|
|
1562
|
-
"value":
|
|
1657
|
+
"value": _optional_money_cell(budgets.get("recent_24h"))}),
|
|
1563
1658
|
_LS.Row(cells={"metric": _LS.TextCell("Budget to 90%"),
|
|
1564
|
-
"value":
|
|
1659
|
+
"value": _optional_money_cell(budgets.get("until_90pct"))}),
|
|
1565
1660
|
_LS.Row(cells={"metric": _LS.TextCell("Budget to 100%"),
|
|
1566
|
-
"value":
|
|
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%",
|
|
1601
|
-
("Days→100%",
|
|
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,
|