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
|
@@ -764,6 +764,18 @@ def _run_conversation_events_stream(
|
|
|
764
764
|
pass
|
|
765
765
|
seen = cached_sigs(files)
|
|
766
766
|
|
|
767
|
+
# #630 S2: `ready` above means "this connection is live-tailing"; it fires
|
|
768
|
+
# BEFORE resolve() and ingest(), so it does not mean a baseline exists,
|
|
769
|
+
# which is why six tests used to sleep a second here. This frame means the
|
|
770
|
+
# baseline IS established, so any subsequent growth will be caught. The
|
|
771
|
+
# client listens only for `ready`, `tail` and `error`
|
|
772
|
+
# (dashboard/web/src/hooks/useConversationLiveTail.ts), and EventSource
|
|
773
|
+
# dispatches only event types something listens for, so this is inert for
|
|
774
|
+
# the browser by construction. It exists for tests; do not delete it as
|
|
775
|
+
# unread. Contract: docs/dashboard-gotchas.md.
|
|
776
|
+
handler.wfile.write(b"event: baselined\ndata: {}\n\n")
|
|
777
|
+
handler.wfile.flush()
|
|
778
|
+
|
|
767
779
|
idle = 0.0
|
|
768
780
|
cycles = 0
|
|
769
781
|
while True:
|
|
@@ -385,8 +385,9 @@ def _envelope_rows_weekly(
|
|
|
385
385
|
# key uniqueness invariant matters.
|
|
386
386
|
rows = conn.execute(
|
|
387
387
|
f"""
|
|
388
|
-
SELECT week_start_date,
|
|
389
|
-
alerted_at, cumulative_cost_usd,
|
|
388
|
+
SELECT week_start_date, week_start_at, percent_threshold,
|
|
389
|
+
captured_at_utc, alerted_at, cumulative_cost_usd,
|
|
390
|
+
reset_event_id, account_key
|
|
390
391
|
FROM {descriptor.milestone_table}
|
|
391
392
|
WHERE alerted_at IS NOT NULL
|
|
392
393
|
ORDER BY {_CANON_ALERTED_AT} DESC
|
|
@@ -409,6 +410,16 @@ def _envelope_rows_weekly(
|
|
|
409
410
|
**account_fields("claude", r["account_key"]),
|
|
410
411
|
"context": {
|
|
411
412
|
"week_start_date": r["week_start_date"],
|
|
413
|
+
# The subscription week's RESET INSTANT, which the milestone
|
|
414
|
+
# row retains and `AlertEntry.context` already declares. A
|
|
415
|
+
# reader given only `week_start_date` can place the window no
|
|
416
|
+
# better than UTC midnight, which is wrong by the whole
|
|
417
|
+
# reset-hour offset for every account that does not reset at
|
|
418
|
+
# midnight. Empty string on a row that predates the column,
|
|
419
|
+
# mirroring `block_start_at` on the five-hour axis below: the
|
|
420
|
+
# key stays on the wire and the reader degrades to day
|
|
421
|
+
# granularity instead of inventing a clock reading.
|
|
422
|
+
"week_start_at": r["week_start_at"] or "",
|
|
412
423
|
"cumulative_cost_usd": cumulative,
|
|
413
424
|
"dollars_per_percent": dpp,
|
|
414
425
|
# Round-3: parallel to the 5h context block below — both
|
|
@@ -984,6 +995,30 @@ def _sync_failure_envelope(
|
|
|
984
995
|
"action": None,
|
|
985
996
|
}
|
|
986
997
|
|
|
998
|
+
text = error.casefold()
|
|
999
|
+
# Maintenance ownership supplies a more specific recovery action than a
|
|
1000
|
+
# secondary busy attribution from another leg in the same failed tick.
|
|
1001
|
+
# Classify those markers before the typed busy fallback so a blocked stale
|
|
1002
|
+
# owner is never told merely to checkpoint the WAL.
|
|
1003
|
+
if (
|
|
1004
|
+
"stale maintenance marker" in text
|
|
1005
|
+
or "stale repair marker" in text
|
|
1006
|
+
or "recovery could not claim maintenance" in text
|
|
1007
|
+
):
|
|
1008
|
+
return {
|
|
1009
|
+
"kind": "maintenance_stale",
|
|
1010
|
+
"label": "⚠ cache repair blocked",
|
|
1011
|
+
"detail": "A stale cache repair owner is blocking server sync.",
|
|
1012
|
+
"action": "cctally cache-sync --rebuild",
|
|
1013
|
+
}
|
|
1014
|
+
if "maintenance is in progress" in text:
|
|
1015
|
+
return {
|
|
1016
|
+
"kind": "maintenance_active",
|
|
1017
|
+
"label": "cache repair in progress",
|
|
1018
|
+
"detail": "Another cctally process is repairing the server cache.",
|
|
1019
|
+
"action": None,
|
|
1020
|
+
}
|
|
1021
|
+
|
|
987
1022
|
# #583 S2 §7. A locked cache.db is named rather than left in the generic
|
|
988
1023
|
# bucket. Typed attribution only — a blanket uncorrupted-cache branch
|
|
989
1024
|
# would be too broad, because ANY exception raised while reading cache.db
|
|
@@ -996,9 +1031,7 @@ def _sync_failure_envelope(
|
|
|
996
1031
|
# without it a tick carrying a corruption-shaped cache attribution AND a
|
|
997
1032
|
# separate busy one rendered "cache database busy" and dropped the
|
|
998
1033
|
# corruption message — the more urgent of the two, whose remedy is not
|
|
999
|
-
# interchangeable with a checkpoint.
|
|
1000
|
-
# attribution only: a busy attribution still outranks the legacy raw-text
|
|
1001
|
-
# corruption legs below, which is what Preserve 10 asks for.
|
|
1034
|
+
# interchangeable with a checkpoint.
|
|
1002
1035
|
if attributed("cache", sqlite_busy=True) and not attributed(
|
|
1003
1036
|
"cache", corruption=True
|
|
1004
1037
|
):
|
|
@@ -1012,26 +1045,6 @@ def _sync_failure_envelope(
|
|
|
1012
1045
|
"action": "cctally db checkpoint",
|
|
1013
1046
|
}
|
|
1014
1047
|
|
|
1015
|
-
text = error.casefold()
|
|
1016
|
-
if (
|
|
1017
|
-
"stale maintenance marker" in text
|
|
1018
|
-
or "stale repair marker" in text
|
|
1019
|
-
or "recovery could not claim maintenance" in text
|
|
1020
|
-
):
|
|
1021
|
-
return {
|
|
1022
|
-
"kind": "maintenance_stale",
|
|
1023
|
-
"label": "⚠ cache repair blocked",
|
|
1024
|
-
"detail": "A stale cache repair owner is blocking server sync.",
|
|
1025
|
-
"action": "cctally cache-sync --rebuild",
|
|
1026
|
-
}
|
|
1027
|
-
if "maintenance is in progress" in text:
|
|
1028
|
-
return {
|
|
1029
|
-
"kind": "maintenance_active",
|
|
1030
|
-
"label": "cache repair in progress",
|
|
1031
|
-
"detail": "Another cctally process is repairing the server cache.",
|
|
1032
|
-
"action": None,
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
1048
|
c = sys.modules["cctally"]
|
|
1036
1049
|
if (
|
|
1037
1050
|
attributed("cache", corruption=True)
|
|
@@ -1941,35 +1954,21 @@ def _claude_source_session_rows(envelope: dict) -> list:
|
|
|
1941
1954
|
"""The SOURCE-scoped Claude session row lists the overlay must cover.
|
|
1942
1955
|
|
|
1943
1956
|
Since source schema 10 (#583 S3 §4) there is exactly ONE such list, on the
|
|
1944
|
-
physical ``sources.claude`` entry.
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
``sourceRows.ts::collectSourceSessionRows`` still reads
|
|
1948
|
-
``providers?.claude ?? env.sources.claude.data``, and a tab still running a
|
|
1949
|
-
version 9 bundle after an in-place ``execvp`` update would otherwise get an
|
|
1950
|
-
ungated row list. Removing the null stub and this candidate together is
|
|
1951
|
-
filed as a residual.
|
|
1957
|
+
physical ``sources.claude`` entry. The null
|
|
1958
|
+
``sources.all.data.providers.claude`` compatibility stub is not a second
|
|
1959
|
+
row owner and must not receive request-local transcript data.
|
|
1952
1960
|
"""
|
|
1953
1961
|
sources = envelope.get("sources")
|
|
1954
1962
|
if not isinstance(sources, Mapping):
|
|
1955
1963
|
return []
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
if not isinstance(data, Mapping):
|
|
1965
|
-
continue
|
|
1966
|
-
sessions = data.get("sessions")
|
|
1967
|
-
if not isinstance(sessions, Mapping):
|
|
1968
|
-
continue
|
|
1969
|
-
rows = sessions.get("rows")
|
|
1970
|
-
if isinstance(rows, list):
|
|
1971
|
-
out.append(rows)
|
|
1972
|
-
return out
|
|
1964
|
+
data = (sources.get("claude") or {}).get("data")
|
|
1965
|
+
if not isinstance(data, Mapping):
|
|
1966
|
+
return []
|
|
1967
|
+
sessions = data.get("sessions")
|
|
1968
|
+
if not isinstance(sessions, Mapping):
|
|
1969
|
+
return []
|
|
1970
|
+
rows = sessions.get("rows")
|
|
1971
|
+
return [rows] if isinstance(rows, list) else []
|
|
1973
1972
|
|
|
1974
1973
|
|
|
1975
1974
|
def _overlay_claude_source_session_titles(
|
|
@@ -2021,9 +2020,9 @@ def _codex_source_session_rows(envelope: dict) -> list:
|
|
|
2021
2020
|
label overlay must cover it under the same per-request transcript gate.
|
|
2022
2021
|
|
|
2023
2022
|
Since source schema 10 (#583 S3 §4) those lists all live on the physical
|
|
2024
|
-
``sources.codex`` entry. ``sources.all.data.providers.codex``
|
|
2025
|
-
|
|
2026
|
-
|
|
2023
|
+
``sources.codex`` entry. The null ``sources.all.data.providers.codex``
|
|
2024
|
+
compatibility stub is not a second row owner and must not receive
|
|
2025
|
+
request-local transcript data.
|
|
2027
2026
|
"""
|
|
2028
2027
|
sources = envelope.get("sources")
|
|
2029
2028
|
if not isinstance(sources, Mapping):
|
|
@@ -2051,14 +2050,7 @@ def _codex_source_session_rows(envelope: dict) -> list:
|
|
|
2051
2050
|
if isinstance(rows, list):
|
|
2052
2051
|
out.append(rows)
|
|
2053
2052
|
|
|
2054
|
-
|
|
2055
|
-
all_data = (sources.get("all") or {}).get("data")
|
|
2056
|
-
if isinstance(all_data, Mapping):
|
|
2057
|
-
providers = all_data.get("providers")
|
|
2058
|
-
if isinstance(providers, Mapping):
|
|
2059
|
-
candidates.append(providers.get("codex"))
|
|
2060
|
-
for data in candidates:
|
|
2061
|
-
append_rows(data)
|
|
2053
|
+
append_rows((sources.get("codex") or {}).get("data"))
|
|
2062
2054
|
return out
|
|
2063
2055
|
|
|
2064
2056
|
|
|
@@ -58,6 +58,25 @@ def _share_load_lib(*args, **kwargs):
|
|
|
58
58
|
return sys.modules["cctally"]._share_load_lib(*args, **kwargs)
|
|
59
59
|
|
|
60
60
|
|
|
61
|
+
def _optional_float(value):
|
|
62
|
+
"""``float(value)`` when there is a value, ``None`` when there is not.
|
|
63
|
+
|
|
64
|
+
#620 S1 D5. The idiom this replaces — ``float(getattr(x, "f", 0.0) or
|
|
65
|
+
0.0)`` — collapses three distinct states (a real zero, an explicit
|
|
66
|
+
``None``, and a missing attribute) onto one number, so a withheld
|
|
67
|
+
measurement leaves the builder indistinguishable from a measured zero.
|
|
68
|
+
Written as a helper rather than repeated inline because the reason is
|
|
69
|
+
the same at every site and the inline form reads as a default.
|
|
70
|
+
"""
|
|
71
|
+
if value is None:
|
|
72
|
+
return None
|
|
73
|
+
# No `except (TypeError, ValueError): return None`. On a surface whose
|
|
74
|
+
# entire purpose is telling "absent" apart from "present", reporting a
|
|
75
|
+
# malformed value as withheld is the same conflation the helper removes.
|
|
76
|
+
# A malformed value is a defect and must surface as one (#620 S1).
|
|
77
|
+
return float(value)
|
|
78
|
+
|
|
79
|
+
|
|
61
80
|
def _share_now_utc(*args, **kwargs):
|
|
62
81
|
return sys.modules["cctally"]._share_now_utc(*args, **kwargs)
|
|
63
82
|
|
|
@@ -631,9 +650,19 @@ def _build_weekly_share_panel_data(options: dict,
|
|
|
631
650
|
wsa = getattr(r, "week_start_at", "") or ""
|
|
632
651
|
start_date = wsa[:10] if isinstance(wsa, str) and len(wsa) >= 10 else wsa
|
|
633
652
|
cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
653
|
+
# `WeeklyPeriodRow.used_pct` is `float | None` — `None` when the week
|
|
654
|
+
# has a cost snapshot but no usage snapshot. Coercing it to zero
|
|
655
|
+
# published `0.0%` for a week nothing measured, which reads as a real
|
|
656
|
+
# zero-usage week. Same defect as the rate below, same treatment.
|
|
657
|
+
used_pct_raw = _optional_float(getattr(r, "used_pct", None))
|
|
658
|
+
used_pct = None if used_pct_raw is None else used_pct_raw / 100.0
|
|
659
|
+
# `WeeklyPeriodRow.dollar_per_pct` is `float | None` — it is `None`
|
|
660
|
+
# whenever the week has no usage percentage to divide by. Coercing it
|
|
661
|
+
# to zero here published `$0.000` for a rate nothing measured, which
|
|
662
|
+
# is the same fabrication #620 S1 D5 removed on the current-week and
|
|
663
|
+
# trend panels. Pre-existing rather than introduced by D5, and swept
|
|
664
|
+
# with them because it is the same defect on the same surface.
|
|
665
|
+
dpp = _optional_float(getattr(r, "dollar_per_pct", None))
|
|
637
666
|
# Per-week top_projects: WeeklyPeriodRow doesn't carry a
|
|
638
667
|
# per-project rollup, but `week_start_at` / `week_end_at` give us
|
|
639
668
|
# an exact range — aggregate session_entries once per week so the
|
|
@@ -682,7 +711,9 @@ def _build_current_week_share_panel_data(options: dict,
|
|
|
682
711
|
return {
|
|
683
712
|
"kpi_cost_usd": 0.0,
|
|
684
713
|
"kpi_pct_used": 0.0,
|
|
685
|
-
|
|
714
|
+
# With no current week at all there is no rate to report, so
|
|
715
|
+
# this is withheld for the same reason as the populated path.
|
|
716
|
+
"kpi_dollar_per_pct": None,
|
|
686
717
|
"kpi_days_remaining": 0.0,
|
|
687
718
|
"daily_progression": [],
|
|
688
719
|
"top_projects": [],
|
|
@@ -728,7 +759,13 @@ def _build_current_week_share_panel_data(options: dict,
|
|
|
728
759
|
return {
|
|
729
760
|
"kpi_cost_usd": float(getattr(cw, "spent_usd", 0.0) or 0.0),
|
|
730
761
|
"kpi_pct_used": used_pct,
|
|
731
|
-
|
|
762
|
+
# #620 S1 D5: `TuiCurrentWeek.dollars_per_percent` is `float | None`,
|
|
763
|
+
# and `float(... or 0.0)` turned both a missing attribute and an
|
|
764
|
+
# explicit None into $0.00 — the fabrication D5 removes, restored one
|
|
765
|
+
# layer below the fix. A withheld rate stays withheld; the template
|
|
766
|
+
# renders it as `n/a`.
|
|
767
|
+
"kpi_dollar_per_pct": _optional_float(
|
|
768
|
+
getattr(cw, "dollars_per_percent", None)),
|
|
732
769
|
"kpi_days_remaining": days_remaining,
|
|
733
770
|
"daily_progression": progression,
|
|
734
771
|
"top_projects": top_projects,
|
|
@@ -751,25 +788,41 @@ def _build_trend_share_panel_data(options: dict,
|
|
|
751
788
|
wsa.strftime("%Y-%m-%d") if isinstance(wsa, dt.datetime)
|
|
752
789
|
else (str(wsa)[:10] if wsa else "")
|
|
753
790
|
)
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
791
|
+
# `TuiTrendRow.used_pct` is `float | None` (phantom weeks — a cost
|
|
792
|
+
# snapshot with no usage snapshot). See the weekly builder above.
|
|
793
|
+
used_pct_raw = _optional_float(getattr(r, "used_pct", None))
|
|
794
|
+
used_pct = None if used_pct_raw is None else used_pct_raw / 100.0
|
|
795
|
+
# #620 S1 D5, as above. Here the fabricated zero propagated further
|
|
796
|
+
# than the KPI: `cost_usd` is derived from the rate, so a zeroed rate
|
|
797
|
+
# also published a zero weekly cost for a week whose cost is unknown.
|
|
798
|
+
dpp = _optional_float(getattr(r, "dollars_per_percent", None))
|
|
757
799
|
weeks.append({
|
|
758
800
|
"start_date": start_date,
|
|
759
|
-
|
|
801
|
+
# A rate times a percentage is a cost only when both are known.
|
|
802
|
+
# With `used_pct` coerced to zero, a week with a real rate and no
|
|
803
|
+
# measured percentage published a $0.00 cost.
|
|
804
|
+
"cost_usd": (
|
|
805
|
+
None if (dpp is None or used_pct is None)
|
|
806
|
+
else dpp * (used_pct * 100.0)
|
|
807
|
+
), # ≈ row total
|
|
760
808
|
"pct_used": used_pct,
|
|
761
809
|
"dollar_per_pct": dpp,
|
|
762
810
|
})
|
|
763
811
|
# Compute 3-week delta: compare last row vs row-4-from-end.
|
|
764
|
-
delta
|
|
812
|
+
# A delta between two weeks is a fact only when both weeks are known.
|
|
813
|
+
# The previous default published `0.0`, which states "no change" — the
|
|
814
|
+
# same fabrication as a zeroed rate, one aggregate up (#620 S1 D5).
|
|
815
|
+
delta: dict = {"dpp_change_pct": None, "cost_change_usd": None}
|
|
765
816
|
if len(weeks) >= 4:
|
|
766
817
|
cur = weeks[-1]
|
|
767
818
|
ref = weeks[-4]
|
|
768
|
-
if ref["dollar_per_pct"]:
|
|
819
|
+
if ref["dollar_per_pct"] and cur["dollar_per_pct"] is not None:
|
|
769
820
|
delta["dpp_change_pct"] = (
|
|
770
821
|
(cur["dollar_per_pct"] - ref["dollar_per_pct"]) / ref["dollar_per_pct"]
|
|
771
822
|
)
|
|
772
|
-
delta
|
|
823
|
+
# A delta between two costs is only a fact when both are known.
|
|
824
|
+
if cur["cost_usd"] is not None and ref["cost_usd"] is not None:
|
|
825
|
+
delta["cost_change_usd"] = cur["cost_usd"] - ref["cost_usd"]
|
|
773
826
|
return {"weeks": weeks, "delta_3_weeks": delta}
|
|
774
827
|
|
|
775
828
|
|
|
@@ -923,8 +976,10 @@ def _build_forecast_share_panel_data(options: dict,
|
|
|
923
976
|
if fc is None:
|
|
924
977
|
return {
|
|
925
978
|
"projected_end_pct": 0.0,
|
|
926
|
-
|
|
927
|
-
|
|
979
|
+
# With no forecast at all there is no rate, so neither ceiling has
|
|
980
|
+
# a distance — the same withholding the populated path performs.
|
|
981
|
+
"days_to_100pct": None,
|
|
982
|
+
"days_to_90pct": None,
|
|
928
983
|
"daily_budgets": {
|
|
929
984
|
"avg": 0.0, "recent_24h": 0.0,
|
|
930
985
|
"until_90pct": 0.0, "until_100pct": 0.0,
|
|
@@ -943,10 +998,18 @@ def _build_forecast_share_panel_data(options: dict,
|
|
|
943
998
|
r_recent = float(r_recent_raw) if r_recent_raw is not None else r_avg
|
|
944
999
|
# End-of-week projected %
|
|
945
1000
|
projected_end_pct = (p_now + r_avg * remaining_hours) / 100.0
|
|
946
|
-
# Days to ceilings (simple inverse: hours-to-target / 24)
|
|
947
|
-
|
|
948
|
-
|
|
1001
|
+
# Days to ceilings (simple inverse: hours-to-target / 24).
|
|
1002
|
+
# The two exit conditions are opposite facts and must not share a value.
|
|
1003
|
+
# `p_now >= target_pct` means the target is already reached, and zero days
|
|
1004
|
+
# to it is true. `r_avg <= 0` means no rate was observed, so the target is
|
|
1005
|
+
# not reachable on any timeline this data describes — and in the no-usage
|
|
1006
|
+
# state BOTH hold (`p_now == 0`, `r_avg == 0`), so the shared `0.0`
|
|
1007
|
+
# rendered `Days->90% 0.0`, stating the opposite of the truth.
|
|
1008
|
+
def _days_to_ceiling(target_pct: float) -> "float | None":
|
|
1009
|
+
if p_now >= target_pct:
|
|
949
1010
|
return 0.0
|
|
1011
|
+
if r_avg <= 0:
|
|
1012
|
+
return None
|
|
950
1013
|
hours = (target_pct - p_now) / r_avg
|
|
951
1014
|
return max(0.0, hours / 24.0)
|
|
952
1015
|
days_to_100 = _days_to_ceiling(100.0)
|
|
@@ -954,27 +1017,36 @@ def _build_forecast_share_panel_data(options: dict,
|
|
|
954
1017
|
# Daily budgets — prefer ForecastView's pre-routed pair (issue #57)
|
|
955
1018
|
# when available; otherwise replay the legacy ``fc.budgets`` scan
|
|
956
1019
|
# inline so positionally-constructed fixture snapshots still work.
|
|
957
|
-
|
|
958
|
-
|
|
1020
|
+
# Unknown until a branch below supplies one. A ceiling budget that no
|
|
1021
|
+
# `BudgetRow` describes is not a $0.00/day budget.
|
|
1022
|
+
budgets: dict = {"avg": None, "recent_24h": None,
|
|
1023
|
+
"until_90pct": None, "until_100pct": None}
|
|
1024
|
+
# #620 S1 D5: no `or 0.0` on either branch. `ForecastView.budget_*_per_day_usd`
|
|
1025
|
+
# and `BudgetRow.dollars_per_day` are both `None` whenever the rate is
|
|
1026
|
+
# withheld, and coercing them republished $0.00 on exactly the surface
|
|
1027
|
+
# whose message is that nothing was measured.
|
|
959
1028
|
if fc_view is not None:
|
|
960
|
-
budgets["until_100pct"] =
|
|
961
|
-
fc_view
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
fc_view.budget_90_per_day_usd or 0.0,
|
|
965
|
-
)
|
|
1029
|
+
budgets["until_100pct"] = _optional_float(
|
|
1030
|
+
getattr(fc_view, "budget_100_per_day_usd", None))
|
|
1031
|
+
budgets["until_90pct"] = _optional_float(
|
|
1032
|
+
getattr(fc_view, "budget_90_per_day_usd", None))
|
|
966
1033
|
else:
|
|
967
1034
|
for b in getattr(fc, "budgets", None) or []:
|
|
968
1035
|
tp = getattr(b, "target_percent", None)
|
|
969
|
-
dpd =
|
|
1036
|
+
dpd = _optional_float(getattr(b, "dollars_per_day", None))
|
|
970
1037
|
if tp == 100:
|
|
971
1038
|
budgets["until_100pct"] = dpd
|
|
972
1039
|
elif tp == 90:
|
|
973
1040
|
budgets["until_90pct"] = dpd
|
|
974
1041
|
# avg / recent_24h: derive from dollars-per-percent × r_avg/r_recent.
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1042
|
+
# #620 S1 D5: the `or 0.0` fabrication is REMOVED rather than moved. With
|
|
1043
|
+
# no observed usage there is no rate, so a dollars-per-day figure derived
|
|
1044
|
+
# from it is unavailable; substituting zero here would restore the same
|
|
1045
|
+
# defect one layer below the fix.
|
|
1046
|
+
_raw_dpp = getattr(inputs, "dollars_per_percent", None) if inputs else None
|
|
1047
|
+
dpp = None if _raw_dpp is None else float(_raw_dpp)
|
|
1048
|
+
budgets["avg"] = None if dpp is None else dpp * r_avg * 24.0
|
|
1049
|
+
budgets["recent_24h"] = None if dpp is None else dpp * r_recent * 24.0
|
|
978
1050
|
# Projection curve — 7-day forward, using r_avg
|
|
979
1051
|
today = _share_now_utc().date()
|
|
980
1052
|
projection_curve: list[dict] = []
|