cctally 1.5.0 → 1.6.1

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/bin/cctally CHANGED
@@ -27638,6 +27638,33 @@ def _share_now_utc() -> dt.datetime:
27638
27638
  return dt.datetime.now(dt.timezone.utc)
27639
27639
 
27640
27640
 
27641
+ def _share_now_utc_iso() -> str:
27642
+ """`generated_at` ISO-8601 source for /api/share/render snapshot envelopes.
27643
+
27644
+ Honors `CCTALLY_AS_OF` like `_share_now_utc` so fixture goldens stay
27645
+ deterministic across the CLI and HTTP paths. Format `YYYY-MM-DDTHH:MM:SSZ`.
27646
+ """
27647
+ return _share_now_utc().strftime("%Y-%m-%dT%H:%M:%SZ")
27648
+
27649
+
27650
+ # Spec §11.4 — recent-shares ring buffer caps at 20. Server-side trim
27651
+ # in `_handle_share_history_post` so the on-disk `config.json` can't
27652
+ # grow unbounded even if a misbehaving client floods POSTs.
27653
+ _SHARE_HISTORY_RING_CAP = 20
27654
+
27655
+
27656
+ def _share_history_recipe_id() -> str:
27657
+ """Server-stamped opaque id for a history record.
27658
+
27659
+ Random base16 (26 chars / 13 bytes) is sufficient: we order by
27660
+ insertion (ring buffer position), never by id, so we don't need
27661
+ ULID timestamp-prefix monotonicity. `secrets.token_hex` keeps us
27662
+ on stdlib and avoids the predictability of `random`.
27663
+ """
27664
+ import secrets
27665
+ return secrets.token_hex(13)
27666
+
27667
+
27641
27668
  def _share_resolve_version() -> str:
