cctally 1.65.0 → 1.67.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 (54) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +6 -4
  3. package/bin/_cctally_alerts.py +1 -1
  4. package/bin/_cctally_cache.py +203 -22
  5. package/bin/_cctally_cache_report.py +7 -5
  6. package/bin/_cctally_core.py +125 -14
  7. package/bin/_cctally_dashboard.py +495 -4893
  8. package/bin/_cctally_dashboard_cache_report.py +714 -0
  9. package/bin/_cctally_dashboard_conversation.py +830 -0
  10. package/bin/_cctally_dashboard_envelope.py +1520 -0
  11. package/bin/_cctally_dashboard_share.py +2012 -0
  12. package/bin/_cctally_db.py +158 -5
  13. package/bin/_cctally_doctor.py +104 -3
  14. package/bin/_cctally_five_hour.py +19 -3
  15. package/bin/_cctally_forecast.py +118 -161
  16. package/bin/_cctally_parser.py +1003 -777
  17. package/bin/_cctally_percent_breakdown.py +1 -1
  18. package/bin/_cctally_pricing_check.py +39 -0
  19. package/bin/_cctally_project.py +18 -42
  20. package/bin/_cctally_record.py +279 -274
  21. package/bin/_cctally_reporting.py +1 -1
  22. package/bin/_cctally_setup.py +150 -43
  23. package/bin/_cctally_sync_week.py +1 -1
  24. package/bin/_cctally_telemetry.py +3 -5
  25. package/bin/_cctally_transcript.py +191 -0
  26. package/bin/_cctally_tui.py +42 -18
  27. package/bin/_lib_alert_dispatch.py +1 -1
  28. package/bin/_lib_blocks.py +6 -1
  29. package/bin/_lib_conversation_anon.py +254 -0
  30. package/bin/_lib_conversation_query.py +66 -0
  31. package/bin/_lib_credit.py +133 -0
  32. package/bin/_lib_diff_kernel.py +19 -7
  33. package/bin/_lib_doctor.py +150 -1
  34. package/bin/_lib_five_hour.py +61 -0
  35. package/bin/_lib_forecast.py +144 -0
  36. package/bin/_lib_json_envelope.py +38 -0
  37. package/bin/_lib_jsonl.py +115 -73
  38. package/bin/_lib_log.py +96 -0
  39. package/bin/_lib_pricing.py +47 -1
  40. package/bin/_lib_pricing_check.py +25 -3
  41. package/bin/_lib_record.py +179 -0
  42. package/bin/_lib_render.py +26 -11
  43. package/bin/_lib_snapshot_cache.py +61 -0
  44. package/bin/_lib_view_models.py +7 -1
  45. package/bin/cctally +59 -7
  46. package/bin/cctally-dashboard +2 -2
  47. package/bin/cctally-statusline +1 -0
  48. package/bin/cctally-transcript +5 -0
  49. package/bin/cctally-tui +2 -2
  50. package/dashboard/static/assets/index-BybNp_Di.js +80 -0
  51. package/dashboard/static/assets/{index-DQWNrIqu.css → index-DgAmLK65.css} +1 -1
  52. package/dashboard/static/dashboard.html +2 -2
  53. package/package.json +13 -1
  54. package/dashboard/static/assets/index-CJTUCpKt.js +0 -80