27642
27669
  """Source from CHANGELOG via the existing release helper. Empty string if unset.
27643
27670
 
@@ -28916,6 +28943,851 @@ def _build_session_snapshot(
28916
28943
  )
28917
28944
 
28918
28945
 
28946
+ # ---- v2 share panel_data builders (spec §5.2, plan M1.6) -------------
28947
+ #
28948
+ # These translate the live dashboard `DataSnapshot` into the dict shapes
28949
+ # the M1.4 Recap builders (in `bin/_lib_share_templates.py`) consume.
28950
+ # They're a thin extract step — the DataSnapshot was already built by
28951
+ # the sync thread, so this path doesn't re-query the DB on the share
28952
+ # hot path.
28953
+ #
28954
+ # Per-panel shape contracts live in each Recap builder's docstring in
28955
+ # `bin/_lib_share_templates.py` (see `_build_<panel>_recap`); the keys
28956
+ # below MUST stay in lockstep with those docstrings — the
28957
+ # producer/consumer contract.
28958
+ #
28959
+ # When the snapshot has no data for a given panel (fresh install, no
28960
+ # sync yet), the builder returns a minimal empty-shaped dict that the
28961
+ # downstream Recap builder renders as a "no data" snapshot (kernel
28962
+ # handles empty `weeks=[]` / `days=[]` / etc.).
28963
+
28964
+
28965
+ def _share_iso(value) -> "str | None":
28966
+ """Coerce a datetime / ISO-string into an ISO-8601 string with `Z` suffix.
28967
+
28968
+ DataSnapshot mixes attribute types (`week_start_at` is a
28969
+ `dt.datetime`; `WeeklyPeriodRow.week_start_at` is already a string).
28970
+ Recap builders' `_parse_iso_utc` accepts both shapes via fromisoformat
28971
+ + `Z`-swap, but normalizing here keeps the wire format consistent.
28972
+ """
28973
+ if value is None:
28974
+ return None
28975
+ if isinstance(value, dt.datetime):
28976
+ v = value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)
28977
+ return v.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
28978
+ return str(value)
28979
+
28980
+
28981
+ # ---- Period override (spec §6.2 Q4 + Codex P2 on PR #35) ----
28982
+ #
28983
+ # The share modal's Period control offers three kinds — current, previous,
28984
+ # custom — but the original render path consumed the dashboard's cached
28985
+ # DataSnapshot directly, which only ever holds "current" data. Override
28986
+ # semantics by panel:
28987
+ #
28988
+ # panel current previous custom (start/end)
28989
+ # -------- ------------------ -------------------- -------------------
28990
+ # weekly this subscription one week earlier week containing end
28991
+ # week
28992
+ # daily last 7 display-tz 7 days earlier 7 days ending at end
28993
+ # days ending today
28994
+ # monthly last 12 months 12 months earlier 12 months ending at end
28995
+ # ending now
28996
+ # trend last 8 weeks 8 weeks earlier 8 weeks ending at end
28997
+ # ending now
28998
+ # blocks recent 5h blocks blocks ending one blocks ending at end
28999
+ # 5h-window earlier
29000
+ # forecast future projection (rejected: previous
29001
+ # from now forecast doesn't exist)
29002
+ # current-week this subscription (rejected: panel IS current)
29003
+ # week
29004
+ # sessions recent sessions (deferred: ambiguous semantics — could
29005
+ # mean "older sessions" or "sessions in
29006
+ # date range"; revisit when use case clear)
29007
+ #
29008
+ # Override mechanics: derive a `now_utc` from the period option and
29009
+ # re-build only the relevant DataSnapshot field by calling the same
29010
+ # `_dashboard_build_*` function the sync thread uses, just with a
29011
+ # shifted `now_utc`. `dataclasses.replace` returns a new DataSnapshot
29012
+ # with that field swapped; everything downstream (panel_data builder,
29013
+ # template builder, kernel render) consumes it unchanged.
29014
+ #
29015
+ # Validation failures land on the request as HTTP 400 with
29016
+ # `field: "options.period.<key>"` so the UI can highlight the offending
29017
+ # control.
29018
+
29019
+ _SHARE_PANELS_PERIOD_FIXED = ("forecast", "current-week", "sessions")
29020
+ # Panels whose period is intrinsic to the panel's identity. We accept
29021
+ # `kind="current"` (= no override) and reject anything else with 400.
29022
+
29023
+ _SHARE_PANELS_PERIOD_OVERRIDABLE = ("weekly", "daily", "monthly", "trend", "blocks")
29024
+
29025
+
29026
+ def _share_resolve_period(panel: str, options: dict):
29027
+ """Return (now_utc_override, start_override, error_dict) for the period.
29028
+
29029
+ - `(None, None, None)` — no override needed (period absent or
29030
+ `kind="current"`). Caller continues with the cached DataSnapshot.
29031
+ - `(datetime, None, None)` — `kind="previous"`. Caller rebuilds with
29032
+ this `now_utc`; window length stays at the panel default.
29033
+ - `(datetime, datetime, None)` — `kind="custom"`. Caller rebuilds
29034
+ with `now_utc = end_dt` AND a derived window length spanning
29035
+ `[start_dt, end_dt]` (computed by `_share_apply_period_override`
29036
+ per panel). Spec §6.3 advertises "Custom (start–end pickers)";
29037
+ honoring the start picker means the rendered window's left edge
29038
+ moves with it. The 2-tuple form silently ignored `start_dt`.
29039
+ - `(None, None, {...})` — validation failure; caller emits 400.
29040
+
29041
+ `parse_iso_datetime` (the same parser used by every other share
29042
+ surface) accepts trailing `Z` / `+HH:MM` and naive forms. Naive
29043
+ inputs are treated as UTC by `parse_iso_datetime` and downstream
29044
+ UTC-fixup, so a date-only string like ``"2026-05-04"`` lands at
29045
+ midnight UTC.
29046
+ """
29047
+ period = options.get("period")
29048
+ if period is None or not isinstance(period, dict):
29049
+ # Absent → no override, defaults to current. (Permissive: the
29050
+ # UI always sends a period block, but older basket recipes /
29051
+ # CLI parity may omit it.)
29052
+ return (None, None, None)
29053
+ kind = period.get("kind", "current")
29054
+ if kind not in ("current", "previous", "custom"):
29055
+ return (None, None, {"error": f"unknown period kind: {kind!r}",
29056
+ "field": "options.period.kind"})
29057
+ if panel in _SHARE_PANELS_PERIOD_FIXED:
29058
+ if kind != "current":
29059
+ return (None, None, {
29060
+ "error": (f"panel {panel!r} only supports period kind='current'; "
29061
+ f"got {kind!r}"),
29062
+ "field": "options.period.kind",
29063
+ })
29064
+ return (None, None, None)
29065
+ # Overridable panels — handle each kind.
29066
+ if kind == "current":
29067
+ return (None, None, None)
29068
+ if kind == "previous":
29069
+ delta = _share_previous_period_delta(panel)
29070
+ return (_share_now_utc() - delta, None, None)
29071
+ # kind == "custom"
29072
+ start_str = period.get("start")
29073
+ end_str = period.get("end")
29074
+ if not isinstance(start_str, str) or not start_str \
29075
+ or not isinstance(end_str, str) or not end_str:
29076
+ return (None, None, {
29077
+ "error": "custom period requires non-empty start + end ISO dates",
29078
+ "field": "options.period",
29079
+ })
29080
+ try:
29081
+ start_dt = parse_iso_datetime(start_str, "options.period.start")
29082
+ end_dt = parse_iso_datetime(end_str, "options.period.end")
29083
+ except ValueError as exc:
29084
+ return (None, None, {"error": f"invalid period date: {exc}",
29085
+ "field": "options.period"})
29086
+ if end_dt <= start_dt:
29087
+ return (None, None, {
29088
+ "error": ("custom period end must be strictly after start "
29089
+ f"(got start={start_str!r}, end={end_str!r})"),
29090
+ "field": "options.period",
29091
+ })
29092
+ return (end_dt, start_dt, None)
29093
+
29094
+
29095
+ def _share_custom_window_n(panel: str, start_dt: "dt.datetime",
29096
+ end_dt: "dt.datetime") -> int:
29097
+ """Per-panel window length covering `[start_dt, end_dt]`, min 1.
29098
+
29099
+ Each overridable panel exposes a different unit:
29100
+ - weekly / trend → weeks
29101
+ - daily → days (inclusive)
29102
+ - monthly → calendar months (inclusive)
29103
+ Blocks doesn't use this helper — its builder is window-anchored via
29104
+ `week_start_at`/`week_end_at`, not `n`, so we pass `start_dt`/`end_dt`
29105
+ directly to `_dashboard_build_blocks_panel`.
29106
+
29107
+ Inputs are timezone-aware UTC datetimes (`parse_iso_datetime` UTCs
29108
+ naive inputs upstream). Math is purely on the timedelta + calendar
29109
+ diffs; `_dashboard_build_monthly_periods` does its own display-tz
29110
+ bucketing on the resulting window.
29111
+ """
29112
+ import math as _math
29113
+ delta_seconds = (end_dt - start_dt).total_seconds()
29114
+ delta_days = _math.ceil(delta_seconds / 86400.0)
29115
+ if panel in ("weekly", "trend"):
29116
+ return max(1, _math.ceil(delta_days / 7))
29117
+ if panel == "daily":
29118
+ return max(1, int(delta_days))
29119
+ if panel == "monthly":
29120
+ months = ((end_dt.year - start_dt.year) * 12
29121
+ + (end_dt.month - start_dt.month) + 1)
29122
+ return max(1, months)
29123
+ # Shouldn't reach here — `_share_apply_period_override` handles
29124
+ # blocks separately. Defensive: return 1 rather than raising.
29125
+ return 1
29126
+
29127
+
29128
+ def _share_previous_period_delta(panel: str) -> "dt.timedelta":
29129
+ """How far back `now_utc` shifts for `kind='previous'` on each panel.
29130
+
29131
+ weekly/daily: 7 days. monthly: one whole month worth (we shift to
29132
+ the last day of the previous month at call time to handle variable
29133
+ month length, so this is unused — the caller routes through
29134
+ `_share_resolve_period` which special-cases monthly). trend: 8 weeks
29135
+ (one trend window). blocks: 5 hours (one block).
29136
+ """
29137
+ if panel == "weekly":
29138
+ return dt.timedelta(days=7)
29139
+ if panel == "daily":
29140
+ return dt.timedelta(days=7)
29141
+ if panel == "monthly":
29142
+ return dt.timedelta(days=30) # close-enough for the resolver;
29143
+ # see _share_resolve_period_monthly
29144
+ # below for the calendar-aware
29145
+ # version when needed.
29146
+ if panel == "trend":
29147
+ return dt.timedelta(days=8 * 7)
29148
+ if panel == "blocks":
29149
+ return dt.timedelta(hours=5)
29150
+ raise ValueError(f"_share_previous_period_delta: no delta for panel {panel!r}")
29151
+
29152
+
29153
+ def _share_apply_period_override(panel: str, options: dict,
29154
+ snap: "DataSnapshot | None"):
29155
+ """Return (snap_or_None, error_dict_or_None).
29156
+
29157
+ Walks `_share_resolve_period`, then re-builds the panel's DataSnapshot
29158
+ field from DB when an override is requested. `dataclasses.replace`
29159
+ yields a shallow copy with one field swapped. Returns the original
29160
+ `snap` unchanged when no override applies.
29161
+ """
29162
+ if snap is None:
29163
+ # No cached snapshot to override against — return None unchanged
29164
+ # and let the panel_data builder's empty-snapshot path handle it.
29165
+ # Still validate the period option so the user gets a 400 on
29166
+ # malformed input even before the sync thread's first tick.
29167
+ _, _, err = _share_resolve_period(panel, options)
29168
+ return (snap, err)
29169
+ now_override, start_override, err = _share_resolve_period(panel, options)
29170
+ if err is not None:
29171
+ return (None, err)
29172
+ if now_override is None:
29173
+ return (snap, None)
29174
+ # For `kind="custom"`, derive a per-panel window length covering
29175
+ # `[start_override, now_override]` so the rendered window honors the
29176
+ # Start picker (spec §6.3). For `kind="previous"`, `start_override`
29177
+ # is None → window length stays at the panel's default.
29178
+ n_override = (
29179
+ _share_custom_window_n(panel, start_override, now_override)
29180
+ if start_override is not None else None
29181
+ )
29182
+ import dataclasses as _dc
29183
+ conn = open_db()
29184
+ try:
29185
+ if panel == "weekly":
29186
+ kwargs: dict = {"skip_sync": True}
29187
+ if n_override is not None:
29188
+ kwargs["n"] = n_override
29189
+ rows = _dashboard_build_weekly_periods(conn, now_override, **kwargs)
29190
+ return (_dc.replace(snap, weekly_periods=rows), None)
29191
+ if panel == "daily":
29192
+ display_tz_name = options.get("display_tz", "Etc/UTC")
29193
+ try:
29194
+ display_tz = ZoneInfo(display_tz_name) if display_tz_name else None
29195
+ except Exception:
29196
+ display_tz = None
29197
+ kwargs = {"skip_sync": True, "display_tz": display_tz}
29198
+ if n_override is not None:
29199
+ kwargs["n"] = n_override
29200
+ rows = _dashboard_build_daily_panel(conn, now_override, **kwargs)
29201
+ return (_dc.replace(snap, daily_panel=rows), None)
29202
+ if panel == "monthly":
29203
+ kwargs = {"skip_sync": True}
29204
+ if n_override is not None:
29205
+ kwargs["n"] = n_override
29206
+ rows = _dashboard_build_monthly_periods(conn, now_override, **kwargs)
29207
+ return (_dc.replace(snap, monthly_periods=rows), None)
29208
+ if panel == "trend":
29209
+ kwargs = {"skip_sync": True}
29210
+ if n_override is not None:
29211
+ kwargs["count"] = n_override
29212
+ rows = _tui_build_trend(conn, now_override, **kwargs)
29213
+ return (_dc.replace(snap, trend=rows), None)
29214
+ if panel == "blocks":
29215
+ # `_dashboard_build_blocks_panel` is window-anchored via
29216
+ # `week_start_at`/`week_end_at`, not `n`. For `kind='custom'`,
29217
+ # use the user's [start_dt, end_dt] verbatim. For
29218
+ # `kind='previous'`, fall back to a 7-day window ending at
29219
+ # the override `now_utc` (the spec's prior-block semantics —
29220
+ # intentionally NOT aligned to subscription-week boundaries
29221
+ # since the share period override is wall-clock-aware, not
29222
+ # quota-aware).
29223
+ if start_override is not None:
29224
+ week_start_at = start_override
29225
+ week_end_at = now_override
29226
+ else:
29227
+ week_start_at = now_override - dt.timedelta(days=7)
29228
+ week_end_at = now_override
29229
+ rows = _dashboard_build_blocks_panel(
29230
+ conn, now_override,
29231
+ week_start_at=week_start_at,
29232
+ week_end_at=week_end_at,
29233
+ skip_sync=True,
29234
+ )
29235
+ return (_dc.replace(snap, blocks_panel=rows), None)
29236
+ # forecast / current-week / sessions: resolver already gated; we
29237
+ # only reach here for `kind="current"`, which returns no
29238
+ # override.
29239
+ return (snap, None)
29240
+ finally:
29241
+ conn.close()
29242
+
29243
+
29244
+ def _share_apply_content_toggles(snap_built, options: dict):
29245
+ """Strip chart / table from a built ShareSnapshot per render options.
29246
+
29247
+ The render kernel consumes whatever the template builder emits, so
29248
+ chart/table on-off can't be expressed by the builder alone (every
29249
+ builder unconditionally emits both). Apply the toggle here, after
29250
+ the builder, before `_scrub` and `render`. ShareSnapshot is frozen;
29251
+ `dataclasses.replace` returns a new instance.
29252
+
29253
+ Defaults preserve pre-toggle behavior: `show_chart` defaults to
29254
+ True, `show_table` defaults to True. Explicit False on either
29255
+ drops the corresponding payload.
29256
+ """
29257
+ import dataclasses as _dc
29258
+ show_chart = bool(options.get("show_chart", True))
29259
+ show_table = bool(options.get("show_table", True))
29260
+ changes: dict = {}
29261
+ if not show_chart:
29262
+ changes["chart"] = None
29263
+ if not show_table:
29264
+ changes["columns"] = ()
29265
+ changes["rows"] = ()
29266
+ if not changes:
29267
+ return snap_built
29268
+ return _dc.replace(snap_built, **changes)
29269
+
29270
+
29271
+ # Cap on how many `(project, cost)` rows builders return for top_projects.
29272
+ # Templates take `top_n` from options (default 5, see _lib_share_templates)
29273
+ # and apply their own cap on top of this. The headroom matters because:
29274
+ # (a) the scrubber walks ProjectCells once per row, so unbounded length
29275
+ # balloons render-time anonymization cost;
29276
+ # (b) the live preview iframe streams the full table chrome;
29277
+ # (c) 20 covers any realistic `top_n` knob value (UI typically caps at 10).
29278
+ _SHARE_TOP_PROJECTS_BUILDER_CAP = 20
29279
+
29280
+
29281
+ def _share_top_projects_for_range(
29282
+ range_start: "dt.datetime",
29283
+ range_end: "dt.datetime",
29284
+ *,
29285
+ skip_sync: bool = True,
29286
+ ) -> list[tuple[str, float]]:
29287
+ """Aggregate session_entries in `[range_start, range_end]` by project_path.
29288
+
29289
+ Returns `[(project_path_or_'(unknown)', cost_usd), ...]` sorted desc by
29290
+ cost and capped at `_SHARE_TOP_PROJECTS_BUILDER_CAP`. Templates apply
29291
+ a further `top_n` cap (default 5).
29292
+
29293
+ Routes through `get_claude_session_entries` so we get `project_path`
29294
+ in the join — same cache-first/lock-contention/direct-JSONL fallback
29295
+ chain the rest of the share path relies on. `skip_sync=True` by
29296
+ default: the sync thread has already done its tick at snapshot-build
29297
+ time, and a per-request ingest would block the share render on
29298
+ `cache.db.lock`.
29299
+
29300
+ Cost computation goes through `_calculate_entry_cost` — the
29301
+ single-source-of-truth pricing path. Mirrors `_compute_block_totals`'
29302
+ `by_project` bucketing exactly, so the reconcile invariant
29303
+ `SUM(top_projects) ≈ panel.cost_usd` is preserved within ULP drift
29304
+ when the panel's cost matches the same time range (e.g., current
29305
+ week, current 5h block).
29306
+
29307
+ NULL `project_path` collapses to the `(unknown)` sentinel. Anon
29308
+ happens later in `_scrub()`; builders always emit real names per
29309
+ the kernel's privacy chokepoint contract.
29310
+ """
29311
+ bucket: dict[str, float] = {}
29312
+ try:
29313
+ entries = get_claude_session_entries(
29314
+ range_start, range_end, skip_sync=skip_sync,
29315
+ )
29316
+ except Exception:
29317
+ # `get_claude_session_entries` already has its own fallback chain,
29318
+ # but if even that fails (e.g., HOME unset in a fixture run with
29319
+ # no monkeypatch), don't break the whole share render — just emit
29320
+ # an empty top_projects.
29321
+ return []
29322
+ for entry in entries:
29323
+ usage = {
29324
+ "input_tokens": entry.input_tokens,
29325
+ "output_tokens": entry.output_tokens,
29326
+ "cache_creation_input_tokens": entry.cache_creation_tokens,
29327
+ "cache_read_input_tokens": entry.cache_read_tokens,
29328
+ }
29329
+ cost = _calculate_entry_cost(
29330
+ entry.model, usage, mode="auto", cost_usd=entry.cost_usd,
29331
+ )
29332
+ key = entry.project_path or "(unknown)"
29333
+ bucket[key] = bucket.get(key, 0.0) + cost
29334
+ ranked = sorted(bucket.items(), key=lambda kv: -kv[1])
29335
+ return [(path, cost) for path, cost in ranked[:_SHARE_TOP_PROJECTS_BUILDER_CAP]]
29336
+
29337
+
29338
+ def _build_share_panel_data(panel: str, options: dict,
29339
+ snap: "DataSnapshot | None") -> dict:
29340
+ """Dispatch to the per-panel builder; reuses the dashboard DataSnapshot.
29341
+
29342
+ Each per-panel builder reads from the already-built `DataSnapshot`
29343
+ rather than re-running CLI aggregation queries — keeps /api/share/render
29344
+ cheap and ensures the share artifact matches what the dashboard panel
29345
+ is currently showing.
29346
+ """
29347
+ if panel == "weekly": return _build_weekly_share_panel_data(options, snap)
29348
+ if panel == "daily": return _build_daily_share_panel_data(options, snap)
29349
+ if panel == "monthly": return _build_monthly_share_panel_data(options, snap)
29350
+ if panel == "trend": return _build_trend_share_panel_data(options, snap)
29351
+ if panel == "forecast": return _build_forecast_share_panel_data(options, snap)
29352
+ if panel == "blocks": return _build_blocks_share_panel_data(options, snap)
29353
+ if panel == "sessions": return _build_sessions_share_panel_data(options, snap)
29354
+ if panel == "current-week": return _build_current_week_share_panel_data(options, snap)
29355
+ raise ValueError(f"unknown share panel: {panel!r}")
29356
+
29357
+
29358
+ def _share_empty_week_stub() -> dict:
29359
+ """Minimal week shape so empty snapshots render as "no data" cleanly.
29360
+
29361
+ Recap builders index `weeks[idx]` directly; supplying one zero-filled
29362
+ row keeps that access safe without leaking misleading numbers (the
29363
+ rendered artifact shows $0.00 / 0.0% — accurate for an empty install).
29364
+ """
29365
+ return {
29366
+ "start_date": _share_now_utc().strftime("%Y-%m-%d"),
29367
+ "cost_usd": 0.0,
29368
+ "pct_used": 0.0,
29369
+ "dollar_per_pct": 0.0,
29370
+ "top_projects": [],
29371
+ }
29372
+
29373
+
29374
+ def _build_weekly_share_panel_data(options: dict,
29375
+ snap: "DataSnapshot | None") -> dict:
29376
+ """Weekly panel_data — last 8 subscription weeks + current-week index.
29377
+
29378
+ Reuses `DataSnapshot.weekly_periods` (WeeklyPeriodRow list), already
29379
+ built by `_dashboard_build_weekly_periods` in the sync thread. Empty
29380
+ snapshots emit a one-week stub so the Recap builder's `weeks[idx]`
29381
+ access stays safe (renders as $0.00 / 0.0% — accurate "no data").
29382
+ """
29383
+ rows = list(getattr(snap, "weekly_periods", None) or []) if snap else []
29384
+ # weekly_periods is newest-first (see _dashboard_build_weekly_periods).
29385
+ # Take the newest 8 and reverse to oldest→newest — the Recap template
29386
+ # reads weeks[0] as the start anchor and weeks[-1] as the right-edge
29387
+ # (current-week) anchor, and current_week_index addresses that order.
29388
+ rows_8 = list(reversed(rows[:8]))
29389
+ weeks: list[dict] = []
29390
+ current_idx = 0
29391
+ for i, r in enumerate(rows_8):
29392
+ if getattr(r, "is_current", False):
29393
+ current_idx = i
29394
+ # WeeklyPeriodRow.week_start_at is an ISO datetime string; the
29395
+ # Recap shape wants a YYYY-MM-DD date label. Slice the leading
29396
+ # 10 chars (or fall back to parsing).
29397
+ wsa = getattr(r, "week_start_at", "") or ""
29398
+ start_date = wsa[:10] if isinstance(wsa, str) and len(wsa) >= 10 else wsa
29399
+ cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
29400
+ used_pct_raw = getattr(r, "used_pct", None)
29401
+ used_pct = (float(used_pct_raw) / 100.0) if used_pct_raw is not None else 0.0
29402
+ dpp = float(getattr(r, "dollar_per_pct", 0.0) or 0.0)
29403
+ # Per-week top_projects: WeeklyPeriodRow doesn't carry a
29404
+ # per-project rollup, but `week_start_at` / `week_end_at` give us
29405
+ # an exact range — aggregate session_entries once per week so the
29406
+ # Recap template's `weeks[i].top_projects` table is meaningful.
29407
+ # 8 queries per share render is the perf trade; cached.
29408
+ week_end_at = getattr(r, "week_end_at", "") or ""
29409
+ top_projects: list[tuple[str, float]] = []
29410
+ try:
29411
+ ws_dt = parse_iso_datetime(wsa, "week_start_at") if isinstance(wsa, str) and wsa else None
29412
+ we_dt = parse_iso_datetime(week_end_at, "week_end_at") if isinstance(week_end_at, str) and week_end_at else None
29413
+ except ValueError:
29414
+ ws_dt = we_dt = None
29415
+ if ws_dt is not None and we_dt is not None:
29416
+ top_projects = _share_top_projects_for_range(ws_dt, we_dt)
29417
+ weeks.append({
29418
+ "start_date": start_date,
29419
+ "cost_usd": cost,
29420
+ "pct_used": used_pct,
29421
+ "dollar_per_pct": dpp,
29422
+ "top_projects": top_projects,
29423
+ })
29424
+ if not weeks:
29425
+ weeks = [_share_empty_week_stub()]
29426
+ return {"weeks": weeks, "current_week_index": current_idx}
29427
+
29428
+
29429
+ def _build_current_week_share_panel_data(options: dict,
29430
+ snap: "DataSnapshot | None") -> dict:
29431
+ """Current-week panel_data — KPI strip + daily progression + top projects.
29432
+
29433
+ Synthesized from `DataSnapshot.current_week` + `daily_panel` (no 1:1
29434
+ CLI counterpart, per spec §9.5). `daily_progression` clips the daily
29435
+ panel to the current subscription week.
29436
+ """
29437
+ cw = getattr(snap, "current_week", None) if snap else None
29438
+ daily = list(getattr(snap, "daily_panel", None) or []) if snap else []
29439
+ if cw is None:
29440
+ # Empty-shape fallback — Recap builder renders "no data" gracefully.
29441
+ return {
29442
+ "kpi_cost_usd": 0.0,
29443
+ "kpi_pct_used": 0.0,
29444
+ "kpi_dollar_per_pct": 0.0,
29445
+ "kpi_days_remaining": 0.0,
29446
+ "daily_progression": [],
29447
+ "top_projects": [],
29448
+ "week_start_date": _share_now_utc().strftime("%Y-%m-%d"),
29449
+ "display_tz": options.get("display_tz", "Etc/UTC"),
29450
+ }
29451
+ week_start = getattr(cw, "week_start_at", None)
29452
+ week_end = getattr(cw, "week_end_at", None)
29453
+ week_start_date = (
29454
+ week_start.strftime("%Y-%m-%d") if isinstance(week_start, dt.datetime)
29455
+ else _share_now_utc().strftime("%Y-%m-%d")
29456
+ )
29457
+ # Days remaining = hours_to_reset / 24
29458
+ days_remaining = 0.0
29459
+ if isinstance(week_end, dt.datetime):
29460
+ remaining = (week_end - _share_now_utc()).total_seconds() / 86400.0
29461
+ days_remaining = max(0.0, remaining)
29462
+ used_pct = float(getattr(cw, "used_pct", 0.0) or 0.0) / 100.0
29463
+ progression: list[dict] = []
29464
+ if isinstance(week_start, dt.datetime):
29465
+ ws_date = week_start.date()
29466
+ # daily_panel is newest-first; iterate reversed so progression is
29467
+ # oldest→newest, matching the Recap template's progression[-1] =
29468
+ # today contract and the chart's left→right time axis.
29469
+ for r in reversed(daily):
29470
+ try:
29471
+ d = dt.date.fromisoformat(getattr(r, "date", "") or "")
29472
+ except ValueError:
29473
+ continue
29474
+ if d >= ws_date:
29475
+ progression.append({
29476
+ "date": d.isoformat(),
29477
+ "cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
29478
+ })
29479
+ # Current-week top_projects: aggregate from `[week_start, now]`.
29480
+ # `cw.week_end_at` is the reset instant; using `now` keeps the rollup
29481
+ # symmetric with the panel's "spent this week" KPI (week-to-date).
29482
+ top_projects: list[tuple[str, float]] = []
29483
+ if isinstance(week_start, dt.datetime):
29484
+ top_projects = _share_top_projects_for_range(
29485
+ week_start, _share_now_utc(),
29486
+ )
29487
+ return {
29488
+ "kpi_cost_usd": float(getattr(cw, "spent_usd", 0.0) or 0.0),
29489
+ "kpi_pct_used": used_pct,
29490
+ "kpi_dollar_per_pct": float(getattr(cw, "dollars_per_percent", 0.0) or 0.0),
29491
+ "kpi_days_remaining": days_remaining,
29492
+ "daily_progression": progression,
29493
+ "top_projects": top_projects,
29494
+ "week_start_date": week_start_date,
29495
+ "display_tz": options.get("display_tz", "Etc/UTC"),
29496
+ }
29497
+
29498
+
29499
+ def _build_trend_share_panel_data(options: dict,
29500
+ snap: "DataSnapshot | None") -> dict:
29501
+ """Trend panel_data — 8 weeks of $/% + 3-week delta KPI.
29502
+
29503
+ Reuses `DataSnapshot.trend` (TuiTrendRow list, already 8 rows).
29504
+ """
29505
+ trend = list(getattr(snap, "trend", None) or []) if snap else []
29506
+ weeks: list[dict] = []
29507
+ for r in trend:
29508
+ wsa = getattr(r, "week_start_at", None)
29509
+ start_date = (
29510
+ wsa.strftime("%Y-%m-%d") if isinstance(wsa, dt.datetime)
29511
+ else (str(wsa)[:10] if wsa else "")
29512
+ )
29513
+ used_pct_raw = getattr(r, "used_pct", None)
29514
+ used_pct = (float(used_pct_raw) / 100.0) if used_pct_raw is not None else 0.0
29515
+ dpp = float(getattr(r, "dollars_per_percent", 0.0) or 0.0)
29516
+ weeks.append({
29517
+ "start_date": start_date,
29518
+ "cost_usd": dpp * (used_pct * 100.0), # ≈ row total
29519
+ "pct_used": used_pct,
29520
+ "dollar_per_pct": dpp,
29521
+ })
29522
+ # Compute 3-week delta: compare last row vs row-4-from-end.
29523
+ delta = {"dpp_change_pct": 0.0, "cost_change_usd": 0.0}
29524
+ if len(weeks) >= 4:
29525
+ cur = weeks[-1]
29526
+ ref = weeks[-4]
29527
+ if ref["dollar_per_pct"]:
29528
+ delta["dpp_change_pct"] = (
29529
+ (cur["dollar_per_pct"] - ref["dollar_per_pct"]) / ref["dollar_per_pct"]
29530
+ )
29531
+ delta["cost_change_usd"] = cur["cost_usd"] - ref["cost_usd"]
29532
+ return {"weeks": weeks, "delta_3_weeks": delta}
29533
+
29534
+
29535
+ def _build_daily_share_panel_data(options: dict,
29536
+ snap: "DataSnapshot | None") -> dict:
29537
+ """Daily panel_data — last 7 days with top model per day + top projects.
29538
+
29539
+ Reuses `DataSnapshot.daily_panel` (DailyPanelRow list, 30 rows in
29540
+ full); clips to the most recent 7 for the Recap.
29541
+ """
29542
+ daily = list(getattr(snap, "daily_panel", None) or []) if snap else []
29543
+ # daily_panel is newest-first (today at index 0); take the most recent
29544
+ # 7 and reverse to oldest→newest so the Recap template's days[-1]
29545
+ # anchor lands on today.
29546
+ last_7 = list(reversed(daily[:7]))
29547
+ total = sum(float(getattr(r, "cost_usd", 0.0) or 0.0) for r in last_7) or 1.0
29548
+ days: list[dict] = []
29549
+ for r in last_7:
29550
+ cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
29551
+ models = getattr(r, "models", None) or []
29552
+ top_model = (models[0].get("model") if models else None) or "—"
29553
+ days.append({
29554
+ "date": getattr(r, "date", "") or "",
29555
+ "cost_usd": cost,
29556
+ "pct_of_period": cost / total,
29557
+ "top_model": top_model,
29558
+ })
29559
+ # Daily top_projects: aggregate over the 7-day window. Derive the
29560
+ # range from the dates rendered above so the rollup covers exactly
29561
+ # what the panel shows (rather than re-deriving "7 days ago" from
29562
+ # now and potentially clipping the oldest bucket).
29563
+ top_projects: list[tuple[str, float]] = []
29564
+ if days:
29565
+ try:
29566
+ range_start = dt.datetime.fromisoformat(
29567
+ f"{days[0]['date']}T00:00:00+00:00"
29568
+ )
29569
+ # Include the last day in full — end-exclusive boundary at
29570
+ # the start of the next UTC day.
29571
+ last_date = dt.date.fromisoformat(days[-1]["date"])
29572
+ range_end = dt.datetime(
29573
+ last_date.year, last_date.month, last_date.day,
29574
+ tzinfo=dt.timezone.utc,
29575
+ ) + dt.timedelta(days=1)
29576
+ top_projects = _share_top_projects_for_range(range_start, range_end)
29577
+ except (ValueError, KeyError):
29578
+ top_projects = []
29579
+ return {"days": days, "top_projects": top_projects}
29580
+
29581
+
29582
+ def _build_monthly_share_panel_data(options: dict,
29583
+ snap: "DataSnapshot | None") -> dict:
29584
+ """Monthly panel_data — last 12 months + top projects.
29585
+
29586
+ Reuses `DataSnapshot.monthly_periods` (MonthlyPeriodRow list).
29587
+ `used_pct` isn't stored on MonthlyPeriodRow (monthly aggregates
29588
+ don't carry a subscription-quota %), so it surfaces as 0.0.
29589
+ """
29590
+ rows = list(getattr(snap, "monthly_periods", None) or []) if snap else []
29591
+ # monthly_periods is newest-first (see _dashboard_build_monthly_periods).
29592
+ # Reverse to oldest→newest — the Recap template reads months[0] as the
29593
+ # period-start anchor and months[-1] as the most recent month.
29594
+ rows = list(reversed(rows))
29595
+ months: list[dict] = []
29596
+ for r in rows:
29597
+ models = getattr(r, "models", None) or []
29598
+ top_model = (models[0].get("model") if models else None) or "—"
29599
+ months.append({
29600
+ "month": getattr(r, "label", "") or "", # "YYYY-MM"
29601
+ "cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
29602
+ "pct_used": 0.0,
29603
+ "top_model": top_model,
29604
+ })
29605
+ # Monthly top_projects: aggregate across the entire 12-month window.
29606
+ # Range = [first day of oldest month, last day of newest month + 1].
29607
+ top_projects: list[tuple[str, float]] = []
29608
+ if months:
29609
+ try:
29610
+ oldest_year, oldest_month = months[0]["month"].split("-")
29611
+ newest_year, newest_month = months[-1]["month"].split("-")
29612
+ range_start = dt.datetime(
29613
+ int(oldest_year), int(oldest_month), 1,
29614
+ tzinfo=dt.timezone.utc,
29615
+ )
29616
+ # End-exclusive: first day of the month AFTER the newest one.
29617
+ ny, nm = int(newest_year), int(newest_month) + 1
29618
+ if nm == 13:
29619
+ ny += 1
29620
+ nm = 1
29621
+ range_end = dt.datetime(ny, nm, 1, tzinfo=dt.timezone.utc)
29622
+ top_projects = _share_top_projects_for_range(range_start, range_end)
29623
+ except (ValueError, KeyError):
29624
+ top_projects = []
29625
+ return {"months": months, "top_projects": top_projects}
29626
+
29627
+
29628
+ def _build_forecast_share_panel_data(options: dict,
29629
+ snap: "DataSnapshot | None") -> dict:
29630
+ """Forecast panel_data — projection + per-day budgets + days-to-ceiling.
29631
+
29632
+ Reuses `DataSnapshot.forecast` (ForecastOutput).
29633
+ `projection_curve` is synthesized from `r_avg` / `r_recent` /
29634
+ `inputs.p_now` — the same arithmetic `snapshot_to_envelope` does for
29635
+ `week_avg_projection_pct` / `recent_24h_projection_pct`, extended
29636
+ across the next 7 days.
29637
+ """
29638
+ fc = getattr(snap, "forecast", None) if snap else None
29639
+ if fc is None:
29640
+ return {
29641
+ "projected_end_pct": 0.0,
29642
+ "days_to_100pct": 0.0,
29643
+ "days_to_90pct": 0.0,
29644
+ "daily_budgets": {
29645
+ "avg": 0.0, "recent_24h": 0.0,
29646
+ "until_90pct": 0.0, "until_100pct": 0.0,
29647
+ },
29648
+ "projection_curve": [],
29649
+ "confidence": "LOW CONF",
29650
+ }
29651
+ inputs = getattr(fc, "inputs", None)
29652
+ p_now = float(getattr(inputs, "p_now", 0.0) or 0.0) if inputs else 0.0
29653
+ remaining_hours = float(
29654
+ getattr(inputs, "remaining_hours", 0.0) or 0.0
29655
+ ) if inputs else 0.0
29656
+ confidence = getattr(inputs, "confidence", "ok") if inputs else "ok"
29657
+ r_avg = float(getattr(fc, "r_avg", 0.0) or 0.0)
29658
+ r_recent_raw = getattr(fc, "r_recent", None)
29659
+ r_recent = float(r_recent_raw) if r_recent_raw is not None else r_avg
29660
+ # End-of-week projected %
29661
+ projected_end_pct = (p_now + r_avg * remaining_hours) / 100.0
29662
+ # Days to ceilings (simple inverse: hours-to-target / 24)
29663
+ def _days_to_ceiling(target_pct: float) -> float:
29664
+ if r_avg <= 0 or p_now >= target_pct:
29665
+ return 0.0
29666
+ hours = (target_pct - p_now) / r_avg
29667
+ return max(0.0, hours / 24.0)
29668
+ days_to_100 = _days_to_ceiling(100.0)
29669
+ days_to_90 = _days_to_ceiling(90.0)
29670
+ # Daily budgets — pull from fc.budgets[] when present
29671
+ budgets: dict = {"avg": 0.0, "recent_24h": 0.0,
29672
+ "until_90pct": 0.0, "until_100pct": 0.0}
29673
+ for b in getattr(fc, "budgets", None) or []:
29674
+ tp = getattr(b, "target_percent", None)
29675
+ dpd = float(getattr(b, "dollars_per_day", 0.0) or 0.0)
29676
+ if tp == 100:
29677
+ budgets["until_100pct"] = dpd
29678
+ elif tp == 90:
29679
+ budgets["until_90pct"] = dpd
29680
+ # avg / recent_24h: derive from dollars-per-percent × r_avg/r_recent.
29681
+ dpp = float(getattr(inputs, "dollars_per_percent", 0.0) or 0.0) if inputs else 0.0
29682
+ budgets["avg"] = dpp * r_avg * 24.0
29683
+ budgets["recent_24h"] = dpp * r_recent * 24.0
29684
+ # Projection curve — 7-day forward, using r_avg
29685
+ today = _share_now_utc().date()
29686
+ projection_curve: list[dict] = []
29687
+ for i in range(7):
29688
+ d = today + dt.timedelta(days=i)
29689
+ pct = (p_now + r_avg * (i * 24.0)) / 100.0
29690
+ projection_curve.append({
29691
+ "date": d.isoformat(),
29692
+ "projected_pct_used": pct,
29693
+ })
29694
+ return {
29695
+ "projected_end_pct": projected_end_pct,
29696
+ "days_to_100pct": days_to_100,
29697
+ "days_to_90pct": days_to_90,
29698
+ "daily_budgets": budgets,
29699
+ "projection_curve": projection_curve,
29700
+ "confidence": confidence,
29701
+ }
29702
+
29703
+
29704
+ def _build_blocks_share_panel_data(options: dict,
29705
+ snap: "DataSnapshot | None") -> dict:
29706
+ """Blocks panel_data — current 5h block KPI + 8 recent blocks + top projects.
29707
+
29708
+ Reuses `DataSnapshot.blocks_panel` (BlocksPanelRow list). Current
29709
+ block is the row with `is_active=True`; recent_blocks are the last 8.
29710
+ """
29711
+ rows = list(getattr(snap, "blocks_panel", None) or []) if snap else []
29712
+ current = next((r for r in rows if getattr(r, "is_active", False)), None)
29713
+ cb: dict = {}
29714
+ if current is not None:
29715
+ cb = {
29716
+ "start_at": _share_iso(getattr(current, "start_at", None)) or "",
29717
+ "end_at": _share_iso(getattr(current, "end_at", None)) or "",
29718
+ "cost_usd": float(getattr(current, "cost_usd", 0.0) or 0.0),
29719
+ "pct_used": 0.0, # BlocksPanelRow doesn't carry a %
29720
+ "tokens_total": 0, # BlocksPanelRow drops token counts
29721
+ }
29722
+ # blocks_panel is newest-first (see _dashboard_build_blocks_panel:
29723
+ # `rows.sort(key=lambda r: r.start_at, reverse=True)`). Take the most
29724
+ # recent 8 blocks and reverse to oldest→newest so the template's chart
29725
+ # (uses enumerate(recent) for x-position) plots left→right time order.
29726
+ recent: list[dict] = []
29727
+ for r in list(reversed(rows[:8])):
29728
+ recent.append({
29729
+ "start_at": _share_iso(getattr(r, "start_at", None)) or "",
29730
+ "cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
29731
+ })
29732
+ # Blocks top_projects: aggregate across the window covered by
29733
+ # `recent_blocks` (the oldest block's start through the most recent
29734
+ # block's end — also the active block, if any). Mirrors what the
29735
+ # panel actually shows the user.
29736
+ top_projects: list[tuple[str, float]] = []
29737
+ if recent:
29738
+ try:
29739
+ range_start = parse_iso_datetime(
29740
+ recent[0]["start_at"], "blocks.recent_blocks[0].start_at",
29741
+ )
29742
+ # Pick the end of the latest block. `recent` is oldest→newest
29743
+ # after the slice/reverse, so `recent[-1]` is the most recent.
29744
+ # Each block is 5 hours long; if `current_block` has an
29745
+ # explicit `end_at`, prefer that since it may be the active
29746
+ # block whose end_at lives in the future.
29747
+ if cb.get("end_at"):
29748
+ range_end = parse_iso_datetime(
29749
+ cb["end_at"], "blocks.current_block.end_at",
29750
+ )
29751
+ else:
29752
+ range_end = parse_iso_datetime(
29753
+ recent[-1]["start_at"], "blocks.recent_blocks[-1].start_at",
29754
+ ) + dt.timedelta(hours=5)
29755
+ top_projects = _share_top_projects_for_range(range_start, range_end)
29756
+ except (ValueError, KeyError):
29757
+ top_projects = []
29758
+ return {
29759
+ "current_block": cb,
29760
+ "recent_blocks": recent,
29761
+ "top_projects": top_projects,
29762
+ }
29763
+
29764
+
29765
+ def _build_sessions_share_panel_data(options: dict,
29766
+ snap: "DataSnapshot | None") -> dict:
29767
+ """Sessions panel_data — top N sessions table.
29768
+
29769
+ Reuses `DataSnapshot.sessions` (TuiSessionRow list). Truncated to
29770
+ `options.top_n` (default 15) by upstream cap before the Recap builder
29771
+ runs its own slice.
29772
+ """
29773
+ rows = list(getattr(snap, "sessions", None) or []) if snap else []
29774
+ top_n = options.get("top_n", 15)
29775
+ try:
29776
+ top_n_int = max(1, int(top_n))
29777
+ except (TypeError, ValueError):
29778
+ top_n_int = 15
29779
+ sessions: list[dict] = []
29780
+ for r in rows[:top_n_int]:
29781
+ sessions.append({
29782
+ "session_id": getattr(r, "session_id", "") or "",
29783
+ "project_path": getattr(r, "project_label", "") or "",
29784
+ "cost_usd": float(getattr(r, "cost_usd", 0.0) or 0.0),
29785
+ "started_at": _share_iso(getattr(r, "started_at", None)) or "",
29786
+ "model": getattr(r, "model_primary", "") or "",
29787
+ })
29788
+ return {"sessions": sessions}
29789
+
29790
+
28919
29791
  def _share_render_and_emit(snap, args) -> None:
28920
29792
  """End-to-end: scrub -> render -> emit -> optional open.
28921
29793
 
@@ -32215,6 +33087,12 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
32215
33087
  self._handle_get_update_status()
32216
33088
  elif path.startswith("/api/update/stream/"):
32217
33089
  self._handle_get_update_stream(path)
33090
+ elif path == "/api/share/templates":
33091
+ self._handle_share_templates_get()
33092
+ elif path == "/api/share/presets":
33093
+ self._handle_share_presets_get()
33094
+ elif path == "/api/share/history":
33095
+ self._handle_share_history_get()
32218
33096
  else:
32219
33097
  self.send_error(404, "not found")
32220
33098
 
@@ -32230,6 +33108,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
32230
33108
  self._handle_post_update()
32231
33109
  elif path == "/api/update/dismiss":
32232
33110
  self._handle_post_update_dismiss()
33111
+ elif path == "/api/share/render":
33112
+ self._handle_share_render_post()
33113
+ elif path == "/api/share/compose":
33114
+ self._handle_share_compose_post()
33115
+ elif path == "/api/share/presets":
33116
+ self._handle_share_presets_post()
33117
+ elif path == "/api/share/history":
33118
+ self._handle_share_history_post()
32233
33119
  else:
32234
33120
  self.send_error(404, "not found")
32235
33121
 
@@ -32247,6 +33133,13 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
32247
33133
  def do_DELETE(self) -> None: # noqa: N802 — stdlib API