@@ -0,0 +1,2012 @@
1
+ """Dashboard share feature (#279 S5 F1): shims + builders + handler impls.
2
+
3
+ Consumer-only sibling of ``bin/_cctally_dashboard.py`` — it re-imports every
4
+ name below, so ``bin/cctally``'s re-exports and the share pytest files
5
+ (``tests/test_share_top_projects.py``, ``…_period_resolver.py``,
6
+ ``…_v2_panel_ordering.py``) keep resolving unchanged (spec §2/§3).
7
+
8
+ What lives here (spec §3):
9
+ - the five share-CLI accessor shims (``_share_load_lib``, ``_share_now_utc``,
10
+ ``_share_now_utc_iso``, ``_share_history_recipe_id``, ``_share_iso`` — each
11
+ still forwards late-binding to ``sys.modules["cctally"]``, so moving the
12
+ shim preserves the ``ns["X"]`` patch surface);
13
+ - ``_SHARE_POST_MAX_BYTES`` + the share-panel period constants
14
+ (``_SHARE_PANELS_PERIOD_FIXED`` / ``_SHARE_PANELS_PERIOD_OVERRIDABLE``);
15
+ the dashboard-bind validators stay in the dashboard;
16
+ - the share-period override pipeline + the per-panel share-data builders;
17
+ - the ten share handler methods as ``*_impl(handler, …)`` free functions
18
+ (``self.`` → ``handler.`` throughout; ``type(self)`` → ``type(handler)``),
19
+ the file's own ``_handle_get_project_detail_impl`` precedent. The
20
+ dashboard keeps ten thin bound delegators on ``DashboardHTTPHandler``.
21
+
22
+ Cross-module reaches (spec §2.1 "fully-qualify cross-module refs"): the
23
+ cctally-forwarding accessor shims the moved code called by bare name
24
+ (``load_config``, ``get_display_tz_pref``, ``config_writer_lock``, and
25
+ ``c = _cctally()``) are inlined to their ``sys.modules["cctally"].X``
26
+ call-time reach — identical behavior, ns["X"] patch surface preserved (none
27
+ is dashboard-object-patched; audited). ``get_claude_session_entries`` is
28
+ reached at call time via ``sys.modules["_cctally_dashboard"]`` (spec §3 gate
29
+ / plan Step 2): the share tests patch the cctally namespace, but the
30
+ dashboard-object reach is strictly stronger (it ALSO honors a dashboard-
31
+ module-object patch, which the rebuild-parity cache-report tests use) and
32
+ cycle-free.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import datetime as dt
37
+ import json
38
+ import pathlib
39
+ import sqlite3
40
+ import sys
41
+ from dataclasses import replace
42
+ from zoneinfo import ZoneInfo
43
+
44
+ from _cctally_core import open_db, parse_iso_datetime
45
+ from _cctally_config import save_config, _load_config_unlocked
46
+ from _lib_fmt import stable_sum
47
+ from _lib_pricing import _calculate_entry_cost
48
+ from _lib_five_hour import _canonical_5h_window_key
49
+
50
+
51
+ # Share-CLI helpers consumed by the dashboard's share-data builders.
52
+ def _share_load_lib(*args, **kwargs):
53
+ return sys.modules["cctally"]._share_load_lib(*args, **kwargs)
54
+
55
+
56
+ def _share_now_utc(*args, **kwargs):
57
+ return sys.modules["cctally"]._share_now_utc(*args, **kwargs)
58
+
59
+
60
+ def _share_now_utc_iso(*args, **kwargs):
61
+ return sys.modules["cctally"]._share_now_utc_iso(*args, **kwargs)
62
+
63
+
64
+ def _share_history_recipe_id(*args, **kwargs):
65
+ return sys.modules["cctally"]._share_history_recipe_id(*args, **kwargs)
66
+
67
+
68
+ def _share_iso(*args, **kwargs):
69
+ return sys.modules["cctally"]._share_iso(*args, **kwargs)
70
+
71
+
72
+ # #279 S1 F3: cap on /api/share/* POST bodies. The share composer sends bigger
73
+ # payloads than the 4 KB settings POSTs (a multi-panel compose recipe), but
74
+ # must still be bounded — 64 KiB comfortably exceeds any real payload (render is
75
+ # one panel; compose is ~8 panels each with a small options recipe).
76
+ _SHARE_POST_MAX_BYTES = 64 * 1024
77
+
78
+
79
+ # === Share-period override pipeline (dashboard-internal share helpers) =====
80
+ # Used by DashboardHTTPHandler's POST /api/share/render to rebuild a single
81
+ # panel's DataSnapshot against a shifted ``now_utc`` (kind=previous) or a
82
+ # custom date range (kind=custom). Pre-extract location: bin/cctally L13495.
83
+
84
+ _SHARE_PANELS_PERIOD_FIXED = ("forecast", "current-week", "sessions")
85
+ # Panels whose period is intrinsic to the panel's identity. We accept
86
+ # `kind="current"` (= no override) and reject anything else with 400.
87
+
88
+ _SHARE_PANELS_PERIOD_OVERRIDABLE = ("weekly", "daily", "monthly", "trend", "blocks")
89
+
90
+
91
+ def _share_resolve_period(panel: str, options: dict):
92
+ """Return (now_utc_override, start_override, error_dict) for the period.
93
+
94
+ - `(None, None, None)` — no override needed (period absent or
95
+ `kind="current"`). Caller continues with the cached DataSnapshot.
96
+ - `(datetime, None, None)` — `kind="previous"`. Caller rebuilds with
97
+ this `now_utc`; window length stays at the panel default.
98
+ - `(datetime, datetime, None)` — `kind="custom"`. Caller rebuilds
99
+ with `now_utc = end_dt` AND a derived window length spanning
100
+ `[start_dt, end_dt]` (computed by `_share_apply_period_override`
101
+ per panel). Spec §6.3 advertises "Custom (start–end pickers)";
102
+ honoring the start picker means the rendered window's left edge
103
+ moves with it. The 2-tuple form silently ignored `start_dt`.
104
+ - `(None, None, {...})` — validation failure; caller emits 400.
105
+
106
+ `parse_iso_datetime` (the same parser used by every other share
107
+ surface) accepts trailing `Z` / `+HH:MM` and naive forms. Naive
108
+ inputs are treated as UTC by `parse_iso_datetime` and downstream
109
+ UTC-fixup, so a date-only string like ``"2026-05-04"`` lands at
110
+ midnight UTC.
111
+ """
112
+ period = options.get("period")
113
+ if period is None or not isinstance(period, dict):
114
+ # Absent → no override, defaults to current. (Permissive: the
115
+ # UI always sends a period block, but older basket recipes /
116
+ # CLI parity may omit it.)
117
+ return (None, None, None)
118
+ kind = period.get("kind", "current")
119
+ if kind not in ("current", "previous", "custom"):
120
+ return (None, None, {"error": f"unknown period kind: {kind!r}",
121
+ "field": "options.period.kind"})
122
+ if panel in _SHARE_PANELS_PERIOD_FIXED:
123
+ if kind != "current":
124
+ return (None, None, {
125
+ "error": (f"panel {panel!r} only supports period kind='current'; "
126
+ f"got {kind!r}"),
127
+ "field": "options.period.kind",
128
+ })
129
+ return (None, None, None)
130
+ # Overridable panels — handle each kind.
131
+ if kind == "current":
132
+ return (None, None, None)
133
+ if kind == "previous":
134
+ delta = _share_previous_period_delta(panel)
135
+ return (_share_now_utc() - delta, None, None)
136
+ # kind == "custom"
137
+ start_str = period.get("start")
138
+ end_str = period.get("end")
139
+ if not isinstance(start_str, str) or not start_str \
140
+ or not isinstance(end_str, str) or not end_str:
141
+ return (None, None, {
142
+ "error": "custom period requires non-empty start + end ISO dates",
143
+ "field": "options.period",
144
+ })
145
+ try:
146
+ start_dt = parse_iso_datetime(start_str, "options.period.start")
147
+ end_dt = parse_iso_datetime(end_str, "options.period.end")
148
+ except ValueError as exc:
149
+ return (None, None, {"error": f"invalid period date: {exc}",
150
+ "field": "options.period"})
151
+ if end_dt <= start_dt:
152
+ return (None, None, {
153
+ "error": ("custom period end must be strictly after start "
154
+ f"(got start={start_str!r}, end={end_str!r})"),
155
+ "field": "options.period",
156
+ })
157
+ return (end_dt, start_dt, None)
158
+
159
+
160
+ def _share_custom_window_n(panel: str, start_dt: "dt.datetime",
161
+ end_dt: "dt.datetime") -> int:
162
+ """Per-panel window length covering `[start_dt, end_dt]`, min 1.
163
+
164
+ Each overridable panel exposes a different unit:
165
+ - weekly / trend → weeks
166
+ - daily → days (inclusive)
167
+ - monthly → calendar months (inclusive)
168
+ Blocks doesn't use this helper — its builder is window-anchored via
169
+ `week_start_at`/`week_end_at`, not `n`, so we pass `start_dt`/`end_dt`
170
+ directly to `_dashboard_build_blocks_panel`.
171
+
172
+ Inputs are timezone-aware UTC datetimes (`parse_iso_datetime` UTCs
173
+ naive inputs upstream). Math is purely on the timedelta + calendar
174
+ diffs; `_dashboard_build_monthly_periods` does its own display-tz
175
+ bucketing on the resulting window.
176
+ """
177
+ import math as _math
178
+ delta_seconds = (end_dt - start_dt).total_seconds()
179
+ delta_days = _math.ceil(delta_seconds / 86400.0)
180
+ if panel in ("weekly", "trend"):
181
+ return max(1, _math.ceil(delta_days / 7))
182
+ if panel == "daily":
183
+ return max(1, int(delta_days))
184
+ if panel == "monthly":
185
+ months = ((end_dt.year - start_dt.year) * 12
186
+ + (end_dt.month - start_dt.month) + 1)
187
+ return max(1, months)
188
+ # Shouldn't reach here — `_share_apply_period_override` handles
189
+ # blocks separately. Defensive: return 1 rather than raising.
190
+ return 1
191
+
192
+
193
+ def _share_previous_period_delta(panel: str) -> "dt.timedelta":
194
+ """How far back `now_utc` shifts for `kind='previous'` on each panel.
195
+
196
+ weekly/daily: 7 days. monthly: one whole month worth (we shift to
197
+ the last day of the previous month at call time to handle variable
198
+ month length, so this is unused — the caller routes through
199
+ `_share_resolve_period` which special-cases monthly). trend: 8 weeks
200
+ (one trend window). blocks: 5 hours (one block).
201
+ """
202
+ if panel == "weekly":
203
+ return dt.timedelta(days=7)
204
+ if panel == "daily":
205
+ return dt.timedelta(days=7)
206
+ if panel == "monthly":
207
+ return dt.timedelta(days=30) # close-enough for the resolver;
208
+ # see _share_resolve_period_monthly
209
+ # below for the calendar-aware
210
+ # version when needed.
211
+ if panel == "trend":
212
+ return dt.timedelta(days=8 * 7)
213
+ if panel == "blocks":
214
+ return dt.timedelta(hours=5)
215
+ raise ValueError(f"_share_previous_period_delta: no delta for panel {panel!r}")
216
+
217
+
218
+ def _share_apply_period_override(panel: str, options: dict,
219
+ snap: "DataSnapshot | None"):
220
+ """Return (snap_or_None, error_dict_or_None).
221
+
222
+ Walks `_share_resolve_period`, then re-builds the panel's DataSnapshot
223
+ field from DB when an override is requested. `dataclasses.replace`
224
+ yields a shallow copy with one field swapped. Returns the original
225
+ `snap` unchanged when no override applies.
226
+ """
227
+ if snap is None:
228
+ # No cached snapshot to override against — return None unchanged
229
+ # and let the panel_data builder's empty-snapshot path handle it.
230
+ # Still validate the period option so the user gets a 400 on
231
+ # malformed input even before the sync thread's first tick.
232
+ _, _, err = _share_resolve_period(panel, options)
233
+ return (snap, err)
234
+ now_override, start_override, err = _share_resolve_period(panel, options)
235
+ if err is not None:
236
+ return (None, err)
237
+ if now_override is None:
238
+ return (snap, None)
239
+ # For `kind="custom"`, derive a per-panel window length covering
240
+ # `[start_override, now_override]` so the rendered window honors the
241
+ # Start picker (spec §6.3). For `kind="previous"`, `start_override`
242
+ # is None → window length stays at the panel's default.
243
+ n_override = (
244
+ _share_custom_window_n(panel, start_override, now_override)
245
+ if start_override is not None else None
246
+ )
247
+ import dataclasses as _dc
248
+ # Cross-module accessor — moved-function calls that are ALSO
249
+ # monkeypatched in tests (``_dashboard_build_*``, ``_tui_build_trend``)
250
+ # must resolve through cctally's namespace so ``monkeypatch.setitem(ns,
251
+ # "_dashboard_build_weekly_periods", spy)`` propagates here per spec §5.6.
252
+ c = sys.modules["cctally"]
253
+ conn = open_db()
254
+ try:
255
+ if panel == "weekly":
256
+ kwargs: dict = {"skip_sync": True}
257
+ if n_override is not None:
258
+ kwargs["n"] = n_override
259
+ rows = c._dashboard_build_weekly_periods(conn, now_override, **kwargs)
260
+ return (_dc.replace(snap, weekly_periods=rows), None)
261
+ if panel == "daily":
262
+ display_tz_name = options.get("display_tz", "Etc/UTC")
263
+ try:
264
+ display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
265
+ except Exception:
266
+ display_tz = None
267
+ kwargs = {"skip_sync": True, "display_tz": display_tz}
268
+ if n_override is not None:
269
+ kwargs["n"] = n_override
270
+ rows = c._dashboard_build_daily_panel(conn, now_override, **kwargs)
271
+ return (_dc.replace(snap, daily_panel=rows), None)
272
+ if panel == "monthly":
273
+ kwargs = {"skip_sync": True}
274
+ if n_override is not None:
275
+ kwargs["n"] = n_override
276
+ rows = c._dashboard_build_monthly_periods(conn, now_override, **kwargs)
277
+ return (_dc.replace(snap, monthly_periods=rows), None)
278
+ if panel == "trend":
279
+ kwargs = {"skip_sync": True}
280
+ if n_override is not None:
281
+ kwargs["count"] = n_override
282
+ rows = c._tui_build_trend(conn, now_override, **kwargs)
283
+ return (_dc.replace(snap, trend=rows), None)
284
+ if panel == "blocks":
285
+ # `_dashboard_build_blocks_panel` is window-anchored via
286
+ # `week_start_at`/`week_end_at`, not `n`. For `kind='custom'`,
287
+ # use the user's [start_dt, end_dt] verbatim. For
288
+ # `kind='previous'`, fall back to a 7-day window ending at
289
+ # the override `now_utc` (the spec's prior-block semantics —
290
+ # intentionally NOT aligned to subscription-week boundaries
291
+ # since the share period override is wall-clock-aware, not
292
+ # quota-aware).
293
+ if start_override is not None:
294
+ week_start_at = start_override
295
+ week_end_at = now_override
296
+ else:
297
+ week_start_at = now_override - dt.timedelta(days=7)
298
+ week_end_at = now_override
299
+ rows = c._dashboard_build_blocks_panel(
300
+ conn, now_override,
301
+ week_start_at=week_start_at,
302
+ week_end_at=week_end_at,
303
+ skip_sync=True,
304
+ )
305
+ return (_dc.replace(snap, blocks_panel=rows), None)
306
+ # forecast / current-week / sessions: resolver already gated; we
307
+ # only reach here for `kind="current"`, which returns no
308
+ # override.
309
+ return (snap, None)
310
+ finally:
311
+ conn.close()
312
+
313
+
314
+ def _share_apply_content_toggles(snap_built, options: dict):
315
+ """Strip chart / table from a built ShareSnapshot per render options.
316
+
317
+ The render kernel consumes whatever the template builder emits, so
318
+ chart/table on-off can't be expressed by the builder alone (every
319
+ builder unconditionally emits both). Apply the toggle here, after
320
+ the builder, before `_scrub` and `render`. ShareSnapshot is frozen;
321
+ `dataclasses.replace` returns a new instance.
322
+
323
+ Defaults preserve pre-toggle behavior: `show_chart` defaults to
324
+ True, `show_table` defaults to True. Explicit False on either
325
+ drops the corresponding payload.
326
+ """
327
+ import dataclasses as _dc
328
+ show_chart = bool(options.get("show_chart", True))
329
+ show_table = bool(options.get("show_table", True))
330
+ changes: dict = {}
331
+ if not show_chart:
332
+ changes["chart"] = None
333
+ if not show_table:
334
+ changes["columns"] = ()
335
+ changes["rows"] = ()
336
+ if not changes:
337
+ return snap_built
338
+ return _dc.replace(snap_built, **changes)
339
+
340
+
341
+ # Cap on how many `(project, cost)` rows builders return for top_projects.
342
+ # Templates take `top_n` from options (default 5, see _lib_share_templates)
343
+ # and apply their own cap on top of this. The headroom matters because:
344
+ # (a) the scrubber walks ProjectCells once per row, so unbounded length
345
+ # balloons render-time anonymization cost;
346
+ # (b) the live preview iframe streams the full table chrome;
347
+ # (c) 20 covers any realistic `top_n` knob value (UI typically caps at 10).
348
+ _SHARE_TOP_PROJECTS_BUILDER_CAP = 20
349
+
350
+
351
+ def _share_top_projects_for_range(
352
+ range_start: "dt.datetime",
353
+ range_end: "dt.datetime",
354
+ *,
355
+ skip_sync: bool = True,
356
+ ) -> list[tuple[str, float]]:
357
+ """Aggregate session_entries in `[range_start, range_end]` by project_path.
358
+
359
+ Returns `[(project_path_or_'(unknown)', cost_usd), ...]` sorted desc by
360
+ cost and capped at `_SHARE_TOP_PROJECTS_BUILDER_CAP`. Templates apply
361
+ a further `top_n` cap (default 5).
362
+
363
+ Routes through `get_claude_session_entries` so we get `project_path`
364
+ in the join — same cache-first/lock-contention/direct-JSONL fallback
365
+ chain the rest of the share path relies on. `skip_sync=True` by
366
+ default: the sync thread has already done its tick at snapshot-build
367
+ time, and a per-request ingest would block the share render on
368
+ `cache.db.lock`.
369
+
370
+ Cost computation goes through `_calculate_entry_cost` — the
371
+ single-source-of-truth pricing path. Mirrors `_compute_block_totals`'
372
+ `by_project` bucketing exactly, so the reconcile invariant
373
+ `SUM(top_projects) ≈ panel.cost_usd` is preserved within ULP drift
374
+ when the panel's cost matches the same time range (e.g., current
375
+ week, current 5h block).
376
+
377
+ NULL `project_path` collapses to the `(unknown)` sentinel. Anon
378
+ happens later in `_scrub()`; builders always emit real names per
379
+ the kernel's privacy chokepoint contract.
380
+ """
381
+ bucket: dict[str, float] = {}
382
+ try:
383
+ # late-binding: reach the dashboard module object so both the ns-patch (share tests)
384
+ # and a dashboard-object patch (rebuild-parity) are honored (#279 S5 F1 / spec §3).
385
+ entries = sys.modules["_cctally_dashboard"].get_claude_session_entries(
386
+ range_start, range_end, skip_sync=skip_sync,
387
+ )
388
+ except Exception:
389
+ # `get_claude_session_entries` already has its own fallback chain,
390
+ # but if even that fails (e.g., HOME unset in a fixture run with
391
+ # no monkeypatch), don't break the whole share render — just emit
392
+ # an empty top_projects.
393
+ return []
394
+ for entry in entries:
395
+ usage = {
396
+ "input_tokens": entry.input_tokens,
397
+ "output_tokens": entry.output_tokens,
398
+ "cache_creation_input_tokens": entry.cache_creation_tokens,
399
+ "cache_read_input_tokens": entry.cache_read_tokens,
400
+ }
401
+ cost = _calculate_entry_cost(
402
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
403
+ )
404
+ key = entry.project_path or "(unknown)"
405
+ bucket[key] = bucket.get(key, 0.0) + cost
406
+ ranked = sorted(bucket.items(), key=lambda kv: -kv[1])
407
+ return [(path, cost) for path, cost in ranked[:_SHARE_TOP_PROJECTS_BUILDER_CAP]]
408
+
409
+
410
+ def _share_all_projects_for_range(
411
+ range_start: "dt.datetime",
412
+ range_end: "dt.datetime",
413
+ *,
414
+ skip_sync: bool = True,
415
+ ) -> dict[str, float]:
416
+ """Like `_share_top_projects_for_range` but uncapped and unsorted.
417
+
418
+ Returns {project_path_or_'(unknown)': cost_usd} for every project
419
+ active in the range. Caller orders or caps as needed. Used by
420
+ `_share_per_block_per_project`'s fallback path so the fallback's
421
+ accuracy matches the canonical rollup-table path (spec §7.2.1,
422
+ issue #33).
423
+ """
424
+ bucket: dict[str, float] = {}
425
+ try:
426
+ # late-binding: reach the dashboard module object so both the ns-patch (share tests)
427
+ # and a dashboard-object patch (rebuild-parity) are honored (#279 S5 F1 / spec §3).
428
+ entries = sys.modules["_cctally_dashboard"].get_claude_session_entries(
429
+ range_start, range_end, skip_sync=skip_sync,
430
+ )
431
+ except Exception:
432
+ return bucket
433
+ for entry in entries:
434
+ usage = {
435
+ "input_tokens": entry.input_tokens,
436
+ "output_tokens": entry.output_tokens,
437
+ "cache_creation_input_tokens": entry.cache_creation_tokens,
438
+ "cache_read_input_tokens": entry.cache_read_tokens,
439
+ }
440
+ cost = _calculate_entry_cost(
441
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
442
+ )
443
+ key = entry.project_path or "(unknown)"
444
+ bucket[key] = bucket.get(key, 0.0) + cost
445
+ return bucket
446
+
447
+
448
+ def _share_per_day_per_project_for_range(
449
+ range_start: "dt.datetime",
450
+ range_end: "dt.datetime",
451
+ *,
452
+ display_tz: str,
453
+ skip_sync: bool = True,
454
+ ) -> dict[str, dict[str, float]]:
455
+ """Aggregate session_entries in [range_start, range_end] by
456
+ (day-in-display_tz, project_path).
457
+
458
+ Returns {date_str: {project_path_or_'(unknown)': cost_usd}}. Same
459
+ cache-first/lock-contention/direct-JSONL fallback chain as
460
+ `_share_top_projects_for_range`. Day bucket computed in display_tz
461
+ so the rendered row label matches. Issue #33.
462
+ """
463
+ try:
464
+ tz = ZoneInfo(display_tz) if display_tz else dt.timezone.utc
465
+ except Exception:
466
+ tz = dt.timezone.utc
467
+ out: dict[str, dict[str, float]] = {}
468
+ try:
469
+ # late-binding: reach the dashboard module object so both the ns-patch (share tests)
470
+ # and a dashboard-object patch (rebuild-parity) are honored (#279 S5 F1 / spec §3).
471
+ entries = sys.modules["_cctally_dashboard"].get_claude_session_entries(
472
+ range_start, range_end, skip_sync=skip_sync,
473
+ )
474
+ except Exception:
475
+ return out
476
+ for entry in entries:
477
+ usage = {
478
+ "input_tokens": entry.input_tokens,
479
+ "output_tokens": entry.output_tokens,
480
+ "cache_creation_input_tokens": entry.cache_creation_tokens,
481
+ "cache_read_input_tokens": entry.cache_read_tokens,
482
+ }
483
+ cost = _calculate_entry_cost(
484
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
485
+ )
486
+ day = entry.timestamp.astimezone(tz).strftime("%Y-%m-%d")
487
+ proj = entry.project_path or "(unknown)"
488
+ out.setdefault(day, {})
489
+ out[day][proj] = out[day].get(proj, 0.0) + cost
490
+ return out
491
+
492
+
493
+ def _share_per_block_per_project(
494
+ recent_blocks: list[dict],
495
+ ) -> dict[str, dict[str, float]]:
496
+ """Aggregate per-block per-project costs from `five_hour_block_projects`.
497
+
498
+ Returns {block_start_at_iso: {project_path_or_'(unknown)': cost_usd}}.
499
+ Block.start_at → five_hour_window_key via `_canonical_5h_window_key`
500
+ (10-min floor; same chokepoint as `maybe_update_five_hour_block`,
501
+ per CLAUDE.md "5-hour windows" gotcha — never derive a third key shape).
502
+
503
+ Fallback (rollup empty/unreadable): per-block sweep over
504
+ `_share_all_projects_for_range` — uncapped, accuracy parity with the
505
+ canonical path. Fires only during the first tick after fresh install
506
+ or before stats-migration `002_five_hour_block_projects_backfill_v1`
507
+ completes. Issue #33.
508
+ """
509
+ if not recent_blocks:
510
+ return {}
511
+ out: dict[str, dict[str, float]] = {}
512
+ keys: list[int] = []
513
+ iso_by_key: dict[int, str] = {}
514
+ for b in recent_blocks:
515
+ try:
516
+ ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
517
+ except (ValueError, KeyError):
518
+ continue
519
+ wk = _canonical_5h_window_key(int(ts.timestamp()))
520
+ keys.append(wk)
521
+ iso_by_key[wk] = b["start_at"]
522
+ if not keys:
523
+ return out
524
+ try:
525
+ conn = open_db()
526
+ placeholders = ",".join("?" for _ in keys)
527
+ rows = conn.execute(
528
+ f"SELECT five_hour_window_key, project_path, cost_usd "
529
+ f"FROM five_hour_block_projects "
530
+ f"WHERE five_hour_window_key IN ({placeholders})",
531
+ keys,
532
+ ).fetchall()
533
+ for wk, project_path, cost in rows:
534
+ block_iso = iso_by_key.get(wk)
535
+ if block_iso is None:
536
+ continue
537
+ proj = project_path or "(unknown)"
538
+ out.setdefault(block_iso, {})
539
+ out[block_iso][proj] = out[block_iso].get(proj, 0.0) + float(cost)
540
+ if out:
541
+ return out
542
+ except (sqlite3.DatabaseError, OSError):
543
+ pass
544
+ # Fallback: per-block uncapped session_entries sweep.
545
+ for b in recent_blocks:
546
+ try:
547
+ ts = parse_iso_datetime(b["start_at"], "share.block.start_at")
548
+ except (ValueError, KeyError):
549
+ continue
550
+ end = ts + dt.timedelta(hours=5)
551
+ out[b["start_at"]] = sys.modules["cctally"]._share_all_projects_for_range(ts, end)
552
+ return out
553
+
554
+
555
+ def _build_share_panel_data(panel: str, options: dict,
556
+ snap: "DataSnapshot | None") -> dict:
557
+ """Dispatch to the per-panel builder; reuses the dashboard DataSnapshot.
558
+
559
+ Each per-panel builder reads from the already-built `DataSnapshot`
560
+ rather than re-running CLI aggregation queries — keeps /api/share/render
561
+ cheap and ensures the share artifact matches what the dashboard panel
562
+ is currently showing.
563
+ """
564
+ if panel == "weekly": return _build_weekly_share_panel_data(options, snap)
565
+ if panel == "daily": return _build_daily_share_panel_data(options, snap)
566
+ if panel == "monthly": return _build_monthly_share_panel_data(options, snap)
567
+ if panel == "trend": return _build_trend_share_panel_data(options, snap)
568
+ if panel == "forecast": return _build_forecast_share_panel_data(options, snap)
569
+ if panel == "blocks": return _build_blocks_share_panel_data(options, snap)
570
+ if panel == "sessions": return _build_sessions_share_panel_data(options, snap)
571
+ if panel == "current-week": return _build_current_week_share_panel_data(options, snap)
572
+ if panel == "projects": return _build_projects_share_panel_data(options, snap)
573
+ raise ValueError(f"unknown share panel: {panel!r}")
574
+
575
+
576
+ def _share_empty_week_stub() -> dict:
577
+ """Minimal week shape so empty snapshots render as "no data" cleanly.
578
+
579
+ Recap builders index `weeks[idx]` directly; supplying one zero-filled
580
+ row keeps that access safe without leaking misleading numbers (the
581
+ rendered artifact shows $0.00 / 0.0% — accurate for an empty install).
582
+ """
583
+ return {
584
+ "start_date": _share_now_utc().strftime("%Y-%m-%d"),
585
+ "cost_usd": 0.0,
586
+ "pct_used": 0.0,
587
+ "dollar_per_pct": 0.0,
588
+ "top_projects": [],
589
+ }
590
+
591
+
592
+ def _build_weekly_share_panel_data(options: dict,
593
+ snap: "DataSnapshot | None") -> dict:
594
+ """Weekly panel_data — last 8 subscription weeks + current-week index.
595
+
596
+ Reuses `DataSnapshot.weekly_periods` (WeeklyPeriodRow list), already
597
+ built by `_dashboard_build_weekly_periods` in the sync thread. Empty
598
+ snapshots emit a one-week stub so the Recap builder's `weeks[idx]`
599
+ access stays safe (renders as $0.00 / 0.0% — accurate "no data").
600
+ """
601
+ rows = list(getattr(snap, "weekly_periods", None) or []) if snap else []
602
+ # weekly_periods is newest-first (see _dashboard_build_weekly_periods).
603
+ # Take the newest 8 and reverse to oldest→newest — the Recap template
604
+ # reads weeks[0] as the start anchor and weeks[-1] as the right-edge
605
+ # (current-week) anchor, and current_week_index addresses that order.
606
+ rows_8 = list(reversed(rows[:8]))
607
+ weeks: list[dict] = []
608
+ current_idx = 0
609
+ for i, r in enumerate(rows_8):
610
+ if getattr(r, "is_current", False):
611
+ current_idx = i
612
+ # WeeklyPeriodRow.week_start_at is an ISO datetime string; the
613
+ # Recap shape wants a YYYY-MM-DD date label. Slice the leading
614
+ # 10 chars (or fall back to parsing).
615
+ wsa = getattr(r, "week_start_at", "") or ""
616
+ start_date = wsa[:10] if isinstance(wsa, str) and len(wsa) >= 10 else wsa
617
+ cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
618
+ used_pct_raw = getattr(r, "used_pct", None)
619
+ used_pct = (float(used_pct_raw) / 100.0) if used_pct_raw is not None else 0.0
620
+ dpp = float(getattr(r, "dollar_per_pct", 0.0) or 0.0)
621
+ # Per-week top_projects: WeeklyPeriodRow doesn't carry a
622
+ # per-project rollup, but `week_start_at` / `week_end_at` give us
623
+ # an exact range — aggregate session_entries once per week so the
624
+ # Recap template's `weeks[i].top_projects` table is meaningful.
625
+ # 8 queries per share render is the perf trade; cached.
626
+ week_end_at = getattr(r, "week_end_at", "") or ""
627
+ top_projects: list[tuple[str, float]] = []
628
+ try:
629
+ ws_dt = parse_iso_datetime(wsa, "week_start_at") if isinstance(wsa, str) and wsa else None
630
+ we_dt = parse_iso_datetime(week_end_at, "week_end_at") if isinstance(week_end_at, str) and week_end_at else None
631
+ except ValueError:
632
+ ws_dt = we_dt = None
633
+ if ws_dt is not None and we_dt is not None:
634
+ top_projects = sys.modules["cctally"]._share_top_projects_for_range(ws_dt, we_dt)
635
+ # Per-week × per-model breakdown (issue #33 cross-tab Detail).
636
+ models_list = getattr(r, "models", None) or []
637
+ models = {
638
+ (m.get("model") or "(unknown)"): float(m.get("cost_usd", 0.0) or 0.0)
639
+ for m in models_list
640
+ }
641
+ weeks.append({
642
+ "start_date": start_date,
643
+ "cost_usd": cost,
644
+ "pct_used": used_pct,
645
+ "dollar_per_pct": dpp,
646
+ "top_projects": top_projects,
647
+ "models": models,
648
+ })
649
+ if not weeks:
650
+ weeks = [_share_empty_week_stub()]
651
+ return {"weeks": weeks, "current_week_index": current_idx}
652
+
653
+
654
+ def _build_current_week_share_panel_data(options: dict,
655
+ snap: "DataSnapshot | None") -> dict:
656
+ """Current-week panel_data — KPI strip + daily progression + top projects.
657
+
658
+ Synthesized from `DataSnapshot.current_week` + `daily_panel` (no 1:1
659
+ CLI counterpart, per spec §9.5). `daily_progression` clips the daily
660
+ panel to the current subscription week.
661
+ """
662
+ cw = getattr(snap, "current_week", None) if snap else None
663
+ daily = list(getattr(snap, "daily_panel", None) or []) if snap else []
664
+ if cw is None:
665
+ # Empty-shape fallback — Recap builder renders "no data" gracefully.
666
+ return {
667
+ "kpi_cost_usd": 0.0,
668
+ "kpi_pct_used": 0.0,
669
+ "kpi_dollar_per_pct": 0.0,
670
+ "kpi_days_remaining": 0.0,
671
+ "daily_progression": [],
672
+ "top_projects": [],
673
+ "week_start_date": _share_now_utc().strftime("%Y-%m-%d"),
674
+ "display_tz": options.get("display_tz", "Etc/UTC"),
675
+ }
676
+ week_start = getattr(cw, "week_start_at", None)
677
+ week_end = getattr(cw, "week_end_at", None)
678
+ week_start_date = (
679
+ week_start.strftime("%Y-%m-%d") if isinstance(week_start, dt.datetime)
680
+ else _share_now_utc().strftime("%Y-%m-%d")
681
+ )
682
+ # Days remaining = hours_to_reset / 24
683
+ days_remaining = 0.0
684
+ if isinstance(week_end, dt.datetime):
685
+ remaining = (week_end - _share_now_utc()).total_seconds() / 86400.0
686
+ days_remaining = max(0.0, remaining)
687
+ used_pct = float(getattr(cw, "used_pct", 0.0) or 0.0) / 100.0
688
+ progression: list[dict] = []
689
+ if isinstance(week_start, dt.datetime):
690
+ ws_date = week_start.date()
691
+ # daily_panel is newest-first; iterate reversed so progression is
692
+ # oldest→newest, matching the Recap template's progression[-1] =
693
+ # today contract and the chart's left→right time axis.
694
+ for r in reversed(daily):
695
+ try:
696
+ d = dt.date.fromisoformat(getattr(r, "date", "") or "")
697
+ except ValueError:
698
+ continue
699
+ if d >= ws_date:
700
+ progression.append({
701
+ "date": d.isoformat(),
702
+ "cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
703
+ })
704
+ # Current-week top_projects: aggregate from `[week_start, now]`.
705
+ # `cw.week_end_at` is the reset instant; using `now` keeps the rollup
706
+ # symmetric with the panel's "spent this week" KPI (week-to-date).
707
+ top_projects: list[tuple[str, float]] = []
708
+ if isinstance(week_start, dt.datetime):
709
+ top_projects = sys.modules["cctally"]._share_top_projects_for_range(
710
+ week_start, _share_now_utc(),
711
+ )
712
+ return {
713
+ "kpi_cost_usd": float(getattr(cw, "spent_usd", 0.0) or 0.0),
714
+ "kpi_pct_used": used_pct,
715
+ "kpi_dollar_per_pct": float(getattr(cw, "dollars_per_percent", 0.0) or 0.0),
716
+ "kpi_days_remaining": days_remaining,
717
+ "daily_progression": progression,
718
+ "top_projects": top_projects,
719
+ "week_start_date": week_start_date,
720
+ "display_tz": options.get("display_tz", "Etc/UTC"),
721
+ }
722
+
723
+
724
+ def _build_trend_share_panel_data(options: dict,
725
+ snap: "DataSnapshot | None") -> dict:
726
+ """Trend panel_data — 8 weeks of $/% + 3-week delta KPI.
727
+
728
+ Reuses `DataSnapshot.trend` (TuiTrendRow list, already 8 rows).
729
+ """
730
+ trend = list(getattr(snap, "trend", None) or []) if snap else []
731
+ weeks: list[dict] = []
732
+ for r in trend:
733
+ wsa = getattr(r, "week_start_at", None)
734
+ start_date = (
735
+ wsa.strftime("%Y-%m-%d") if isinstance(wsa, dt.datetime)
736
+ else (str(wsa)[:10] if wsa else "")
737
+ )
738
+ used_pct_raw = getattr(r, "used_pct", None)
739
+ used_pct = (float(used_pct_raw) / 100.0) if used_pct_raw is not None else 0.0
740
+ dpp = float(getattr(r, "dollars_per_percent", 0.0) or 0.0)
741
+ weeks.append({
742
+ "start_date": start_date,
743
+ "cost_usd": dpp * (used_pct * 100.0), # ≈ row total
744
+ "pct_used": used_pct,
745
+ "dollar_per_pct": dpp,
746
+ })
747
+ # Compute 3-week delta: compare last row vs row-4-from-end.
748
+ delta = {"dpp_change_pct": 0.0, "cost_change_usd": 0.0}
749
+ if len(weeks) >= 4:
750
+ cur = weeks[-1]
751
+ ref = weeks[-4]
752
+ if ref["dollar_per_pct"]:
753
+ delta["dpp_change_pct"] = (
754
+ (cur["dollar_per_pct"] - ref["dollar_per_pct"]) / ref["dollar_per_pct"]
755
+ )
756
+ delta["cost_change_usd"] = cur["cost_usd"] - ref["cost_usd"]
757
+ return {"weeks": weeks, "delta_3_weeks": delta}
758
+
759
+
760
+ def _build_daily_share_panel_data(options: dict,
761
+ snap: "DataSnapshot | None") -> dict:
762
+ """Daily panel_data — last 7 days with top model per day + top projects.
763
+
764
+ Reuses `DataSnapshot.daily_panel` (DailyPanelRow list, 30 rows in
765
+ full); clips to the most recent 7 for the Recap.
766
+ """
767
+ daily = list(getattr(snap, "daily_panel", None) or []) if snap else []
768
+ # daily_panel is newest-first (today at index 0); take the most recent
769
+ # 7 and reverse to oldest→newest so the Recap template's days[-1]
770
+ # anchor lands on today.
771
+ last_7 = list(reversed(daily[:7]))
772
+ total = stable_sum(float(getattr(r, "cost_usd", 0.0) or 0.0) for r in last_7) or 1.0
773
+ days: list[dict] = []
774
+ for r in last_7:
775
+ cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
776
+ models = getattr(r, "models", None) or []
777
+ top_model = (models[0].get("model") if models else None) or "—"
778
+ days.append({
779
+ "date": getattr(r, "date", "") or "",
780
+ "cost_usd": cost,
781
+ "pct_of_period": cost / total,
782
+ "top_model": top_model,
783
+ })
784
+ # `days[*].date` is bucketed in display_tz by `_dashboard_build_daily_panel`,
785
+ # so the query window must use display-tz midnights too — otherwise entries
786
+ # near midnight (up to ±UTC-offset hours) get queried under the wrong UTC
787
+ # day and either spill into Other or vanish from cross-tab cells while
788
+ # still counted in the row total.
789
+ display_tz_name = options.get("display_tz", "Etc/UTC")
790
+ try:
791
+ _range_tz = ZoneInfo(display_tz_name) if display_tz_name else dt.timezone.utc
792
+ except Exception:
793
+ _range_tz = dt.timezone.utc
794
+ # Daily top_projects: aggregate over the 7-day window. Derive the
795
+ # range from the dates rendered above so the rollup covers exactly
796
+ # what the panel shows (rather than re-deriving "7 days ago" from
797
+ # now and potentially clipping the oldest bucket).
798
+ top_projects: list[tuple[str, float]] = []
799
+ if days:
800
+ try:
801
+ first_date = dt.date.fromisoformat(days[0]["date"])
802
+ last_date = dt.date.fromisoformat(days[-1]["date"])
803
+ range_start = dt.datetime(
804
+ first_date.year, first_date.month, first_date.day,
805
+ tzinfo=_range_tz,
806
+ )
807
+ # Include the last day in full — end-exclusive boundary at
808
+ # the start of the next display-tz day.
809
+ range_end = dt.datetime(
810
+ last_date.year, last_date.month, last_date.day,
811
+ tzinfo=_range_tz,
812
+ ) + dt.timedelta(days=1)
813
+ top_projects = sys.modules["cctally"]._share_top_projects_for_range(range_start, range_end)
814
+ except (ValueError, KeyError):
815
+ top_projects = []
816
+ # Per-day × per-project breakdown (issue #33 cross-tab Detail).
817
+ per_day_per_project: dict[str, dict[str, float]] = {}
818
+ if days:
819
+ try:
820
+ first_date = dt.date.fromisoformat(days[0]["date"])
821
+ last_date = dt.date.fromisoformat(days[-1]["date"])
822
+ pdpp_range_start = dt.datetime(
823
+ first_date.year, first_date.month, first_date.day,
824
+ tzinfo=_range_tz,
825
+ )
826
+ pdpp_range_end = dt.datetime(
827
+ last_date.year, last_date.month, last_date.day,
828
+ tzinfo=_range_tz,
829
+ ) + dt.timedelta(days=1)
830
+ per_day_per_project = sys.modules["cctally"]._share_per_day_per_project_for_range(
831
+ pdpp_range_start, pdpp_range_end,
832
+ display_tz=display_tz_name,
833
+ )
834
+ except (ValueError, KeyError):
835
+ per_day_per_project = {}
836
+ for d in days:
837
+ d["projects"] = per_day_per_project.get(d["date"], {})
838
+ return {"days": days, "top_projects": top_projects}
839
+
840
+
841
+ def _build_monthly_share_panel_data(options: dict,
842
+ snap: "DataSnapshot | None") -> dict:
843
+ """Monthly panel_data — last 12 months + top projects.
844
+
845
+ Reuses `DataSnapshot.monthly_periods` (MonthlyPeriodRow list).
846
+ `used_pct` isn't stored on MonthlyPeriodRow (monthly aggregates
847
+ don't carry a subscription-quota %), so it surfaces as 0.0.
848
+ """
849
+ rows = list(getattr(snap, "monthly_periods", None) or []) if snap else []
850
+ # monthly_periods is newest-first (see _dashboard_build_monthly_periods).
851
+ # Reverse to oldest→newest — the Recap template reads months[0] as the
852
+ # period-start anchor and months[-1] as the most recent month.
853
+ rows = list(reversed(rows))
854
+ months: list[dict] = []
855
+ for r in rows:
856
+ models_list = getattr(r, "models", None) or []
857
+ top_model = (models_list[0].get("model") if models_list else None) or "—"
858
+ # Per-month × per-model breakdown (issue #33 cross-tab Detail).
859
+ models = {
860
+ (m.get("model") or "(unknown)"): float(m.get("cost_usd", 0.0) or 0.0)
861
+ for m in models_list
862
+ }
863
+ months.append({
864
+ "month": getattr(r, "label", "") or "", # "YYYY-MM"
865
+ "cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
866
+ "pct_used": 0.0,
867
+ "top_model": top_model,
868
+ "models": models,
869
+ })
870
+ # Monthly top_projects: aggregate across the entire 12-month window.
871
+ # Range = [first day of oldest month, last day of newest month + 1].
872
+ top_projects: list[tuple[str, float]] = []
873
+ if months:
874
+ try:
875
+ oldest_year, oldest_month = months[0]["month"].split("-")
876
+ newest_year, newest_month = months[-1]["month"].split("-")
877
+ range_start = dt.datetime(
878
+ int(oldest_year), int(oldest_month), 1,
879
+ tzinfo=dt.timezone.utc,
880
+ )
881
+ # End-exclusive: first day of the month AFTER the newest one.
882
+ ny, nm = int(newest_year), int(newest_month) + 1
883
+ if nm == 13:
884
+ ny += 1
885
+ nm = 1
886
+ range_end = dt.datetime(ny, nm, 1, tzinfo=dt.timezone.utc)
887
+ top_projects = sys.modules["cctally"]._share_top_projects_for_range(range_start, range_end)
888
+ except (ValueError, KeyError):
889
+ top_projects = []
890
+ return {"months": months, "top_projects": top_projects}
891
+
892
+
893
+ def _build_forecast_share_panel_data(options: dict,
894
+ snap: "DataSnapshot | None") -> dict:
895
+ """Forecast panel_data — projection + per-day budgets + days-to-ceiling.
896
+
897
+ Reuses ``DataSnapshot.forecast`` (ForecastOutput) and, when populated
898
+ by the sync thread, ``DataSnapshot.forecast_view`` (the kernel
899
+ wrapper from issue #57) for the (100, 90) budget pair.
900
+ ``projection_curve`` is synthesized from ``r_avg`` / ``r_recent`` /
901
+ ``inputs.p_now`` — the same arithmetic ``snapshot_to_envelope`` does
902
+ for ``week_avg_projection_pct`` / ``recent_24h_projection_pct``,
903
+ extended across the next 7 days.
904
+ """
905
+ fc = getattr(snap, "forecast", None) if snap else None
906
+ fc_view = getattr(snap, "forecast_view", None) if snap else None
907
+ if fc is None:
908
+ return {
909
+ "projected_end_pct": 0.0,
910
+ "days_to_100pct": 0.0,
911
+ "days_to_90pct": 0.0,
912
+ "daily_budgets": {
913
+ "avg": 0.0, "recent_24h": 0.0,
914
+ "until_90pct": 0.0, "until_100pct": 0.0,
915
+ },
916
+ "projection_curve": [],
917
+ "confidence": "LOW CONF",
918
+ }
919
+ inputs = getattr(fc, "inputs", None)
920
+ p_now = float(getattr(inputs, "p_now", 0.0) or 0.0) if inputs else 0.0
921
+ remaining_hours = float(
922
+ getattr(inputs, "remaining_hours", 0.0) or 0.0
923
+ ) if inputs else 0.0
924
+ confidence = getattr(inputs, "confidence", "ok") if inputs else "ok"
925
+ r_avg = float(getattr(fc, "r_avg", 0.0) or 0.0)
926
+ r_recent_raw = getattr(fc, "r_recent", None)
927
+ r_recent = float(r_recent_raw) if r_recent_raw is not None else r_avg
928
+ # End-of-week projected %
929
+ projected_end_pct = (p_now + r_avg * remaining_hours) / 100.0
930
+ # Days to ceilings (simple inverse: hours-to-target / 24)
931
+ def _days_to_ceiling(target_pct: float) -> float:
932
+ if r_avg <= 0 or p_now >= target_pct:
933
+ return 0.0
934
+ hours = (target_pct - p_now) / r_avg
935
+ return max(0.0, hours / 24.0)
936
+ days_to_100 = _days_to_ceiling(100.0)
937
+ days_to_90 = _days_to_ceiling(90.0)
938
+ # Daily budgets — prefer ForecastView's pre-routed pair (issue #57)
939
+ # when available; otherwise replay the legacy ``fc.budgets`` scan
940
+ # inline so positionally-constructed fixture snapshots still work.
941
+ budgets: dict = {"avg": 0.0, "recent_24h": 0.0,
942
+ "until_90pct": 0.0, "until_100pct": 0.0}
943
+ if fc_view is not None:
944
+ budgets["until_100pct"] = float(
945
+ fc_view.budget_100_per_day_usd or 0.0,
946
+ )
947
+ budgets["until_90pct"] = float(
948
+ fc_view.budget_90_per_day_usd or 0.0,
949
+ )
950
+ else:
951
+ for b in getattr(fc, "budgets", None) or []:
952
+ tp = getattr(b, "target_percent", None)
953
+ dpd = float(getattr(b, "dollars_per_day", 0.0) or 0.0)
954
+ if tp == 100:
955
+ budgets["until_100pct"] = dpd
956
+ elif tp == 90:
957
+ budgets["until_90pct"] = dpd
958
+ # avg / recent_24h: derive from dollars-per-percent × r_avg/r_recent.
959
+ dpp = float(getattr(inputs, "dollars_per_percent", 0.0) or 0.0) if inputs else 0.0
960
+ budgets["avg"] = dpp * r_avg * 24.0
961
+ budgets["recent_24h"] = dpp * r_recent * 24.0
962
+ # Projection curve — 7-day forward, using r_avg
963
+ today = _share_now_utc().date()
964
+ projection_curve: list[dict] = []
965
+ for i in range(7):
966
+ d = today + dt.timedelta(days=i)
967
+ pct = (p_now + r_avg * (i * 24.0)) / 100.0
968
+ projection_curve.append({
969
+ "date": d.isoformat(),
970
+ "projected_pct_used": pct,
971
+ })
972
+ return {
973
+ "projected_end_pct": projected_end_pct,
974
+ "days_to_100pct": days_to_100,
975
+ "days_to_90pct": days_to_90,
976
+ "daily_budgets": budgets,
977
+ "projection_curve": projection_curve,
978
+ "confidence": confidence,
979
+ }
980
+
981
+
982
+ def _build_blocks_share_panel_data(options: dict,
983
+ snap: "DataSnapshot | None") -> dict:
984
+ """Blocks panel_data — current 5h block KPI + 8 recent blocks + top projects.
985
+
986
+ Reuses `DataSnapshot.blocks_panel` (BlocksPanelRow list). Current
987
+ block is the row with `is_active=True`; recent_blocks are the last 8.
988
+ """
989
+ rows = list(getattr(snap, "blocks_panel", None) or []) if snap else []
990
+ current = next((r for r in rows if getattr(r, "is_active", False)), None)
991
+ cb: dict = {}
992
+ if current is not None:
993
+ cb = {
994
+ "start_at": _share_iso(getattr(current, "start_at", None)) or "",
995
+ "end_at": _share_iso(getattr(current, "end_at", None)) or "",
996
+ "cost_usd": float(getattr(current, "cost_usd", 0.0) or 0.0),
997
+ "pct_used": 0.0, # BlocksPanelRow doesn't carry a %
998
+ "tokens_total": 0, # BlocksPanelRow drops token counts
999
+ }
1000
+ # blocks_panel is newest-first (see _dashboard_build_blocks_panel:
1001
+ # `rows.sort(key=lambda r: r.start_at, reverse=True)`). Take the most
1002
+ # recent 8 blocks and reverse to oldest→newest so the template's chart
1003
+ # (uses enumerate(recent) for x-position) plots left→right time order.
1004
+ recent: list[dict] = []
1005
+ for r in list(reversed(rows[:8])):
1006
+ recent.append({
1007
+ "start_at": _share_iso(getattr(r, "start_at", None)) or "",
1008
+ "cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
1009
+ })
1010
+ # Blocks top_projects: aggregate across the window covered by
1011
+ # `recent_blocks` (the oldest block's start through the most recent
1012
+ # block's end — also the active block, if any). Mirrors what the
1013
+ # panel actually shows the user.
1014
+ top_projects: list[tuple[str, float]] = []
1015
+ if recent:
1016
+ try:
1017
+ range_start = parse_iso_datetime(
1018
+ recent[0]["start_at"], "blocks.recent_blocks[0].start_at",
1019
+ )
1020
+ # Pick the end of the latest block. `recent` is oldest→newest
1021
+ # after the slice/reverse, so `recent[-1]` is the most recent.
1022
+ # Each block is 5 hours long; if `current_block` has an
1023
+ # explicit `end_at`, prefer that since it may be the active
1024
+ # block whose end_at lives in the future.
1025
+ if cb.get("end_at"):
1026
+ range_end = parse_iso_datetime(
1027
+ cb["end_at"], "blocks.current_block.end_at",
1028
+ )
1029
+ else:
1030
+ range_end = parse_iso_datetime(
1031
+ recent[-1]["start_at"], "blocks.recent_blocks[-1].start_at",
1032
+ ) + dt.timedelta(hours=5)
1033
+ top_projects = sys.modules["cctally"]._share_top_projects_for_range(range_start, range_end)
1034
+ except (ValueError, KeyError):
1035
+ top_projects = []
1036
+ # Per-block × per-project breakdown (issue #33 cross-tab Detail).
1037
+ per_block_per_project = sys.modules["cctally"]._share_per_block_per_project(recent)
1038
+ for r in recent:
1039
+ r["projects"] = per_block_per_project.get(r["start_at"], {})
1040
+ return {
1041
+ "current_block": cb,
1042
+ "recent_blocks": recent,
1043
+ "top_projects": top_projects,
1044
+ }
1045
+
1046
+
1047
+ def _build_sessions_share_panel_data(options: dict,
1048
+ snap: "DataSnapshot | None") -> dict:
1049
+ """Sessions panel_data — top N sessions table.
1050
+
1051
+ Reuses `DataSnapshot.sessions` (TuiSessionRow list). Truncated to
1052
+ `options.top_n` (default 15) by upstream cap before the Recap builder
1053
+ runs its own slice.
1054
+ """
1055
+ rows = list(getattr(snap, "sessions", None) or []) if snap else []
1056
+ top_n = options.get("top_n", 15)
1057
+ try:
1058
+ top_n_int = max(1, int(top_n))
1059
+ except (TypeError, ValueError):
1060
+ top_n_int = 15
1061
+ sessions: list[dict] = []
1062
+ for r in rows[:top_n_int]:
1063
+ sessions.append({
1064
+ "session_id": getattr(r, "session_id", "") or "",
1065
+ "project_path": getattr(r, "project_label", "") or "",
1066
+ "cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
1067
+ "started_at": _share_iso(getattr(r, "started_at", None)) or "",
1068
+ "model": getattr(r, "model_primary", "") or "",
1069
+ })
1070
+ return {"sessions": sessions}
1071
+
1072
+
1073
+ def _build_projects_share_panel_data(options: dict,
1074
+ snap: "DataSnapshot | None") -> dict:
1075
+ """Projects panel_data — per-project rollup over a selectable window.
1076
+
1077
+ Reuses ``DataSnapshot.projects_envelope`` already populated by the
1078
+ sync thread, so the share artifact matches what the dashboard panel
1079
+ is showing. ``options.windowWeeks`` (spec §5.4 + §7.3) selects the
1080
+ aggregation window:
1081
+
1082
+ - ``windowWeeks=1`` (default): current_week only (PANEL share flow).
1083
+ - ``windowWeeks ∈ {4, 8, 12}``: sum across the trend window
1084
+ (MODAL share flow — supplies its active window pill).
1085
+
1086
+ Output shape (consumed by `_build_projects_recap` / `_visual` /
1087
+ `_detail` builders below — see bin/_lib_share_templates.py):
1088
+
1089
+ {
1090
+ "rows": [
1091
+ {
1092
+ "key": "<disambiguated display_key>",
1093
+ "bucket_path": "<absolute path>",
1094
+ "cost_usd": <float>,
1095
+ "attributed_pct": <float | None>,
1096
+ "sessions_count": <int>,
1097
+ },
1098
+ ... # desc by cost
1099
+ ],
1100
+ "total_cost_usd": <float>,
1101
+ "period_start": <dt.datetime UTC>,
1102
+ "period_end": <dt.datetime UTC>,
1103
+ "window_weeks": <int>,
1104
+ }
1105
+
1106
+ The Privacy invariant per spec §7.4 lives at the share-render gate
1107
+ (`_lib_share._scrub`), NOT here. This panel_data carries REAL
1108
+ display_keys + bucket_paths; downstream `_scrub` rewrites them
1109
+ when ``reveal_projects=false``.
1110
+ """
1111
+ env: dict = getattr(snap, "projects_envelope", None) or {} if snap else {}
1112
+ if not env:
1113
+ # First-tick / sub-build failure → render a minimal "no data"
1114
+ # shape. _build_project_snapshot already handles empty rows
1115
+ # downstream via "no data" title.
1116
+ now = _share_now_utc()
1117
+ return {
1118
+ "rows": [],
1119
+ "total_cost_usd": 0.0,
1120
+ "period_start": now - dt.timedelta(days=7),
1121
+ "period_end": now,
1122
+ "window_weeks": 1,
1123
+ }
1124
+ weeks_back_raw = options.get("windowWeeks", 1)
1125
+ try:
1126
+ weeks_back = int(weeks_back_raw)
1127
+ except (TypeError, ValueError):
1128
+ weeks_back = 1
1129
+ if weeks_back not in {1, 4, 8, 12}:
1130
+ weeks_back = 1
1131
+ cw = env.get("current_week", {}) or {}
1132
+ trend = env.get("trend", {}) or {}
1133
+
1134
+ # `effective_weeks` is the actual number of weeks of data the artifact
1135
+ # represents. For the 1-week (panel) path it's always 1. For multi-week
1136
+ # (modal) the trend envelope may carry fewer weeks than requested on
1137
+ # thin-history dashboards (fresh installs, post-rebuild), so clamp to
1138
+ # whatever history exists — otherwise the share artifact would label
1139
+ # itself "Last 12 weeks" and render a 12-week date range while only
1140
+ # (say) 3 weeks of rows were aggregated. The period bounds and the
1141
+ # `window_weeks` returned downstream both ride on `effective_weeks`.
1142
+ rows: list[dict]
1143
+ if weeks_back == 1:
1144
+ effective_weeks = 1
1145
+ rows = [
1146
+ {
1147
+ "key": r["key"],
1148
+ "bucket_path": r["bucket_path"],
1149
+ "cost_usd": float(r["cost_usd"]),
1150
+ "attributed_pct": r.get("attributed_pct"),
1151
+ "sessions_count": int(r.get("sessions_count", 0) or 0),
1152
+ }
1153
+ for r in (cw.get("rows") or [])
1154
+ ]
1155
+ total_cost = float(cw.get("total_cost_usd", 0.0) or 0.0)
1156
+ else:
1157
+ # Multi-week: sum across the trailing `weeks_back` slices of
1158
+ # trend.projects[i].weekly_cost. attributed_pct sums each
1159
+ # project's weekly_pct (None when no week has a snapshot).
1160
+ n_weeks = len(trend.get("weeks") or [])
1161
+ # The trend window is already clamped to <= 12; we take the
1162
+ # trailing `weeks_back` slices.
1163
+ take = min(weeks_back, n_weeks)
1164
+ # On a brand-new dashboard with zero trend weeks, fall back to a
1165
+ # single-week (current_week) period so the artifact's labelling
1166
+ # still names a real range instead of "Last 0 weeks".
1167
+ effective_weeks = max(1, take)
1168
+ rows = []
1169
+ running_total = 0.0
1170
+ for tp in trend.get("projects") or []:
1171
+ wc = (tp.get("weekly_cost") or [])[-take:]
1172
+ wp = (tp.get("weekly_pct") or [])[-take:]
1173
+ ws = (tp.get("sessions_per_week") or [])[-take:]
1174
+ cost = float(stable_sum(wc))
1175
+ running_total += cost
1176
+ valid_pct = [float(p) for p in wp if p is not None]
1177
+ attributed = stable_sum(valid_pct) if valid_pct else None
1178
+ # Sum per-week distinct session counts. Slight over-count when a
1179
+ # single session spans a week boundary; the envelope's per-week
1180
+ # bucketing has no session-id sets to union, so this is the
1181
+ # cheapest reasonable approximation and matches the modal's
1182
+ # client-side derivation (envelope.ts → ProjectsModal.tsx).
1183
+ rows.append({
1184
+ "key": tp["key"],
1185
+ "bucket_path": tp["bucket_path"],
1186
+ "cost_usd": cost,
1187
+ "attributed_pct": attributed,
1188
+ # Integer session counts — bare sum() is exact (NOT a
1189
+ # stable_sum float-output site; see test_stable_sum_chokepoint).
1190
+ "sessions_count": int(sum(ws)),
1191
+ })
1192
+ rows.sort(key=lambda r: (-r["cost_usd"], r["key"]))
1193
+ total_cost = running_total
1194
+
1195
+ # Compute window bounds from the *effective* span — see the
1196
+ # `effective_weeks` note above. The rows in this panel_data are
1197
+ # week-to-date (current_week.rows are aggregated through "now"; the
1198
+ # multi-week branch sums weekly_cost slices, with the trailing slice
1199
+ # also week-to-date), so clip `period_end` to min(reset_at, now).
1200
+ # Without the clip a mid-week export advertises a future reset date
1201
+ # in the rendered period/frontmatter and disagrees with the live
1202
+ # dashboard's "spent this week" KPI, which is symmetrically clipped
1203
+ # by `_build_current_week_share_panel_data`'s use of `now`.
1204
+ cw_start_iso = cw.get("week_start_at") or _share_now_utc_iso()
1205
+ cw_start = parse_iso_datetime(cw_start_iso, "projects.cw_start")
1206
+ week_end = cw_start + dt.timedelta(days=7)
1207
+ now = _share_now_utc()
1208
+ period_end = week_end if week_end <= now else now
1209
+ period_start = cw_start - dt.timedelta(days=7 * (effective_weeks - 1))
1210
+
1211
+ return {
1212
+ "rows": rows,
1213
+ "total_cost_usd": total_cost,
1214
+ "period_start": period_start,
1215
+ "period_end": period_end,
1216
+ "window_weeks": effective_weeks,
1217
+ }
1218
+
1219
+
1220
+ # ---- share endpoints (spec §5.1) ----------------------------------
1221
+ #
1222
+ # GET /api/share/templates?panel=<id> → list Recap/Visual/Detail
1223
+ # templates registered in _lib_share_templates for that panel.
1224
+ # POST /api/share/render → render one panel-section to
1225
+ # body via the kernel; returns {body, content_type, snapshot}
1226
+ # with kernel_version + data_digest for v2 composer drift checks.
1227
+ #
1228
+ # The template registry is late-imported per-request to keep dashboard
1229
+ # startup cheap — matches cmd_tui's `rich` lazy-import pattern. Same
1230
+ # late-load applies to the kernel (`_lib_share`) via `_share_load_lib`.
1231
+ # GET is unauthenticated (idempotent read). POST gates on
1232
+ # `_check_origin_csrf` (same convention as /api/sync, /api/settings).
1233
+
1234
+ def _share_load_templates_module_impl(handler):
1235
+ """Late-load the share-templates registry, cached in sys.modules.
1236
+
1237
+ Keeps dashboard startup zero-cost — the registry only imports when
1238
+ the first share request arrives. Subsequent requests reuse the
1239
+ sys.modules entry; matches the `_share_load_lib` convention so
1240
+ ShareTemplate identity stays stable across calls.
1241
+ """
1242
+ cached = sys.modules.get("_lib_share_templates")
1243
+ if cached is not None:
1244
+ return cached
1245
+ import importlib.util as _ilu
1246
+ p = pathlib.Path(__file__).resolve().parent / "_lib_share_templates.py"
1247
+ spec = _ilu.spec_from_file_location("_lib_share_templates", p)
1248
+ mod = _ilu.module_from_spec(spec)
1249
+ sys.modules["_lib_share_templates"] = mod
1250
+ try:
1251
+ spec.loader.exec_module(mod)
1252
+ except Exception:
1253
+ sys.modules.pop("_lib_share_templates", None)
1254
+ raise
1255
+ return mod
1256
+
1257
+ def _handle_share_templates_get_impl(handler) -> None:
1258
+ """List share templates registered for the requested panel.
1259
+
1260
+ Query: ?panel=<id>. Rejects missing or non-share-capable panels
1261
+ (e.g., `alerts`) with 400 + {error, field} envelope (matches
1262
+ existing dashboard error shape; see spec §5.5).
1263
+ """
1264
+ import urllib.parse as _urlparse
1265
+ qs = _urlparse.urlparse(handler.path).query
1266
+ params = _urlparse.parse_qs(qs)
1267
+ panel = (params.get("panel", [""])[0] or "").strip()
1268
+ if not panel:
1269
+ handler._respond_json(400, {
1270
+ "error": "missing query param: panel",
1271
+ "field": "panel",
1272
+ })
1273
+ return
1274
+ tpl_mod = handler._share_load_templates_module()
1275
+ if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
1276
+ handler._respond_json(400, {
1277
+ "error": f"unknown share panel: {panel!r}",
1278
+ "field": "panel",
1279
+ })
1280
+ return
1281
+ templates = [
1282
+ {
1283
+ "id": t.id,
1284
+ "label": t.label,
1285
+ "description": t.description,
1286
+ "default_options": dict(t.default_options),
1287
+ }
1288
+ for t in tpl_mod.templates_for_panel(panel)
1289
+ ]
1290
+ handler._respond_json(200, {"panel": panel, "templates": templates})
1291
+
1292
+ def _handle_share_render_post_impl(handler) -> None:
1293
+ """Render a panel-section to body via the share kernel.
1294
+
1295
+ Body shape: ``{panel, template_id, options}``. Validates panel +
1296
+ template_id against the registry, dispatches to the per-panel
1297
+ `_build_<panel>_share_panel_data` helper to assemble the
1298
+ builder-shaped dict from the current dashboard snapshot, runs the
1299
+ template's builder, applies `_scrub` when
1300
+ ``options.reveal_projects`` is False, then renders via
1301
+ `_lib_share.render`. Response: ``{body, content_type, snapshot}``
1302
+ where `snapshot` carries `kernel_version` + `data_digest` for the
1303
+ v2 composer's drift detection (spec §5.2).
1304
+
1305
+ CSRF: Origin/Host parity via `_check_origin_csrf` — same gate as
1306
+ `/api/sync`, `/api/settings`, `/api/alerts/test`.
1307
+ """
1308
+ if not handler._check_origin_csrf():
1309
+ return
1310
+ try:
1311
+ length = int(handler.headers.get("Content-Length", "0") or "0")
1312
+ except ValueError:
1313
+ length = 0
1314
+ if length > _SHARE_POST_MAX_BYTES:
1315
+ # #279 S1 F3: bound the body before reading it (memory/slow-loris).
1316
+ # length == 0 stays allowed below (empty body -> {}); cap only the top.
1317
+ handler._respond_json(400, {"error": "body too large (max 64 KiB)"})
1318
+ return
1319
+ try:
1320
+ raw = handler.rfile.read(length) if length > 0 else b""
1321
+ req = json.loads(raw) if raw else {}
1322
+ except (ValueError, json.JSONDecodeError):
1323
+ handler._respond_json(400, {"error": "malformed json"})
1324
+ return
1325
+ if not isinstance(req, dict):
1326
+ handler._respond_json(400, {"error": "expected JSON object"})
1327
+ return
1328
+ panel = req.get("panel")
1329
+ template_id = req.get("template_id")
1330
+ options = req.get("options") or {}
1331
+ if not isinstance(options, dict):
1332
+ handler._respond_json(400, {
1333
+ "error": "options must be an object",
1334
+ "field": "options",
1335
+ })
1336
+ return
1337
+ # Client `ShareOptions` (dashboard/web/src/share/types.ts) does
1338
+ # not carry `display_tz`; server-side config is the source of
1339
+ # truth. Inject before `_share_apply_period_override` so the
1340
+ # daily panel rebuild and per-day cross-tab bucketing both see
1341
+ # the user's display tz instead of falling back to UTC.
1342
+ if "display_tz" not in options:
1343
+ options["display_tz"] = sys.modules["cctally"].get_display_tz_pref(sys.modules["cctally"].load_config())
1344
+ if not isinstance(panel, str) or not panel:
1345
+ handler._respond_json(400, {
1346
+ "error": "missing or non-string panel",
1347
+ "field": "panel",
1348
+ })
1349
+ return
1350
+ if not isinstance(template_id, str) or not template_id:
1351
+ handler._respond_json(400, {
1352
+ "error": "missing or non-string template_id",
1353
+ "field": "template_id",
1354
+ })
1355
+ return
1356
+ fmt = options.get("format", "html")
1357
+ if fmt not in ("md", "html", "svg"):
1358
+ handler._respond_json(400, {
1359
+ "error": f"unknown format: {fmt!r}",
1360
+ "field": "options.format",
1361
+ })
1362
+ return
1363
+ theme = options.get("theme", "light")
1364
+ if theme not in ("light", "dark"):
1365
+ handler._respond_json(400, {
1366
+ "error": f"unknown theme: {theme!r}",
1367
+ "field": "options.theme",
1368
+ })
1369
+ return
1370
+ # `top_n` may be explicit-null when the UI's Top-N input is
1371
+ # cleared (Knobs.tsx:43); treat null as "use template default"
1372
+ # rather than 400-ing every preview/export until the user types
1373
+ # a number.
1374
+ if options.get("top_n") is not None:
1375
+ top_n_raw = options["top_n"]
1376
+ if not isinstance(top_n_raw, int) or isinstance(top_n_raw, bool) or top_n_raw < 1:
1377
+ handler._respond_json(400, {
1378
+ "error": f"top_n must be a positive integer, got {top_n_raw!r}",
1379
+ "field": "options.top_n",
1380
+ })
1381
+ return
1382
+
1383
+ tpl_mod = handler._share_load_templates_module()
1384
+ if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
1385
+ handler._respond_json(400, {
1386
+ "error": f"unknown share panel: {panel!r}",
1387
+ "field": "panel",
1388
+ })
1389
+ return
1390
+ try:
1391
+ template = tpl_mod.get_template(template_id)
1392
+ except KeyError:
1393
+ handler._respond_json(400, {
1394
+ "error": f"unknown template_id: {template_id!r}",
1395
+ "field": "template_id",
1396
+ })
1397
+ return
1398
+ if template.panel != panel:
1399
+ handler._respond_json(400, {
1400
+ "error": (
1401
+ f"template_id {template_id!r} belongs to panel "
1402
+ f"{template.panel!r}, not {panel!r}"
1403
+ ),
1404
+ "field": "template_id",
1405
+ })
1406
+ return
1407
+
1408
+ # Build panel_data from the live dashboard snapshot — reuses the
1409
+ # already-built `DataSnapshot` so we don't re-query the DB on the
1410
+ # share hot path. `_build_share_panel_data` dispatches per panel.
1411
+ snap_ref = type(handler).snapshot_ref
1412
+ data_snap = snap_ref.get() if snap_ref is not None else None
1413
+ # Period override (current / previous / custom). For
1414
+ # `kind='current'` (the default) this is a no-op; otherwise we
1415
+ # re-build the relevant panel's DataSnapshot field from DB with
1416
+ # a shifted `now_utc` before slicing.
1417
+ data_snap, period_err = _share_apply_period_override(panel, options,
1418
+ data_snap)
1419
+ if period_err is not None:
1420
+ handler._respond_json(400, period_err)
1421
+ return
1422
+ try:
1423
+ panel_data = _build_share_panel_data(panel, options, data_snap)
1424
+ except Exception as exc:
1425
+ handler._respond_json(500, {"error": f"panel_data build failed: {exc}"})
1426
+ return
1427
+
1428
+ # Run template builder → kernel render. Builder produces a
1429
+ # ShareSnapshot; `_scrub` anonymizes project labels when the
1430
+ # client opted in to anon-on-export (`reveal_projects=False`).
1431
+ ls = _share_load_lib()
1432
+ try:
1433
+ snap_built = template.builder(panel_data=panel_data, options=options)
1434
+ except Exception as exc:
1435
+ handler._respond_json(500, {"error": f"builder failed: {exc}"})
1436
+ return
1437
+ snap_built = replace(snap_built, template_id=template_id)
1438
+ # Content toggles (spec §Q4). Defaults match the existing
1439
+ # behavior (chart on, table on); explicit False strips the
1440
+ # corresponding section from the ShareSnapshot. ShareSnapshot
1441
+ # is frozen so we use dataclasses.replace.
1442
+ snap_built = _share_apply_content_toggles(snap_built, options)
1443
+ reveal = bool(options.get("reveal_projects", True))
1444
+ if not reveal:
1445
+ snap_built = ls._scrub(snap_built, reveal_projects=False)
1446
+ try:
1447
+ body = ls.render(
1448
+ snap_built,
1449
+ format=fmt,
1450
+ theme=options.get("theme", "light"),
1451
+ branding=not options.get("no_branding", False),
1452
+ )
1453
+ except Exception as exc:
1454
+ handler._respond_json(500, {"error": f"render failed: {exc}"})
1455
+ return
1456
+ content_type = {
1457
+ "md": "text/markdown",
1458
+ "html": "text/html",
1459
+ "svg": "image/svg+xml",
1460
+ }[fmt]
1461
+
1462
+ # data_digest hashes the inputs that identify the underlying DATA
1463
+ # (panel + template + panel_data), NOT rendering toggles like theme
1464
+ # / branding / reveal_projects / format. Used by the composer to
1465
+ # detect "section data has drifted since add-time" (spec §5.2 /
1466
+ # §7.1) — flipping anon-on-export must not register as drift, since
1467
+ # the underlying data is identical.
1468
+ digest_input = {
1469
+ "panel": panel,
1470
+ "template_id": template_id,
1471
+ "panel_data": panel_data,
1472
+ }
1473
+ try:
1474
+ data_digest = ls._data_digest(digest_input)
1475
+ except Exception:
1476
+ # Defensive: digest is non-blocking for the response — fall
1477
+ # back to an empty string and let the composer treat it as
1478
+ # "always drifted" rather than failing the whole render.
1479
+ data_digest = ""
1480
+
1481
+ handler._respond_json(200, {
1482
+ "body": body,
1483
+ "content_type": content_type,
1484
+ "snapshot": {
1485
+ "kernel_version": ls.KERNEL_VERSION,
1486
+ "panel": panel,
1487
+ "template_id": template_id,
1488
+ "options": options,
1489
+ "generated_at": _share_now_utc_iso(),
1490
+ "data_digest": data_digest,
1491
+ },
1492
+ })
1493
+
1494
+ # ---- /api/share/compose — stitch many basket sections (spec §5.3) ----
1495
+
1496
+ def _handle_share_compose_post_impl(handler) -> None:
1497
+ """Stitch multiple panel sections into one composed document.
1498
+
1499
+ Recipe-only. The server re-renders every section from its
1500
+ ``(panel, template_id, options)`` recipe — never accepting a client-
1501
+ supplied ``body``. Per-section drift detection compares the fresh
1502
+ ``data_digest`` against the client's ``data_digest_at_add``;
1503
+ mismatches surface as ``section_results[i].drift_detected = true``
1504
+ for the composer's "Outdated" badge.
1505
+
1506
+ Spec §5.3, §10.3. CSRF-gated.
1507
+ """
1508
+ if not handler._check_origin_csrf():
1509
+ return
1510
+ try:
1511
+ length = int(handler.headers.get("Content-Length", "0") or "0")
1512
+ except ValueError:
1513
+ length = 0
1514
+ if length > _SHARE_POST_MAX_BYTES:
1515
+ # #279 S1 F3: bound the body before reading it (memory/slow-loris).
1516
+ # length == 0 stays allowed below (empty body -> {}); cap only the top.
1517
+ handler._respond_json(400, {"error": "body too large (max 64 KiB)"})
1518
+ return
1519
+ try:
1520
+ raw = handler.rfile.read(length) if length > 0 else b""
1521
+ req = json.loads(raw) if raw else {}
1522
+ except (ValueError, json.JSONDecodeError):
1523
+ handler._respond_json(400, {"error": "malformed json"})
1524
+ return
1525
+ if not isinstance(req, dict):
1526
+ handler._respond_json(400, {"error": "expected JSON object"})
1527
+ return
1528
+
1529
+ title = req.get("title")
1530
+ theme = req.get("theme", "light")
1531
+ fmt = req.get("format", "html")
1532
+ no_branding = bool(req.get("no_branding", False))
1533
+ reveal_projects = bool(req.get("reveal_projects", False))
1534
+ sections_in = req.get("sections")
1535
+ if not isinstance(title, str) or not title:
1536
+ handler._respond_json(400, {"error": "missing title", "field": "title"})
1537
+ return
1538
+ if theme not in ("light", "dark"):
1539
+ handler._respond_json(400, {"error": f"unknown theme: {theme!r}",
1540
+ "field": "theme"})
1541
+ return
1542
+ if fmt not in ("md", "html", "svg"):
1543
+ handler._respond_json(400, {"error": f"unknown format: {fmt!r}",
1544
+ "field": "format"})
1545
+ return
1546
+ if not isinstance(sections_in, list) or not sections_in:
1547
+ handler._respond_json(400, {
1548
+ "error": "sections must be a non-empty array",
1549
+ "field": "sections",
1550
+ })
1551
+ return
1552
+
1553
+ tpl_mod = handler._share_load_templates_module()
1554
+ ls = _share_load_lib()
1555
+ snap_ref = type(handler).snapshot_ref
1556
+ data_snap = snap_ref.get() if snap_ref is not None else None
1557
+ # Resolve display_tz from config once (client `ShareOptions`
1558
+ # does not carry it); applied to every section's options below
1559
+ # so daily panel rebuilds and per-day cross-tab cells bucket in
1560
+ # the user's display tz, not UTC.
1561
+ composite_display_tz = sys.modules["cctally"].get_display_tz_pref(sys.modules["cctally"].load_config())
1562
+
1563
+ composed_sections: list = []
1564
+ section_results: list[dict] = []
1565
+
1566
+ for idx, sec in enumerate(sections_in):
1567
+ if not isinstance(sec, dict):
1568
+ handler._respond_json(400, {
1569
+ "error": f"sections[{idx}] must be an object",
1570
+ "field": f"sections[{idx}]",
1571
+ })
1572
+ return
1573
+ # Explicit: client-supplied `body` and `content_type` are
1574
+ # silently IGNORED. This is the privacy chokepoint — the
1575
+ # regression test in tests/test_api_share.py guards it.
1576
+ snap_recipe = sec.get("snapshot") or {}
1577
+ panel = snap_recipe.get("panel")
1578
+ template_id = snap_recipe.get("template_id")
1579
+ sec_opts = snap_recipe.get("options") or {}
1580
+ digest_at_add = snap_recipe.get("data_digest_at_add") or ""
1581
+ if (not isinstance(panel, str)
1582
+ or panel not in tpl_mod.SHARE_CAPABLE_PANELS):
1583
+ handler._respond_json(400, {
1584
+ "error": (
1585
+ f"sections[{idx}].snapshot.panel invalid: {panel!r}"
1586
+ ),
1587
+ "field": f"sections[{idx}].snapshot.panel",
1588
+ })
1589
+ return
1590
+ try:
1591
+ template = tpl_mod.get_template(template_id)
1592
+ except KeyError:
1593
+ handler._respond_json(400, {
1594
+ "error": (
1595
+ f"sections[{idx}].snapshot.template_id "
1596
+ f"unknown: {template_id!r}"
1597
+ ),
1598
+ "field": f"sections[{idx}].snapshot.template_id",
1599
+ })
1600
+ return
1601
+ if template.panel != panel:
1602
+ handler._respond_json(400, {
1603
+ "error": (f"sections[{idx}].snapshot.template_id "
1604
+ f"{template_id!r} belongs to panel "
1605
+ f"{template.panel!r}, not {panel!r}"),
1606
+ "field": f"sections[{idx}].snapshot.template_id",
1607
+ })
1608
+ return
1609
+
1610
+ # Force the composite reveal_projects across every section
1611
+ # (spec §8.5: per-section anon at add-time is ignored at compose).
1612
+ composite_opts = {**sec_opts, "reveal_projects": reveal_projects,
1613
+ "theme": theme, "format": fmt,
1614
+ "no_branding": no_branding}
1615
+ composite_opts.setdefault("display_tz", composite_display_tz)
1616
+ # Per-section period override — each basket item carries its
1617
+ # own period recipe, independent of the composite anon flag.
1618
+ sec_snap, period_err = _share_apply_period_override(
1619
+ panel, composite_opts, data_snap,
1620
+ )
1621
+ if period_err is not None:
1622
+ handler._respond_json(400, {
1623
+ "error": f"sections[{idx}]: {period_err['error']}",
1624
+ "field": f"sections[{idx}].snapshot.{period_err['field']}",
1625
+ })
1626
+ return
1627
+ try:
1628
+ panel_data = _build_share_panel_data(panel, composite_opts,
1629
+ sec_snap)
1630
+ except Exception as exc:
1631
+ handler._respond_json(500, {
1632
+ "error": f"sections[{idx}] panel_data build failed: {exc}",
1633
+ })
1634
+ return
1635
+ try:
1636
+ snap_built = template.builder(panel_data=panel_data,
1637
+ options=composite_opts)
1638
+ except Exception as exc:
1639
+ handler._respond_json(500, {
1640
+ "error": f"sections[{idx}] builder failed: {exc}",
1641
+ })
1642
+ return
1643
+ snap_built = replace(snap_built, template_id=template_id)
1644
+ # Same content toggles as the single-section render path.
1645
+ # Per-section `show_chart`/`show_table` from the basket
1646
+ # recipe are applied here; the composite anon flag is
1647
+ # already merged into composite_opts upstream.
1648
+ snap_built = _share_apply_content_toggles(snap_built, composite_opts)
1649
+ if not reveal_projects:
1650
+ snap_built = ls._scrub(snap_built, reveal_projects=False)
1651
+
1652
+ # Defensive: digest is non-blocking metadata — fall back to
1653
+ # "" on failure rather than 500-ing the whole compose
1654
+ # (mirrors the render handler at bin/cctally:33402-33408).
1655
+ try:
1656
+ digest_now = ls._data_digest({
1657
+ "panel": panel,
1658
+ "template_id": template_id,
1659
+ "panel_data": panel_data,
1660
+ })
1661
+ except Exception:
1662
+ digest_now = ""
1663
+ composed_sections.append(ls.ComposedSection(
1664
+ snap=snap_built,
1665
+ drift_detected=(digest_now != digest_at_add),
1666
+ ))
1667
+ section_results.append({
1668
+ "snapshot_id": f"{idx:02d}",
1669
+ "drift_detected": digest_now != digest_at_add,
1670
+ "data_digest_at_add": digest_at_add,
1671
+ "data_digest_now": digest_now,
1672
+ })
1673
+
1674
+ compose_opts = ls.ComposeOptions(
1675
+ title=title, theme=theme, format=fmt,
1676
+ no_branding=no_branding, reveal_projects=reveal_projects,
1677
+ )
1678
+ try:
1679
+ body = ls.compose(tuple(composed_sections), opts=compose_opts)
1680
+ except Exception as exc:
1681
+ handler._respond_json(500, {"error": f"compose failed: {exc}"})
1682
+ return
1683
+
1684
+ content_type = {
1685
+ "md": "text/markdown",
1686
+ "html": "text/html",
1687
+ "svg": "image/svg+xml",
1688
+ }[fmt]
1689
+ handler._respond_json(200, {
1690
+ "body": body,
1691
+ "content_type": content_type,
1692
+ "snapshot": {
1693
+ "kernel_version": ls.KERNEL_VERSION,
1694
+ "composed_at": _share_now_utc_iso(),
1695
+ "section_results": section_results,
1696
+ },
1697
+ })
1698
+
1699
+ # ---- /api/share/presets — saved-recipe CRUD (spec §5.1, §11.3) ----
1700
+ #
1701
+ # GET /api/share/presets → list, grouped by panel
1702
+ # POST /api/share/presets → upsert (panel, name)
1703
+ # DELETE /api/share/presets/{panel}/{name} → remove one preset
1704
+ #
1705
+ # Persistence: `config.json` under `share.presets[<panel>][<name>]` so
1706
+ # the CLI can read them later (CLI consumer is designed for, not
1707
+ # shipped — out of scope per spec §15). GET is unauthenticated like
1708
+ # `/api/share/templates`; POST + DELETE go through `_check_origin_csrf`
1709
+ # (same gate as `/api/sync`, `/api/settings`, `/api/alerts/test`).
1710
+ # Write discipline: `config_writer_lock` + `_load_config_unlocked` +
1711
+ # `save_config` (atomic `os.replace`). Never call `load_config` from
1712
+ # inside the writer lock — `fcntl.flock` is per-fd and would
1713
+ # self-deadlock; see `_cmd_config_set` for the established pattern.
1714
+
1715
+ def _handle_share_presets_get_impl(handler) -> None:
1716
+ """List saved share presets, grouped by panel (spec §5.1, §11.3).
1717
+
1718
+ Read-only — no CSRF gate. `config.json` may not contain the
1719
+ `share.presets` key on first run; returns `{"presets": {}}` then.
1720
+ """
1721
+ cfg = sys.modules["cctally"].load_config()
1722
+ presets = (cfg.get("share") or {}).get("presets") or {}
1723
+ handler._respond_json(200, {"presets": presets})
1724
+
1725
+ def _handle_share_presets_post_impl(handler) -> None:
1726
+ """Create or overwrite a preset (idempotent on `(panel, name)`).
1727
+
1728
+ Body: ``{panel, name, template_id, options}``. CSRF-gated.
1729
+
1730
+ Persistence is a read-modify-write under ``config_writer_lock`` +
1731
+ ``_load_config_unlocked``. The plain ``load_config`` would
1732
+ self-deadlock on the same fcntl.flock fd; see the CLAUDE.md
1733
+ config-write invariant and `_cmd_config_set` for the canonical
1734
+ pattern.
1735
+ """
1736
+ if not handler._check_origin_csrf():
1737
+ return
1738
+ try:
1739
+ length = int(handler.headers.get("Content-Length", "0") or "0")
1740
+ except ValueError:
1741
+ length = 0
1742
+ if length > _SHARE_POST_MAX_BYTES:
1743
+ # #279 S1 F3: bound the body before reading it (memory/slow-loris).
1744
+ # length == 0 stays allowed below (empty body -> {}); cap only the top.
1745
+ handler._respond_json(400, {"error": "body too large (max 64 KiB)"})
1746
+ return
1747
+ try:
1748
+ raw = handler.rfile.read(length) if length > 0 else b""
1749
+ req = json.loads(raw) if raw else {}
1750
+ except (ValueError, json.JSONDecodeError):
1751
+ handler._respond_json(400, {"error": "malformed json"})
1752
+ return
1753
+ if not isinstance(req, dict):
1754
+ handler._respond_json(400, {"error": "expected JSON object"})
1755
+ return
1756
+ panel = req.get("panel")
1757
+ name = req.get("name")
1758
+ template_id = req.get("template_id")
1759
+ options = req.get("options")
1760
+ if not isinstance(panel, str) or not panel:
1761
+ handler._respond_json(400, {
1762
+ "error": "missing or non-string panel",
1763
+ "field": "panel",
1764
+ })
1765
+ return
1766
+ tpl_mod = handler._share_load_templates_module()
1767
+ if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
1768
+ handler._respond_json(400, {
1769
+ "error": f"unknown share panel: {panel!r}",
1770
+ "field": "panel",
1771
+ })
1772
+ return
1773
+ if not isinstance(name, str) or not name or "/" in name or len(name) > 64:
1774
+ handler._respond_json(400, {
1775
+ "error": "name must be 1-64 chars and contain no '/'",
1776
+ "field": "name",
1777
+ })
1778
+ return
1779
+ if not isinstance(template_id, str) or not template_id:
1780
+ handler._respond_json(400, {
1781
+ "error": "missing or non-string template_id",
1782
+ "field": "template_id",
1783
+ })
1784
+ return
1785
+ try:
1786
+ template = tpl_mod.get_template(template_id)
1787
+ except KeyError:
1788
+ handler._respond_json(400, {
1789
+ "error": f"unknown template_id: {template_id!r}",
1790
+ "field": "template_id",
1791
+ })
1792
+ return
1793
+ if template.panel != panel:
1794
+ handler._respond_json(400, {
1795
+ "error": (
1796
+ f"template_id {template_id!r} belongs to panel "
1797
+ f"{template.panel!r}, not {panel!r}"
1798
+ ),
1799
+ "field": "template_id",
1800
+ })
1801
+ return
1802
+ if not isinstance(options, dict):
1803
+ handler._respond_json(400, {
1804
+ "error": "options must be an object",
1805
+ "field": "options",
1806
+ })
1807
+ return
1808
+
1809
+ saved_at = _share_now_utc_iso()
1810
+ record = {"template_id": template_id, "options": options, "saved_at": saved_at}
1811
+
1812
+ with sys.modules["cctally"].config_writer_lock():
1813
+ cfg = _load_config_unlocked()
1814
+ share = cfg.setdefault("share", {})
1815
+ presets = share.setdefault("presets", {})
1816
+ panel_bucket = presets.setdefault(panel, {})
1817
+ panel_bucket[name] = record
1818
+ save_config(cfg)
1819
+ handler._respond_json(200, {"panel": panel, "name": name, **record})
1820
+
1821
+ def _handle_share_presets_delete_impl(handler) -> None:
1822
+ """Remove a preset by `(panel, name)`.
1823
+
1824
+ Path: ``/api/share/presets/{panel}/{name}``. Missing → 404 so
1825
+ DELETE stays meaningful for idempotency-aware clients. CSRF-gated.
1826
+ """
1827
+ if not handler._check_origin_csrf():
1828
+ return
1829
+ import urllib.parse as _urlparse
1830
+ # Strip the query string defensively; the spec only uses path
1831
+ # segments but a stray "?" shouldn't poison the name token.
1832
+ path_only = handler.path.split("?", 1)[0]
1833
+ parts = path_only.split("/")
1834
+ # Expected: ["", "api", "share", "presets", "<panel>", "<name>"]
1835
+ if (
1836
+ len(parts) != 6
1837
+ or parts[1] != "api"
1838
+ or parts[2] != "share"
1839
+ or parts[3] != "presets"
1840
+ or not parts[4]
1841
+ or not parts[5]
1842
+ ):
1843
+ handler._respond_json(400, {"error": "malformed delete path"})
1844
+ return
1845
+ panel = _urlparse.unquote(parts[4])
1846
+ name = _urlparse.unquote(parts[5])
1847
+ with sys.modules["cctally"].config_writer_lock():
1848
+ cfg = _load_config_unlocked()
1849
+ share = cfg.get("share") or {}
1850
+ presets = share.get("presets") or {}
1851
+ panel_bucket = presets.get(panel) or {}
1852
+ if name not in panel_bucket:
1853
+ handler._respond_json(404, {"error": "no such preset"})
1854
+ return
1855
+ del panel_bucket[name]
1856
+ # Tidy empty buckets so GET stays clean.
1857
+ if not panel_bucket:
1858
+ presets.pop(panel, None)
1859
+ save_config(cfg)
1860
+ handler.send_response(204)
1861
+ handler.send_header("Content-Length", "0")
1862
+ handler.end_headers()
1863
+
1864
+ # ---- /api/share/history — export-recipe ring buffer (spec §5.1, §11.4) ----
1865
+ #
1866
+ # GET /api/share/history → list (newest last) of last 20 export recipes
1867
+ # POST /api/share/history → append; server-side FIFO trim to 20
1868
+ # DELETE /api/share/history → clear the entire buffer
1869
+ #
1870
+ # Persisted under `share.history` in `config.json`. Write discipline
1871
+ # matches the presets handlers above: `config_writer_lock` +
1872
+ # `_load_config_unlocked` + `save_config`. GET is unauthenticated
1873
+ # like `/api/share/templates`; POST + DELETE go through
1874
+ # `_check_origin_csrf`. The frontend posts fire-and-forget after
1875
+ # every successful export — history failures are non-fatal.
1876
+
1877
+ def _handle_share_history_get_impl(handler) -> None:
1878
+ """Return the recent-shares ring buffer (newest last, spec §11.4)."""
1879
+ cfg = sys.modules["cctally"].load_config()
1880
+ history = (cfg.get("share") or {}).get("history") or []
1881
+ handler._respond_json(200, {"history": history})
1882
+
1883
+ def _handle_share_history_post_impl(handler) -> None:
1884
+ """Append a recipe to the ring buffer; FIFO trim to 20.
1885
+
1886
+ Body: ``{panel, template_id, options, format, destination}``. The
1887
+ server stamps ``recipe_id`` (random hex) and ``exported_at``
1888
+ (UTC ISO-8601) so the client doesn't need a clock or a UUID lib.
1889
+ CSRF-gated. Read-modify-write under ``config_writer_lock`` —
1890
+ same pattern as the presets POST.
1891
+ """
1892
+ if not handler._check_origin_csrf():
1893
+ return
1894
+ try:
1895
+ length = int(handler.headers.get("Content-Length", "0") or "0")
1896
+ except ValueError:
1897
+ length = 0
1898
+ if length > _SHARE_POST_MAX_BYTES:
1899
+ # #279 S1 F3: bound the body before reading it (memory/slow-loris).
1900
+ # length == 0 stays allowed below (empty body -> {}); cap only the top.
1901
+ handler._respond_json(400, {"error": "body too large (max 64 KiB)"})
1902
+ return
1903
+ try:
1904
+ raw = handler.rfile.read(length) if length > 0 else b""
1905
+ req = json.loads(raw) if raw else {}
1906
+ except (ValueError, json.JSONDecodeError):
1907
+ handler._respond_json(400, {"error": "malformed json"})
1908
+ return
1909
+ if not isinstance(req, dict):
1910
+ handler._respond_json(400, {"error": "expected JSON object"})
1911
+ return
1912
+ panel = req.get("panel")
1913
+ template_id = req.get("template_id")
1914
+ options = req.get("options") or {}
1915
+ fmt = req.get("format")
1916
+ destination = req.get("destination")
1917
+ if not isinstance(panel, str) or not panel:
1918
+ handler._respond_json(400, {
1919
+ "error": "missing or non-string panel",
1920
+ "field": "panel",
1921
+ })
1922
+ return
1923
+ tpl_mod = handler._share_load_templates_module()
1924
+ if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
1925
+ handler._respond_json(400, {
1926
+ "error": f"unknown share panel: {panel!r}",
1927
+ "field": "panel",
1928
+ })
1929
+ return
1930
+ if not isinstance(template_id, str) or not template_id:
1931
+ handler._respond_json(400, {
1932
+ "error": "missing or non-string template_id",
1933
+ "field": "template_id",
1934
+ })
1935
+ return
1936
+ try:
1937
+ template = tpl_mod.get_template(template_id)
1938
+ except KeyError:
1939
+ handler._respond_json(400, {
1940
+ "error": f"unknown template_id: {template_id!r}",
1941
+ "field": "template_id",
1942
+ })
1943
+ return
1944
+ if template.panel != panel:
1945
+ handler._respond_json(400, {
1946
+ "error": (
1947
+ f"template_id {template_id!r} belongs to panel "
1948
+ f"{template.panel!r}, not {panel!r}"
1949
+ ),
1950
+ "field": "template_id",
1951
+ })
1952
+ return
1953
+ if not isinstance(options, dict):
1954
+ handler._respond_json(400, {
1955
+ "error": "options must be an object",
1956
+ "field": "options",
1957
+ })
1958
+ return
1959
+ # `format` and `destination` are advisory strings — accept any
1960
+ # non-empty string; the frontend uses them only as display hints
1961
+ # in the dropdown row. None/missing is allowed (mirrors how the
1962
+ # CLI doesn't always know which destination produced the export).
1963
+ if fmt is not None and not isinstance(fmt, str):
1964
+ handler._respond_json(400, {
1965
+ "error": "format must be a string if provided",
1966
+ "field": "format",
1967
+ })
1968
+ return
1969
+ if destination is not None and not isinstance(destination, str):
1970
+ handler._respond_json(400, {
1971
+ "error": "destination must be a string if provided",
1972
+ "field": "destination",
1973
+ })
1974
+ return
1975
+
1976
+ record = {
1977
+ "recipe_id": _share_history_recipe_id(),
1978
+ "panel": panel,
1979
+ "template_id": template_id,
1980
+ "options": options,
1981
+ "format": fmt,
1982
+ "destination": destination,
1983
+ "exported_at": _share_now_utc_iso(),
1984
+ }
1985
+ with sys.modules["cctally"].config_writer_lock():
1986
+ cfg = _load_config_unlocked()
1987
+ share = cfg.setdefault("share", {})
1988
+ history = share.setdefault("history", [])
1989
+ history.append(record)
1990
+ # Ring buffer: trim from the front so the newest is always
1991
+ # last. `del history[:n]` keeps the same list instance, so
1992
+ # callers holding a reference (none in this scope, but a
1993
+ # safe invariant) see the same object mutated in place.
1994
+ _ring_cap = sys.modules["cctally"]._SHARE_HISTORY_RING_CAP
1995
+ if len(history) > _ring_cap:
1996
+ del history[: len(history) - _ring_cap]
1997
+ save_config(cfg)
1998
+ handler._respond_json(200, record)
1999
+
2000
+ def _handle_share_history_delete_impl(handler) -> None:
2001
+ """Empty the share-history ring buffer (spec §11.4)."""
2002
+ if not handler._check_origin_csrf():
2003
+ return
2004
+ with sys.modules["cctally"].config_writer_lock():
2005
+ cfg = _load_config_unlocked()
2006
+ share = cfg.get("share")
2007
+ if isinstance(share, dict) and "history" in share:
2008
+ share["history"] = []
2009
+ save_config(cfg)
2010
+ handler.send_response(204)
2011
+ handler.send_header("Content-Length", "0")
2012
+ handler.end_headers()