32248
33134
  if self._method_not_allowed_for_settings():
32249
33135
  return
33136
+ path = self.path.split("?", 1)[0]
33137
+ if path.startswith("/api/share/presets/"):
33138
+ self._handle_share_presets_delete()
33139
+ return
33140
+ if path == "/api/share/history":
33141
+ self._handle_share_history_delete()
33142
+ return
32250
33143
  self.send_error(501, "Unsupported method ('DELETE')")
32251
33144
 
32252
33145
  def do_PATCH(self) -> None: # noqa: N802 — stdlib API
@@ -32771,6 +33664,766 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
32771
33664
  status = _dispatch_alert_notification(payload, mode="test")
32772
33665
  self._respond_json(200, {"alert": payload, "dispatch": status})
32773
33666
 
33667
+ # ---- share endpoints (spec §5.1) ----------------------------------
33668
+ #
33669
+ # GET /api/share/templates?panel=<id> → list Recap/Visual/Detail
33670
+ # templates registered in _lib_share_templates for that panel.
33671
+ # POST /api/share/render → render one panel-section to
33672
+ # body via the kernel; returns {body, content_type, snapshot}
33673
+ # with kernel_version + data_digest for v2 composer drift checks.
33674
+ #
33675
+ # The template registry is late-imported per-request to keep dashboard
33676
+ # startup cheap — matches cmd_tui's `rich` lazy-import pattern. Same
33677
+ # late-load applies to the kernel (`_lib_share`) via `_share_load_lib`.
33678
+ # GET is unauthenticated (idempotent read). POST gates on
33679
+ # `_check_origin_csrf` (same convention as /api/sync, /api/settings).
33680
+
33681
+ def _share_load_templates_module(self):
33682
+ """Late-load the share-templates registry, cached in sys.modules.
33683
+
33684
+ Keeps dashboard startup zero-cost — the registry only imports when
33685
+ the first share request arrives. Subsequent requests reuse the
33686
+ sys.modules entry; matches the `_share_load_lib` convention so
33687
+ ShareTemplate identity stays stable across calls.
33688
+ """
33689
+ cached = sys.modules.get("_lib_share_templates")
33690
+ if cached is not None:
33691
+ return cached
33692
+ import importlib.util as _ilu
33693
+ p = pathlib.Path(__file__).resolve().parent / "_lib_share_templates.py"
33694
+ spec = _ilu.spec_from_file_location("_lib_share_templates", p)
33695
+ mod = _ilu.module_from_spec(spec)
33696
+ sys.modules["_lib_share_templates"] = mod
33697
+ try:
33698
+ spec.loader.exec_module(mod)
33699
+ except Exception:
33700
+ sys.modules.pop("_lib_share_templates", None)
33701
+ raise
33702
+ return mod
33703
+
33704
+ def _handle_share_templates_get(self) -> None:
33705
+ """List share templates registered for the requested panel.
33706
+
33707
+ Query: ?panel=<id>. Rejects missing or non-share-capable panels
33708
+ (e.g., `alerts`) with 400 + {error, field} envelope (matches
33709
+ existing dashboard error shape; see spec §5.5).
33710
+ """
33711
+ import urllib.parse as _urlparse
33712
+ qs = _urlparse.urlparse(self.path).query
33713
+ params = _urlparse.parse_qs(qs)
33714
+ panel = (params.get("panel", [""])[0] or "").strip()
33715
+ if not panel:
33716
+ self._respond_json(400, {
33717
+ "error": "missing query param: panel",
33718
+ "field": "panel",
33719
+ })
33720
+ return
33721
+ tpl_mod = self._share_load_templates_module()
33722
+ if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
33723
+ self._respond_json(400, {
33724
+ "error": f"unknown share panel: {panel!r}",
33725
+ "field": "panel",
33726
+ })
33727
+ return
33728
+ templates = [
33729
+ {
33730
+ "id": t.id,
33731
+ "label": t.label,
33732
+ "description": t.description,
33733
+ "default_options": dict(t.default_options),
33734
+ }
33735
+ for t in tpl_mod.templates_for_panel(panel)
33736
+ ]
33737
+ self._respond_json(200, {"panel": panel, "templates": templates})
33738
+
33739
+ def _handle_share_render_post(self) -> None:
33740
+ """Render a panel-section to body via the share kernel.
33741
+
33742
+ Body shape: ``{panel, template_id, options}``. Validates panel +
33743
+ template_id against the registry, dispatches to the per-panel
33744
+ `_build_<panel>_share_panel_data` helper to assemble the
33745
+ builder-shaped dict from the current dashboard snapshot, runs the
33746
+ template's builder, applies `_scrub` when
33747
+ ``options.reveal_projects`` is False, then renders via
33748
+ `_lib_share.render`. Response: ``{body, content_type, snapshot}``
33749
+ where `snapshot` carries `kernel_version` + `data_digest` for the
33750
+ v2 composer's drift detection (spec §5.2).
33751
+
33752
+ CSRF: Origin/Host parity via `_check_origin_csrf` — same gate as
33753
+ `/api/sync`, `/api/settings`, `/api/alerts/test`.
33754
+ """
33755
+ if not self._check_origin_csrf():
33756
+ return
33757
+ try:
33758
+ length = int(self.headers.get("Content-Length", "0") or "0")
33759
+ except ValueError:
33760
+ length = 0
33761
+ try:
33762
+ raw = self.rfile.read(length) if length > 0 else b""
33763
+ req = json.loads(raw) if raw else {}
33764
+ except (ValueError, json.JSONDecodeError):
33765
+ self._respond_json(400, {"error": "malformed json"})
33766
+ return
33767
+ if not isinstance(req, dict):
33768
+ self._respond_json(400, {"error": "expected JSON object"})
33769
+ return
33770
+ panel = req.get("panel")
33771
+ template_id = req.get("template_id")
33772
+ options = req.get("options") or {}
33773
+ if not isinstance(options, dict):
33774
+ self._respond_json(400, {
33775
+ "error": "options must be an object",
33776
+ "field": "options",
33777
+ })
33778
+ return
33779
+ if not isinstance(panel, str) or not panel:
33780
+ self._respond_json(400, {
33781
+ "error": "missing or non-string panel",
33782
+ "field": "panel",
33783
+ })
33784
+ return
33785
+ if not isinstance(template_id, str) or not template_id:
33786
+ self._respond_json(400, {
33787
+ "error": "missing or non-string template_id",
33788
+ "field": "template_id",
33789
+ })
33790
+ return
33791
+ fmt = options.get("format", "html")
33792
+ if fmt not in ("md", "html", "svg"):
33793
+ self._respond_json(400, {
33794
+ "error": f"unknown format: {fmt!r}",
33795
+ "field": "options.format",
33796
+ })
33797
+ return
33798
+ theme = options.get("theme", "light")
33799
+ if theme not in ("light", "dark"):
33800
+ self._respond_json(400, {
33801
+ "error": f"unknown theme: {theme!r}",
33802
+ "field": "options.theme",
33803
+ })
33804
+ return
33805
+ # `top_n` may be explicit-null when the UI's Top-N input is
33806
+ # cleared (Knobs.tsx:43); treat null as "use template default"
33807
+ # rather than 400-ing every preview/export until the user types
33808
+ # a number.
33809
+ if options.get("top_n") is not None:
33810
+ top_n_raw = options["top_n"]
33811
+ if not isinstance(top_n_raw, int) or isinstance(top_n_raw, bool) or top_n_raw < 1:
33812
+ self._respond_json(400, {
33813
+ "error": f"top_n must be a positive integer, got {top_n_raw!r}",
33814
+ "field": "options.top_n",
33815
+ })
33816
+ return
33817
+
33818
+ tpl_mod = self._share_load_templates_module()
33819
+ if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
33820
+ self._respond_json(400, {
33821
+ "error": f"unknown share panel: {panel!r}",
33822
+ "field": "panel",
33823
+ })
33824
+ return
33825
+ try:
33826
+ template = tpl_mod.get_template(template_id)
33827
+ except KeyError:
33828
+ self._respond_json(400, {
33829
+ "error": f"unknown template_id: {template_id!r}",
33830
+ "field": "template_id",
33831
+ })
33832
+ return
33833
+ if template.panel != panel:
33834
+ self._respond_json(400, {
33835
+ "error": (
33836
+ f"template_id {template_id!r} belongs to panel "
33837
+ f"{template.panel!r}, not {panel!r}"
33838
+ ),
33839
+ "field": "template_id",
33840
+ })
33841
+ return
33842
+
33843
+ # Build panel_data from the live dashboard snapshot — reuses the
33844
+ # already-built `DataSnapshot` so we don't re-query the DB on the
33845
+ # share hot path. `_build_share_panel_data` dispatches per panel.
33846
+ snap_ref = type(self).snapshot_ref
33847
+ data_snap = snap_ref.get() if snap_ref is not None else None
33848
+ # Period override (current / previous / custom). For
33849
+ # `kind='current'` (the default) this is a no-op; otherwise we
33850
+ # re-build the relevant panel's DataSnapshot field from DB with
33851
+ # a shifted `now_utc` before slicing.
33852
+ data_snap, period_err = _share_apply_period_override(panel, options,
33853
+ data_snap)
33854
+ if period_err is not None:
33855
+ self._respond_json(400, period_err)
33856
+ return
33857
+ try:
33858
+ panel_data = _build_share_panel_data(panel, options, data_snap)
33859
+ except Exception as exc:
33860
+ self._respond_json(500, {"error": f"panel_data build failed: {exc}"})
33861
+ return
33862
+
33863
+ # Run template builder → kernel render. Builder produces a
33864
+ # ShareSnapshot; `_scrub` anonymizes project labels when the
33865
+ # client opted in to anon-on-export (`reveal_projects=False`).
33866
+ ls = _share_load_lib()
33867
+ try:
33868
+ snap_built = template.builder(panel_data=panel_data, options=options)
33869
+ except Exception as exc:
33870
+ self._respond_json(500, {"error": f"builder failed: {exc}"})
33871
+ return
33872
+ snap_built = replace(snap_built, template_id=template_id)
33873
+ # Content toggles (spec §Q4). Defaults match the existing
33874
+ # behavior (chart on, table on); explicit False strips the
33875
+ # corresponding section from the ShareSnapshot. ShareSnapshot
33876
+ # is frozen so we use dataclasses.replace.
33877
+ snap_built = _share_apply_content_toggles(snap_built, options)
33878
+ reveal = bool(options.get("reveal_projects", True))
33879
+ if not reveal:
33880
+ snap_built = ls._scrub(snap_built, reveal_projects=False)
33881
+ try:
33882
+ body = ls.render(
33883
+ snap_built,
33884
+ format=fmt,
33885
+ theme=options.get("theme", "light"),
33886
+ branding=not options.get("no_branding", False),
33887
+ )
33888
+ except Exception as exc:
33889
+ self._respond_json(500, {"error": f"render failed: {exc}"})
33890
+ return
33891
+ content_type = {
33892
+ "md": "text/markdown",
33893
+ "html": "text/html",
33894
+ "svg": "image/svg+xml",
33895
+ }[fmt]
33896
+
33897
+ # data_digest hashes the inputs that identify the underlying DATA
33898
+ # (panel + template + panel_data), NOT rendering toggles like theme
33899
+ # / branding / reveal_projects / format. Used by the composer to
33900
+ # detect "section data has drifted since add-time" (spec §5.2 /
33901
+ # §7.1) — flipping anon-on-export must not register as drift, since
33902
+ # the underlying data is identical.
33903
+ digest_input = {
33904
+ "panel": panel,
33905
+ "template_id": template_id,
33906
+ "panel_data": panel_data,
33907
+ }
33908
+ try:
33909
+ data_digest = ls._data_digest(digest_input)
33910
+ except Exception:
33911
+ # Defensive: digest is non-blocking for the response — fall
33912
+ # back to an empty string and let the composer treat it as
33913
+ # "always drifted" rather than failing the whole render.
33914
+ data_digest = ""
33915
+
33916
+ self._respond_json(200, {
33917
+ "body": body,
33918
+ "content_type": content_type,
33919
+ "snapshot": {
33920
+ "kernel_version": ls.KERNEL_VERSION,
33921
+ "panel": panel,
33922
+ "template_id": template_id,
33923
+ "options": options,
33924
+ "generated_at": _share_now_utc_iso(),
33925
+ "data_digest": data_digest,
33926
+ },
33927
+ })
33928
+
33929
+ # ---- /api/share/compose — stitch many basket sections (spec §5.3) ----
33930
+
33931
+ def _handle_share_compose_post(self) -> None:
33932
+ """Stitch multiple panel sections into one composed document.
33933
+
33934
+ Recipe-only. The server re-renders every section from its
33935
+ ``(panel, template_id, options)`` recipe — never accepting a client-
33936
+ supplied ``body``. Per-section drift detection compares the fresh
33937
+ ``data_digest`` against the client's ``data_digest_at_add``;
33938
+ mismatches surface as ``section_results[i].drift_detected = true``
33939
+ for the composer's "Outdated" badge.
33940
+
33941
+ Spec §5.3, §10.3. CSRF-gated.
33942
+ """
33943
+ if not self._check_origin_csrf():
33944
+ return
33945
+ try:
33946
+ length = int(self.headers.get("Content-Length", "0") or "0")
33947
+ except ValueError:
33948
+ length = 0
33949
+ try:
33950
+ raw = self.rfile.read(length) if length > 0 else b""
33951
+ req = json.loads(raw) if raw else {}
33952
+ except (ValueError, json.JSONDecodeError):
33953
+ self._respond_json(400, {"error": "malformed json"})
33954
+ return
33955
+ if not isinstance(req, dict):
33956
+ self._respond_json(400, {"error": "expected JSON object"})
33957
+ return
33958
+
33959
+ title = req.get("title")
33960
+ theme = req.get("theme", "light")
33961
+ fmt = req.get("format", "html")
33962
+ no_branding = bool(req.get("no_branding", False))
33963
+ reveal_projects = bool(req.get("reveal_projects", False))
33964
+ sections_in = req.get("sections")
33965
+ if not isinstance(title, str) or not title:
33966
+ self._respond_json(400, {"error": "missing title", "field": "title"})
33967
+ return
33968
+ if theme not in ("light", "dark"):
33969
+ self._respond_json(400, {"error": f"unknown theme: {theme!r}",
33970
+ "field": "theme"})
33971
+ return
33972
+ if fmt not in ("md", "html", "svg"):
33973
+ self._respond_json(400, {"error": f"unknown format: {fmt!r}",
33974
+ "field": "format"})
33975
+ return
33976
+ if not isinstance(sections_in, list) or not sections_in:
33977
+ self._respond_json(400, {
33978
+ "error": "sections must be a non-empty array",
33979
+ "field": "sections",
33980
+ })
33981
+ return
33982
+
33983
+ tpl_mod = self._share_load_templates_module()
33984
+ ls = _share_load_lib()
33985
+ snap_ref = type(self).snapshot_ref
33986
+ data_snap = snap_ref.get() if snap_ref is not None else None
33987
+
33988
+ composed_sections: list = []
33989
+ section_results: list[dict] = []
33990
+
33991
+ for idx, sec in enumerate(sections_in):
33992
+ if not isinstance(sec, dict):
33993
+ self._respond_json(400, {
33994
+ "error": f"sections[{idx}] must be an object",
33995
+ "field": f"sections[{idx}]",
33996
+ })
33997
+ return
33998
+ # Explicit: client-supplied `body` and `content_type` are
33999
+ # silently IGNORED. This is the privacy chokepoint — the
34000
+ # regression test in tests/test_api_share.py guards it.
34001
+ snap_recipe = sec.get("snapshot") or {}
34002
+ panel = snap_recipe.get("panel")
34003
+ template_id = snap_recipe.get("template_id")
34004
+ sec_opts = snap_recipe.get("options") or {}
34005
+ digest_at_add = snap_recipe.get("data_digest_at_add") or ""
34006
+ if (not isinstance(panel, str)
34007
+ or panel not in tpl_mod.SHARE_CAPABLE_PANELS):
34008
+ self._respond_json(400, {
34009
+ "error": (
34010
+ f"sections[{idx}].snapshot.panel invalid: {panel!r}"
34011
+ ),
34012
+ "field": f"sections[{idx}].snapshot.panel",
34013
+ })
34014
+ return
34015
+ try:
34016
+ template = tpl_mod.get_template(template_id)
34017
+ except KeyError:
34018
+ self._respond_json(400, {
34019
+ "error": (
34020
+ f"sections[{idx}].snapshot.template_id "
34021
+ f"unknown: {template_id!r}"
34022
+ ),
34023
+ "field": f"sections[{idx}].snapshot.template_id",
34024
+ })
34025
+ return
34026
+ if template.panel != panel:
34027
+ self._respond_json(400, {
34028
+ "error": (f"sections[{idx}].snapshot.template_id "
34029
+ f"{template_id!r} belongs to panel "
34030
+ f"{template.panel!r}, not {panel!r}"),
34031
+ "field": f"sections[{idx}].snapshot.template_id",
34032
+ })
34033
+ return
34034
+
34035
+ # Force the composite reveal_projects across every section
34036
+ # (spec §8.5: per-section anon at add-time is ignored at compose).
34037
+ composite_opts = {**sec_opts, "reveal_projects": reveal_projects,
34038
+ "theme": theme, "format": fmt,
34039
+ "no_branding": no_branding}
34040
+ # Per-section period override — each basket item carries its
34041
+ # own period recipe, independent of the composite anon flag.
34042
+ sec_snap, period_err = _share_apply_period_override(
34043
+ panel, composite_opts, data_snap,
34044
+ )
34045
+ if period_err is not None:
34046
+ self._respond_json(400, {
34047
+ "error": f"sections[{idx}]: {period_err['error']}",
34048
+ "field": f"sections[{idx}].snapshot.{period_err['field']}",
34049
+ })
34050
+ return
34051
+ try:
34052
+ panel_data = _build_share_panel_data(panel, composite_opts,
34053
+ sec_snap)
34054
+ except Exception as exc:
34055
+ self._respond_json(500, {
34056
+ "error": f"sections[{idx}] panel_data build failed: {exc}",
34057
+ })
34058
+ return
34059
+ try:
34060
+ snap_built = template.builder(panel_data=panel_data,
34061
+ options=composite_opts)
34062
+ except Exception as exc:
34063
+ self._respond_json(500, {
34064
+ "error": f"sections[{idx}] builder failed: {exc}",
34065
+ })
34066
+ return
34067
+ snap_built = replace(snap_built, template_id=template_id)
34068
+ # Same content toggles as the single-section render path.
34069
+ # Per-section `show_chart`/`show_table` from the basket
34070
+ # recipe are applied here; the composite anon flag is
34071
+ # already merged into composite_opts upstream.
34072
+ snap_built = _share_apply_content_toggles(snap_built, composite_opts)
34073
+ if not reveal_projects:
34074
+ snap_built = ls._scrub(snap_built, reveal_projects=False)
34075
+
34076
+ # Defensive: digest is non-blocking metadata — fall back to
34077
+ # "" on failure rather than 500-ing the whole compose
34078
+ # (mirrors the render handler at bin/cctally:33402-33408).
34079
+ try:
34080
+ digest_now = ls._data_digest({
34081
+ "panel": panel,
34082
+ "template_id": template_id,
34083
+ "panel_data": panel_data,
34084
+ })
34085
+ except Exception:
34086
+ digest_now = ""
34087
+ composed_sections.append(ls.ComposedSection(
34088
+ snap=snap_built,
34089
+ drift_detected=(digest_now != digest_at_add),
34090
+ ))
34091
+ section_results.append({
34092
+ "snapshot_id": f"{idx:02d}",
34093
+ "drift_detected": digest_now != digest_at_add,
34094
+ "data_digest_at_add": digest_at_add,
34095
+ "data_digest_now": digest_now,
34096
+ })
34097
+
34098
+ compose_opts = ls.ComposeOptions(
34099
+ title=title, theme=theme, format=fmt,
34100
+ no_branding=no_branding, reveal_projects=reveal_projects,
34101
+ )
34102
+ try:
34103
+ body = ls.compose(tuple(composed_sections), opts=compose_opts)
34104
+ except Exception as exc:
34105
+ self._respond_json(500, {"error": f"compose failed: {exc}"})
34106
+ return
34107
+
34108
+ content_type = {
34109
+ "md": "text/markdown",
34110
+ "html": "text/html",
34111
+ "svg": "image/svg+xml",
34112
+ }[fmt]
34113
+ self._respond_json(200, {
34114
+ "body": body,
34115
+ "content_type": content_type,
34116
+ "snapshot": {
34117
+ "kernel_version": ls.KERNEL_VERSION,
34118
+ "composed_at": _share_now_utc_iso(),
34119
+ "section_results": section_results,
34120
+ },
34121
+ })
34122
+
34123
+ # ---- /api/share/presets — saved-recipe CRUD (spec §5.1, §11.3) ----
34124
+ #
34125
+ # GET /api/share/presets → list, grouped by panel
34126
+ # POST /api/share/presets → upsert (panel, name)
34127
+ # DELETE /api/share/presets/{panel}/{name} → remove one preset
34128
+ #
34129
+ # Persistence: `config.json` under `share.presets[<panel>][<name>]` so
34130
+ # the CLI can read them later (CLI consumer is designed for, not
34131
+ # shipped — out of scope per spec §15). GET is unauthenticated like
34132
+ # `/api/share/templates`; POST + DELETE go through `_check_origin_csrf`
34133
+ # (same gate as `/api/sync`, `/api/settings`, `/api/alerts/test`).
34134
+ # Write discipline: `config_writer_lock` + `_load_config_unlocked` +
34135
+ # `save_config` (atomic `os.replace`). Never call `load_config` from
34136
+ # inside the writer lock — `fcntl.flock` is per-fd and would
34137
+ # self-deadlock; see `_cmd_config_set` for the established pattern.
34138
+
34139
+ def _handle_share_presets_get(self) -> None:
34140
+ """List saved share presets, grouped by panel (spec §5.1, §11.3).
34141
+
34142
+ Read-only — no CSRF gate. `config.json` may not contain the
34143
+ `share.presets` key on first run; returns `{"presets": {}}` then.
34144
+ """
34145
+ cfg = load_config()
34146
+ presets = (cfg.get("share") or {}).get("presets") or {}
34147
+ self._respond_json(200, {"presets": presets})
34148
+
34149
+ def _handle_share_presets_post(self) -> None:
34150
+ """Create or overwrite a preset (idempotent on `(panel, name)`).
34151
+
34152
+ Body: ``{panel, name, template_id, options}``. CSRF-gated.
34153
+
34154
+ Persistence is a read-modify-write under ``config_writer_lock`` +
34155
+ ``_load_config_unlocked``. The plain ``load_config`` would
34156
+ self-deadlock on the same fcntl.flock fd; see the CLAUDE.md
34157
+ config-write invariant and `_cmd_config_set` for the canonical
34158
+ pattern.
34159
+ """
34160
+ if not self._check_origin_csrf():
34161
+ return
34162
+ try:
34163
+ length = int(self.headers.get("Content-Length", "0") or "0")
34164
+ except ValueError:
34165
+ length = 0
34166
+ try:
34167
+ raw = self.rfile.read(length) if length > 0 else b""
34168
+ req = json.loads(raw) if raw else {}
34169
+ except (ValueError, json.JSONDecodeError):
34170
+ self._respond_json(400, {"error": "malformed json"})
34171
+ return
34172
+ if not isinstance(req, dict):
34173
+ self._respond_json(400, {"error": "expected JSON object"})
34174
+ return
34175
+ panel = req.get("panel")
34176
+ name = req.get("name")
34177
+ template_id = req.get("template_id")
34178
+ options = req.get("options")
34179
+ if not isinstance(panel, str) or not panel:
34180
+ self._respond_json(400, {
34181
+ "error": "missing or non-string panel",
34182
+ "field": "panel",
34183
+ })
34184
+ return
34185
+ tpl_mod = self._share_load_templates_module()
34186
+ if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
34187
+ self._respond_json(400, {
34188
+ "error": f"unknown share panel: {panel!r}",
34189
+ "field": "panel",
34190
+ })
34191
+ return
34192
+ if not isinstance(name, str) or not name or "/" in name or len(name) > 64:
34193
+ self._respond_json(400, {
34194
+ "error": "name must be 1-64 chars and contain no '/'",
34195
+ "field": "name",
34196
+ })
34197
+ return
34198
+ if not isinstance(template_id, str) or not template_id:
34199
+ self._respond_json(400, {
34200
+ "error": "missing or non-string template_id",
34201
+ "field": "template_id",
34202
+ })
34203
+ return
34204
+ try:
34205
+ template = tpl_mod.get_template(template_id)
34206
+ except KeyError:
34207
+ self._respond_json(400, {
34208
+ "error": f"unknown template_id: {template_id!r}",
34209
+ "field": "template_id",
34210
+ })
34211
+ return
34212
+ if template.panel != panel:
34213
+ self._respond_json(400, {
34214
+ "error": (
34215
+ f"template_id {template_id!r} belongs to panel "
34216
+ f"{template.panel!r}, not {panel!r}"
34217
+ ),
34218
+ "field": "template_id",
34219
+ })
34220
+ return
34221
+ if not isinstance(options, dict):
34222
+ self._respond_json(400, {
34223
+ "error": "options must be an object",
34224
+ "field": "options",
34225
+ })
34226
+ return
34227
+
34228
+ saved_at = _share_now_utc_iso()
34229
+ record = {"template_id": template_id, "options": options, "saved_at": saved_at}
34230
+
34231
+ with config_writer_lock():
34232
+ cfg = _load_config_unlocked()
34233
+ share = cfg.setdefault("share", {})
34234
+ presets = share.setdefault("presets", {})
34235
+ panel_bucket = presets.setdefault(panel, {})
34236
+ panel_bucket[name] = record
34237
+ save_config(cfg)
34238
+ self._respond_json(200, {"panel": panel, "name": name, **record})
34239
+
34240
+ def _handle_share_presets_delete(self) -> None:
34241
+ """Remove a preset by `(panel, name)`.
34242
+
34243
+ Path: ``/api/share/presets/{panel}/{name}``. Missing → 404 so
34244
+ DELETE stays meaningful for idempotency-aware clients. CSRF-gated.
34245
+ """
34246
+ if not self._check_origin_csrf():
34247
+ return
34248
+ import urllib.parse as _urlparse
34249
+ # Strip the query string defensively; the spec only uses path
34250
+ # segments but a stray "?" shouldn't poison the name token.
34251
+ path_only = self.path.split("?", 1)[0]
34252
+ parts = path_only.split("/")
34253
+ # Expected: ["", "api", "share", "presets", "<panel>", "<name>"]
34254
+ if (
34255
+ len(parts) != 6
34256
+ or parts[1] != "api"
34257
+ or parts[2] != "share"
34258
+ or parts[3] != "presets"
34259
+ or not parts[4]
34260
+ or not parts[5]
34261
+ ):
34262
+ self._respond_json(400, {"error": "malformed delete path"})
34263
+ return
34264
+ panel = _urlparse.unquote(parts[4])
34265
+ name = _urlparse.unquote(parts[5])
34266
+ with config_writer_lock():
34267
+ cfg = _load_config_unlocked()
34268
+ share = cfg.get("share") or {}
34269
+ presets = share.get("presets") or {}
34270
+ panel_bucket = presets.get(panel) or {}
34271
+ if name not in panel_bucket:
34272
+ self._respond_json(404, {"error": "no such preset"})
34273
+ return
34274
+ del panel_bucket[name]
34275
+ # Tidy empty buckets so GET stays clean.
34276
+ if not panel_bucket:
34277
+ presets.pop(panel, None)
34278
+ save_config(cfg)
34279
+ self.send_response(204)
34280
+ self.send_header("Content-Length", "0")
34281
+ self.end_headers()
34282
+
34283
+ # ---- /api/share/history — export-recipe ring buffer (spec §5.1, §11.4) ----
34284
+ #
34285
+ # GET /api/share/history → list (newest last) of last 20 export recipes
34286
+ # POST /api/share/history → append; server-side FIFO trim to 20
34287
+ # DELETE /api/share/history → clear the entire buffer
34288
+ #
34289
+ # Persisted under `share.history` in `config.json`. Write discipline
34290
+ # matches the presets handlers above: `config_writer_lock` +
34291
+ # `_load_config_unlocked` + `save_config`. GET is unauthenticated
34292
+ # like `/api/share/templates`; POST + DELETE go through
34293
+ # `_check_origin_csrf`. The frontend posts fire-and-forget after
34294
+ # every successful export — history failures are non-fatal.
34295
+
34296
+ def _handle_share_history_get(self) -> None:
34297
+ """Return the recent-shares ring buffer (newest last, spec §11.4)."""
34298
+ cfg = load_config()
34299
+ history = (cfg.get("share") or {}).get("history") or []
34300
+ self._respond_json(200, {"history": history})
34301
+
34302
+ def _handle_share_history_post(self) -> None:
34303
+ """Append a recipe to the ring buffer; FIFO trim to 20.
34304
+
34305
+ Body: ``{panel, template_id, options, format, destination}``. The
34306
+ server stamps ``recipe_id`` (random hex) and ``exported_at``
34307
+ (UTC ISO-8601) so the client doesn't need a clock or a UUID lib.
34308
+ CSRF-gated. Read-modify-write under ``config_writer_lock`` —
34309
+ same pattern as the presets POST.
34310
+ """
34311
+ if not self._check_origin_csrf():
34312
+ return
34313
+ try:
34314
+ length = int(self.headers.get("Content-Length", "0") or "0")
34315
+ except ValueError:
34316
+ length = 0
34317
+ try:
34318
+ raw = self.rfile.read(length) if length > 0 else b""
34319
+ req = json.loads(raw) if raw else {}
34320
+ except (ValueError, json.JSONDecodeError):
34321
+ self._respond_json(400, {"error": "malformed json"})
34322
+ return
34323
+ if not isinstance(req, dict):
34324
+ self._respond_json(400, {"error": "expected JSON object"})
34325
+ return
34326
+ panel = req.get("panel")
34327
+ template_id = req.get("template_id")
34328
+ options = req.get("options") or {}
34329
+ fmt = req.get("format")
34330
+ destination = req.get("destination")
34331
+ if not isinstance(panel, str) or not panel:
34332
+ self._respond_json(400, {
34333
+ "error": "missing or non-string panel",
34334
+ "field": "panel",
34335
+ })
34336
+ return
34337
+ tpl_mod = self._share_load_templates_module()
34338
+ if panel not in tpl_mod.SHARE_CAPABLE_PANELS:
34339
+ self._respond_json(400, {
34340
+ "error": f"unknown share panel: {panel!r}",
34341
+ "field": "panel",
34342
+ })
34343
+ return
34344
+ if not isinstance(template_id, str) or not template_id:
34345
+ self._respond_json(400, {
34346
+ "error": "missing or non-string template_id",
34347
+ "field": "template_id",
34348
+ })
34349
+ return
34350
+ try:
34351
+ template = tpl_mod.get_template(template_id)
34352
+ except KeyError:
34353
+ self._respond_json(400, {
34354
+ "error": f"unknown template_id: {template_id!r}",
34355
+ "field": "template_id",
34356
+ })
34357
+ return
34358
+ if template.panel != panel:
34359
+ self._respond_json(400, {
34360
+ "error": (
34361
+ f"template_id {template_id!r} belongs to panel "
34362
+ f"{template.panel!r}, not {panel!r}"
34363
+ ),
34364
+ "field": "template_id",
34365
+ })
34366
+ return
34367
+ if not isinstance(options, dict):
34368
+ self._respond_json(400, {
34369
+ "error": "options must be an object",
34370
+ "field": "options",
34371
+ })
34372
+ return
34373
+ # `format` and `destination` are advisory strings — accept any
34374
+ # non-empty string; the frontend uses them only as display hints
34375
+ # in the dropdown row. None/missing is allowed (mirrors how the
34376
+ # CLI doesn't always know which destination produced the export).
34377
+ if fmt is not None and not isinstance(fmt, str):
34378
+ self._respond_json(400, {
34379
+ "error": "format must be a string if provided",
34380
+ "field": "format",
34381
+ })
34382
+ return
34383
+ if destination is not None and not isinstance(destination, str):
34384
+ self._respond_json(400, {
34385
+ "error": "destination must be a string if provided",
34386
+ "field": "destination",
34387
+ })
34388
+ return
34389
+
34390
+ record = {
34391
+ "recipe_id": _share_history_recipe_id(),
34392
+ "panel": panel,
34393
+ "template_id": template_id,
34394
+ "options": options,
34395
+ "format": fmt,
34396
+ "destination": destination,
34397
+ "exported_at": _share_now_utc_iso(),
34398
+ }
34399
+ with config_writer_lock():
34400
+ cfg = _load_config_unlocked()
34401
+ share = cfg.setdefault("share", {})
34402
+ history = share.setdefault("history", [])
34403
+ history.append(record)
34404
+ # Ring buffer: trim from the front so the newest is always
34405
+ # last. `del history[:n]` keeps the same list instance, so
34406
+ # callers holding a reference (none in this scope, but a
34407
+ # safe invariant) see the same object mutated in place.
34408
+ if len(history) > _SHARE_HISTORY_RING_CAP:
34409
+ del history[: len(history) - _SHARE_HISTORY_RING_CAP]
34410
+ save_config(cfg)
34411
+ self._respond_json(200, record)
34412
+
34413
+ def _handle_share_history_delete(self) -> None:
34414
+ """Empty the share-history ring buffer (spec §11.4)."""
34415
+ if not self._check_origin_csrf():
34416
+ return
34417
+ with config_writer_lock():
34418
+ cfg = _load_config_unlocked()
34419
+ share = cfg.get("share")
34420
+ if isinstance(share, dict) and "history" in share:
34421
+ share["history"] = []
34422
+ save_config(cfg)
34423
+ self.send_response(204)
34424
+ self.send_header("Content-Length", "0")
34425
+ self.end_headers()
34426
+
32774
34427
  # ---- helpers ----
32775
34428
 
32776
34429
  def _respond_json(self, status: int, body: dict) -> None: