cctally 1.22.0 → 1.22.2

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.
@@ -0,0 +1,1707 @@
1
+ """cctally share-output construction (eager sibling).
2
+
3
+ Holds the share destination/emit path (_resolve_destination, _emit,
4
+ _share_open_file), the _build_*_snapshot builders, and the _share_* helpers.
5
+ Loaded eagerly by bin/cctally; every symbol is re-exported into the cctally
6
+ namespace (the dashboard thunks + staying budget-snapshot builders bare-call
7
+ these). Heavy share kernels are lazy-loaded inside _share_load_lib().
8
+
9
+ Spec: docs/superpowers/specs/2026-05-30-parser-share-extraction-design.md
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import datetime as dt
14
+ import os
15
+ import pathlib
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+
20
+ import _lib_changelog # module-qualified: _lib_changelog._read_latest_changelog_version()
21
+ from _lib_display_tz import format_display_dt
22
+ from _lib_render import _project_disambiguate_labels
23
+
24
+
25
+ # ============================================================
26
+ # ==== Shareable reports: destination + emit ==== =
27
+ # ============================================================
28
+ # Translate parsed argparse args + a rendered string into actual delivery
29
+ # (stdout / file / clipboard / open). These helpers live here, NOT in
30
+ # `_lib_share.py`, so the kernel module stays I/O-pure (Section 5.8 of the
31
+ # shareable-reports spec).
32
+
33
+
34
+ # Module-level latch for the home-dir fallback hint. Spec Section 4.2 calls
35
+ # for a one-shot stderr suggestion when share output lands in $HOME because
36
+ # both XDG_DOWNLOAD_DIR and ~/Downloads were absent. Latched here (process
37
+ # scope) so a user running, e.g., a `cctally daily --format html` followed
38
+ # by `cctally weekly --format html` in the same shell sees the hint exactly
39
+ # once. Tests reset by reaching into the module globals if needed.
40
+ _DOWNLOADS_HOME_HINT_EMITTED = False
41
+
42
+
43
+ def _share_resolve_download_dir() -> pathlib.Path:
44
+ """XDG -> ~/Downloads -> ~ fallback (Section 4.2)."""
45
+ global _DOWNLOADS_HOME_HINT_EMITTED
46
+ xdg = os.environ.get("XDG_DOWNLOAD_DIR")
47
+ if xdg:
48
+ p = pathlib.Path(xdg).expanduser()
49
+ if p.exists():
50
+ return p
51
+ downloads = pathlib.Path.home() / "Downloads"
52
+ if downloads.exists():
53
+ return downloads
54
+ if not _DOWNLOADS_HOME_HINT_EMITTED:
55
+ sys.stderr.write(
56
+ "cctally: writing share output to home dir; "
57
+ "pass --output <path> to choose a destination\n"
58
+ )
59
+ _DOWNLOADS_HOME_HINT_EMITTED = True
60
+ return pathlib.Path.home()
61
+
62
+
63
+ def _share_unique_path(base: pathlib.Path) -> pathlib.Path:
64
+ """Auto-collision counter — base.html -> base-2.html -> base-3.html -> ... cap 99.
65
+
66
+ Exhaustion (>99 same-day collisions) exits 3 per spec Section 4.4. Prior
67
+ code raised ``SystemExit("…")`` which yields exit 1 — broke the spec's
68
+ distinct-exit-code contract for collision exhaustion vs. generic errors.
69
+ """
70
+ if not base.exists():
71
+ return base
72
+ stem = base.stem
73
+ suffix = base.suffix
74
+ parent = base.parent
75
+ for n in range(2, 100):
76
+ candidate = parent / f"{stem}-{n}{suffix}"
77
+ if not candidate.exists():
78
+ return candidate
79
+ print(
80
+ f"cctally: too many same-day collisions in {parent}; use --output <path>",
81
+ file=sys.stderr,
82
+ )
83
+ sys.exit(3)
84
+
85
+
86
+ def _resolve_destination(
87
+ args, *, cmd: str, generated_at_utc_date: str
88
+ ) -> tuple[str, pathlib.Path | None]:
89
+ """Translate argparse args into (kind, value).
90
+
91
+ kind: "stdout" | "file" | "clipboard"
92
+ value: pathlib.Path for "file"; None for "stdout" / "clipboard".
93
+
94
+ Exit-code contract (spec Section 4.4):
95
+ - exit 2 on invalid flag combinations (--copy on non-md;
96
+ --copy + --output; --copy with no clipboard tool; --open + md).
97
+ - exit 3 on collision exhaustion (delegated to _share_unique_path).
98
+ """
99
+ fmt = args.format
100
+ if getattr(args, "copy", False) and getattr(args, "output", None) is not None:
101
+ # Mutex: a clipboard destination by definition has no path. Spec
102
+ # Section 4.4 line 132 calls this out explicitly. Prior code silently
103
+ # let --copy override --output, which surprised users who expected
104
+ # the file to land alongside the clipboard write.
105
+ print(
106
+ "cctally: --copy is mutually exclusive with --output",
107
+ file=sys.stderr,
108
+ )
109
+ sys.exit(2)
110
+ if getattr(args, "copy", False):
111
+ if fmt != "md":
112
+ print("cctally: --copy is only valid with --format md", file=sys.stderr)
113
+ sys.exit(2)
114
+ return ("clipboard", None)
115
+
116
+ output = getattr(args, "output", None)
117
+ if output == "-":
118
+ return ("stdout", None)
119
+ if output:
120
+ return ("file", pathlib.Path(output).expanduser())
121
+
122
+ if fmt == "md":
123
+ return ("stdout", None)
124
+ # html/svg default -> ~/Downloads/cctally-<cmd>-<utcdate>.<ext>
125
+ base = _share_resolve_download_dir() / f"cctally-{cmd}-{generated_at_utc_date}.{fmt}"
126
+ return ("file", _share_unique_path(base))
127
+
128
+
129
+ def _emit(content: str, *, kind: str, value: pathlib.Path | str | None) -> None:
130
+ """Deliver rendered content to stdout/file/clipboard."""
131
+ if kind == "stdout":
132
+ sys.stdout.write(content)
133
+ if not content.endswith("\n"):
134
+ sys.stdout.write("\n")
135
+ return
136
+
137
+ if kind == "file":
138
+ path = pathlib.Path(value)
139
+ path.parent.mkdir(parents=True, exist_ok=True)
140
+ path.write_text(content, encoding="utf-8")
141
+ sys.stderr.write(f"Wrote {path}\n")
142
+ return
143
+
144
+ if kind == "clipboard":
145
+ # Track tools that were found-but-failed separately from "no tool on
146
+ # PATH" so the error message accurately describes what went wrong.
147
+ # The prior shape ("requires pbcopy/xclip/clip on PATH") was
148
+ # misleading when e.g. pbcopy was present but exited non-zero.
149
+ tried = []
150
+ for cmd_args in (
151
+ ["pbcopy"],
152
+ ["xclip", "-sel", "clip"],
153
+ ["clip.exe"],
154
+ ):
155
+ tool = cmd_args[0]
156
+ if shutil.which(tool):
157
+ proc = subprocess.run(cmd_args, input=content, text=True, check=False)
158
+ if proc.returncode == 0:
159
+ sys.stderr.write(f"Copied to clipboard via {tool}\n")
160
+ return
161
+ tried.append(f"{tool} (exit {proc.returncode})")
162
+ if tried:
163
+ print(
164
+ f"cctally: clipboard tool failed: {', '.join(tried)}",
165
+ file=sys.stderr,
166
+ )
167
+ sys.exit(2)
168
+ print(
169
+ "cctally: --copy requires pbcopy, xclip, or clip on PATH",
170
+ file=sys.stderr,
171
+ )
172
+ sys.exit(2)
173
+
174
+ raise ValueError(f"unknown destination kind: {kind!r}")
175
+
176
+
177
+ def _share_load_lib():
178
+ """Lazy-load `_lib_share` with sys.modules caching.
179
+
180
+ Single-load semantics keep ShareSnapshot / MoneyCell / etc. class
181
+ identities stable across kernel imports: the test harness pre-registers
182
+ `_lib_share` in `sys.modules`, the wrapper imports it via this helper,
183
+ and snapshot builders import it via this helper — all paths must see
184
+ the SAME module object so `isinstance` checks on snapshot cells compare
185
+ across one class identity, not many. This is the chokepoint for the
186
+ duplicate-class-identity bug surfaced under the test harness in
187
+ Implementor 6's fix-loop.
188
+
189
+ Registers in sys.modules BEFORE exec_module: Python 3.14's `dataclass`
190
+ decorator looks up `cls.__module__` in `sys.modules` for `KW_ONLY` type
191
+ checks, and an absent entry would re-trigger the dual-load path under
192
+ some import orders.
193
+ """
194
+ cached = sys.modules.get("_lib_share")
195
+ if cached is not None:
196
+ return cached
197
+ import importlib.util as _ilu
198
+ _lib_share_path = pathlib.Path(__file__).resolve().parent / "_lib_share.py"
199
+ _spec = _ilu.spec_from_file_location("_lib_share", _lib_share_path)
200
+ _mod = _ilu.module_from_spec(_spec)
201
+ sys.modules["_lib_share"] = _mod
202
+ _spec.loader.exec_module(_mod)
203
+ return _mod
204
+
205
+
206
+ def _share_now_utc() -> dt.datetime:
207
+ """`generated_at` source — honors CCTALLY_AS_OF env hook for fixture stability.
208
+
209
+ Mirrors the existing `CCTALLY_AS_OF` precedent used by `project` /
210
+ `forecast` for deterministic fixture goldens. Format: ISO-8601 with `Z`
211
+ or explicit offset (e.g. `2026-05-09T12:00:00Z` or
212
+ `2026-05-09T12:00:00+00:00`); falls back to wall-clock UTC when unset.
213
+
214
+ Raises ValueError on malformed `CCTALLY_AS_OF` input — deliberate
215
+ fail-loud behavior for the dev hook so fixture authors notice typos
216
+ immediately rather than silently falling back to wall-clock time.
217
+ """
218
+ override = os.environ.get("CCTALLY_AS_OF")
219
+ if override:
220
+ parsed = dt.datetime.fromisoformat(override.replace("Z", "+00:00"))
221
+ if parsed.tzinfo is None:
222
+ parsed = parsed.replace(tzinfo=dt.timezone.utc)
223
+ return parsed.astimezone(dt.timezone.utc)
224
+ return dt.datetime.now(dt.timezone.utc)
225
+
226
+
227
+ def _share_now_utc_iso() -> str:
228
+ """`generated_at` ISO-8601 source for /api/share/render snapshot envelopes.
229
+
230
+ Honors `CCTALLY_AS_OF` like `_share_now_utc` so fixture goldens stay
231
+ deterministic across the CLI and HTTP paths. Format `YYYY-MM-DDTHH:MM:SSZ`.
232
+ """
233
+ return _share_now_utc().strftime("%Y-%m-%dT%H:%M:%SZ")
234
+
235
+
236
+ # Spec §11.4 — recent-shares ring buffer caps at 20. Server-side trim
237
+ # in `_handle_share_history_post` so the on-disk `config.json` can't
238
+ # grow unbounded even if a misbehaving client floods POSTs.
239
+ _SHARE_HISTORY_RING_CAP = 20
240
+
241
+
242
+ def _share_history_recipe_id() -> str:
243
+ """Server-stamped opaque id for a history record.
244
+
245
+ Random base16 (26 chars / 13 bytes) is sufficient: we order by
246
+ insertion (ring buffer position), never by id, so we don't need
247
+ ULID timestamp-prefix monotonicity. `secrets.token_hex` keeps us
248
+ on stdlib and avoids the predictability of `random`.
249
+ """
250
+ import secrets
251
+ return secrets.token_hex(13)
252
+
253
+
254
+ def _share_resolve_version() -> str:
255
+ """Source from CHANGELOG via the public helper. Empty string if unset.
256
+
257
+ `_lib_changelog._read_latest_changelog_version` returns
258
+ `(version, date) | None`; the snapshot's `version` field carries
259
+ the version string only.
260
+ """
261
+ info = _lib_changelog._read_latest_changelog_version()
262
+ return info[0] if info else ""
263
+
264
+
265
+ def _share_period_label(
266
+ period_start: dt.datetime,
267
+ period_end: dt.datetime,
268
+ display_tz_label: str,
269
+ ) -> str:
270
+ """Render the canonical "<start> → <end> (<tz>)" period label.
271
+
272
+ Used by both the report and daily snapshot builders so the period label
273
+ format stays consistent across share-enabled subcommands.
274
+ """
275
+ return (
276
+ f"{period_start.strftime('%b %d')} → "
277
+ f"{period_end.strftime('%b %d')} ({display_tz_label})"
278
+ )
279
+
280
+
281
+ def _share_parse_date_to_dt(value, tz: "ZoneInfo | None") -> dt.datetime:
282
+ """Coerce a `YYYY-MM-DD` string or `dt.date` into a tz-aware datetime.
283
+
284
+ Used by the share gate sites to lift week-boundary date strings
285
+ (`weekStartDate`, `weekEndDate`) into the tz-aware datetimes that
286
+ `PeriodSpec` expects. None / empty / unparseable -> current UTC; the
287
+ caller already gated on a non-empty trend before reaching this path,
288
+ so the fallback is purely defensive against missing-data corner cases.
289
+ """
290
+ if value is None:
291
+ return _share_now_utc()
292
+ if isinstance(value, dt.datetime):
293
+ return value if value.tzinfo else value.replace(tzinfo=tz or dt.timezone.utc)
294
+ if isinstance(value, dt.date):
295
+ d = value
296
+ else:
297
+ try:
298
+ d = dt.date.fromisoformat(str(value))
299
+ except ValueError:
300
+ return _share_now_utc()
301
+ midnight = dt.datetime(d.year, d.month, d.day)
302
+ return midnight.replace(tzinfo=tz or dt.timezone.utc)
303
+
304
+
305
+ def _share_display_tz_label(tz: "ZoneInfo | None") -> str:
306
+ """Render a stable display-tz string for `PeriodSpec.display_tz`.
307
+
308
+ `resolve_display_tz` returns `None` for "local" (caller does bare
309
+ astimezone); the share snapshot needs a non-None string. Map None ->
310
+ "local" and use ZoneInfo.key otherwise.
311
+ """
312
+ return tz.key if tz is not None else "local"
313
+
314
+
315
+ def _build_report_snapshot(
316
+ view: "TrendView",
317
+ *,
318
+ period_start: dt.datetime,
319
+ period_end: dt.datetime,
320
+ display_tz: str,
321
+ version: str,
322
+ theme: str,
323
+ reveal_projects: bool,
324
+ ) -> "ShareSnapshot":
325
+ """Build a ShareSnapshot for `cctally report`.
326
+
327
+ Consumes the unified TrendView (spec §6.4). `view.rows` is the
328
+ chronological (oldest-first) TuiTrendRow tuple — exactly the order
329
+ the chart needs (BarChart polyline trends left→right with time);
330
+ no reversal needed.
331
+
332
+ The earlier camelCase-dict workaround (recorded in the commit body
333
+ of Implementor 7 of the share-v2 work) is obsolete: `TuiTrendRow`
334
+ now carries 10 nullable extended fields (spec §4.1) and is the
335
+ single typed shape that flows through both CLI report and share
336
+ builders. Cmd_report's JSON serialization happens at the gate site
337
+ (camelCase mapping done in cmd_report); this function reads
338
+ attributes directly from the typed row.
339
+
340
+ `theme` and `reveal_projects` flow into the subtitle directly so
341
+ the builder owns the canonical subtitle shape — no post-build
342
+ re-stamp at the gate site. The forward-reference return type
343
+ matches the kernel's lazy-import boundary.
344
+ """
345
+ _lib_share = _share_load_lib()
346
+ columns = (
347
+ _lib_share.ColumnSpec(key="week", label="Week", align="left"),
348
+ _lib_share.ColumnSpec(key="used", label="% Used", align="right"),
349
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right"),
350
+ _lib_share.ColumnSpec(key="dpp", label="$ / %", align="right",
351
+ emphasis=True),
352
+ )
353
+ rows = view.rows # oldest-first; matches chart's left→right walk.
354
+ snap_rows: list = []
355
+ chart_pts: list = []
356
+ for i, r in enumerate(rows):
357
+ wsd = r.week_start_date.isoformat() if r.week_start_date else None
358
+ if isinstance(wsd, str) and wsd:
359
+ try:
360
+ week_label = dt.date.fromisoformat(wsd).strftime("%b %d")
361
+ except ValueError:
362
+ week_label = wsd
363
+ else:
364
+ week_label = "—"
365
+ # Preserve None vs 0.0 distinction (parity with terminal/JSON).
366
+ # Terminal _render_weekly_table renders missing values as "—";
367
+ # share artifact follows the same convention. Coercing None to
368
+ # 0.0 would render `$0.00` / `0.0%` — indistinguishable from a
369
+ # genuine zero, and would skew the avg / chart.
370
+ used_pct_raw = r.used_pct
371
+ cost_raw = r.weekly_cost_usd
372
+ dpp_raw = r.dollars_per_percent
373
+ snap_rows.append(_lib_share.Row(cells={
374
+ "week": _lib_share.TextCell(week_label),
375
+ "used": (
376
+ _lib_share.PercentCell(float(used_pct_raw))
377
+ if used_pct_raw is not None else _lib_share.TextCell("—")
378
+ ),
379
+ "cost": (
380
+ _lib_share.MoneyCell(float(cost_raw))
381
+ if cost_raw is not None else _lib_share.TextCell("—")
382
+ ),
383
+ "dpp": (
384
+ _lib_share.MoneyCell(float(dpp_raw))
385
+ if dpp_raw is not None else _lib_share.TextCell("—")
386
+ ),
387
+ }))
388
+ # Skip chart points for weeks with no $/% sample — the polyline
389
+ # connects across the gap rather than dropping to 0, which would
390
+ # misrepresent missing data as a crash to zero.
391
+ if dpp_raw is not None:
392
+ chart_pts.append(_lib_share.ChartPoint(
393
+ x_label=week_label,
394
+ x_value=float(i),
395
+ y_value=float(dpp_raw),
396
+ ))
397
+ chart = (
398
+ _lib_share.LineChart(points=tuple(chart_pts), y_label="$ / %")
399
+ if len(chart_pts) >= 3 else None
400
+ )
401
+ # Source the avg from the view (3-sample rule). Falls back to a
402
+ # length-based average over the chart points for the <3-sample case
403
+ # so the Totalled cell always renders something concrete; preserves
404
+ # the prior $0.00 sentinel on empty data.
405
+ if view.avg_dollars_per_pct is not None:
406
+ avg_dpp = view.avg_dollars_per_pct
407
+ else:
408
+ avg_dpp = (
409
+ sum(p.y_value for p in chart_pts) / len(chart_pts)
410
+ if chart_pts else 0.0
411
+ )
412
+ totals = (
413
+ _lib_share.Totalled(label="Avg $/%", value=f"${avg_dpp:,.2f}"),
414
+ )
415
+ if rows:
416
+ title = f"Weekly $ / % trend — last {len(rows)} weeks"
417
+ else:
418
+ title = "Weekly $ / % trend — no data"
419
+ period_label = _share_period_label(period_start, period_end, display_tz)
420
+ subtitle = " · ".join([
421
+ period_label,
422
+ theme,
423
+ "real projects" if reveal_projects else "projects anonymized",
424
+ ])
425
+ return _lib_share.ShareSnapshot(
426
+ cmd="report",
427
+ title=title,
428
+ subtitle=subtitle,
429
+ period=_lib_share.PeriodSpec(
430
+ start=period_start, end=period_end,
431
+ display_tz=display_tz, label=period_label,
432
+ ),
433
+ columns=columns, rows=tuple(snap_rows),
434
+ chart=chart, totals=totals, notes=(),
435
+ generated_at=_share_now_utc(), version=version,
436
+ )
437
+
438
+
439
+ def _build_daily_snapshot(
440
+ view: "DailyView",
441
+ *,
442
+ period_start: dt.datetime,
443
+ period_end: dt.datetime,
444
+ display_tz: str,
445
+ version: str,
446
+ theme: str,
447
+ reveal_projects: bool,
448
+ ) -> "ShareSnapshot":
449
+ """Build a ShareSnapshot for `cctally daily`.
450
+
451
+ Consumes the unified DailyView (spec §6.1). `view.aggregated` is
452
+ the gap-free BucketUsage tuple in newest-first order; we reverse
453
+ here so BarChart bars render left-to-right chronologically.
454
+ `view.total_cost_usd` is the pre-computed sum (replacing the
455
+ prior inline re-totaling).
456
+
457
+ Deviations from the plan sketch (which assumed dict rows with keys
458
+ `date` / `cost_usd` / `pct_of_week` / `top_model`):
459
+
460
+ - Rows are `BucketUsage` dataclasses; we read fields by attribute.
461
+ - Daily has no native `% of week` column — daily is range-scoped, not
462
+ week-scoped. We render `% of period` (this row's cost / total range
463
+ cost) so the column carries meaningful info; the `pct_week` key
464
+ survives in the column spec for plan-shape parity.
465
+ - `top_model` is the first entry of `model_breakdowns` (sorted by cost
466
+ desc per upstream ccusage parity); empty → "—".
467
+
468
+ `period_start` / `period_end` / `display_tz` are passed by the
469
+ caller (they reflect the CLI's `--since` / `--until` window which
470
+ may extend past the data window). `theme` and `reveal_projects`
471
+ flow into the subtitle directly so the builder owns the canonical
472
+ subtitle shape — no post-build re-stamp at the gate site.
473
+ """
474
+ _lib_share = _share_load_lib()
475
+ columns = (
476
+ _lib_share.ColumnSpec(key="date", label="Date", align="left"),
477
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
478
+ emphasis=True),
479
+ _lib_share.ColumnSpec(key="pct_week", label="% of Period",
480
+ align="right"),
481
+ _lib_share.ColumnSpec(key="top_model", label="Top Model",
482
+ align="left"),
483
+ )
484
+ # Caller MUST pass rows in chronological order so the BarChart bars
485
+ # line up left-to-right with time. view.aggregated is newest-first
486
+ # (matches dashboard convention); reverse for chronological iteration.
487
+ rows = list(reversed(view.aggregated))
488
+ total_cost = view.total_cost_usd
489
+
490
+ snap_rows: list = []
491
+ chart_pts: list = []
492
+ for i, r in enumerate(rows):
493
+ # `BucketUsage.bucket` is typed `str` (YYYY-MM-DD); guard against
494
+ # empty / unparseable but skip the dead `dt.date` branch.
495
+ bucket = getattr(r, "bucket", None)
496
+ if isinstance(bucket, str) and bucket:
497
+ try:
498
+ date_str = dt.date.fromisoformat(bucket).strftime("%b %d")
499
+ except ValueError:
500
+ date_str = bucket
501
+ else:
502
+ date_str = "—"
503
+ cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
504
+ breakdowns = getattr(r, "model_breakdowns", None) or []
505
+ top_model = (breakdowns[0].get("modelName") if breakdowns else None) or "—"
506
+ pct_of_period = (cost_usd / total_cost * 100.0) if total_cost > 0 else 0.0
507
+ snap_rows.append(_lib_share.Row(cells={
508
+ "date": _lib_share.TextCell(date_str),
509
+ "cost": _lib_share.MoneyCell(cost_usd),
510
+ "pct_week": _lib_share.PercentCell(pct_of_period),
511
+ "top_model": _lib_share.TextCell(top_model),
512
+ }))
513
+ chart_pts.append(_lib_share.ChartPoint(
514
+ x_label=date_str,
515
+ x_value=float(i),
516
+ y_value=cost_usd,
517
+ ))
518
+ chart = (
519
+ _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
520
+ if chart_pts else None
521
+ )
522
+ avg_cost = (total_cost / len(chart_pts)) if chart_pts else 0.0
523
+ totals = (
524
+ _lib_share.Totalled(label="Sum", value=f"${total_cost:,.2f}"),
525
+ _lib_share.Totalled(label="Days", value=str(len(chart_pts))),
526
+ _lib_share.Totalled(label="Avg / day", value=f"${avg_cost:,.2f}"),
527
+ )
528
+ if rows:
529
+ title = (
530
+ f"Daily usage — {period_start.strftime('%b %d')} → "
531
+ f"{period_end.strftime('%b %d')}"
532
+ )
533
+ else:
534
+ title = "Daily usage — no data"
535
+ period_label = _share_period_label(period_start, period_end, display_tz)
536
+ subtitle = " · ".join([
537
+ period_label,
538
+ theme,
539
+ "real projects" if reveal_projects else "projects anonymized",
540
+ ])
541
+ return _lib_share.ShareSnapshot(
542
+ cmd="daily",
543
+ title=title,
544
+ subtitle=subtitle,
545
+ period=_lib_share.PeriodSpec(
546
+ start=period_start, end=period_end,
547
+ display_tz=display_tz, label=period_label,
548
+ ),
549
+ columns=columns, rows=tuple(snap_rows),
550
+ chart=chart, totals=totals, notes=(),
551
+ generated_at=_share_now_utc(), version=version,
552
+ )
553
+
554
+
555
+ def _build_monthly_snapshot(
556
+ view: "MonthlyView",
557
+ *,
558
+ period_start: dt.datetime,
559
+ period_end: dt.datetime,
560
+ display_tz: str,
561
+ version: str,
562
+ theme: str,
563
+ reveal_projects: bool,
564
+ ) -> "ShareSnapshot":
565
+ """Build a ShareSnapshot for `cctally monthly`.
566
+
567
+ Consumes the unified MonthlyView (spec §6.2). `view.aggregated` is
568
+ the gap-free BucketUsage tuple in newest-first order; we reverse
569
+ so BarChart bars render left-to-right chronologically.
570
+
571
+ Deviations from the plan sketch (which assumed dict rows with keys
572
+ `month` / `cost_usd` / `sessions`):
573
+
574
+ - Rows are `BucketUsage` dataclasses; we read fields by attribute.
575
+ - The plan's `Sessions` column has no source in the underlying data
576
+ (`BucketUsage` carries no session count and `_aggregate_monthly`
577
+ never computes one). Substituted with a `Tokens` column carrying
578
+ total tokens — meaningful info already on the dataclass.
579
+ - `Δ vs prior` is computed on `cost_usd` between consecutive ASC-sorted
580
+ months, matching the plan's intent.
581
+
582
+ `period_start` / `period_end` / `display_tz` are passed by the
583
+ caller (the CLI's `--since` / `--until` window may extend past
584
+ the data window). `theme` / `reveal_projects` flow into the
585
+ subtitle directly so the builder owns the canonical subtitle
586
+ shape — no post-build re-stamp at the gate site.
587
+ """
588
+ # Caller MUST pass rows in chronological order so the BarChart bars
589
+ # line up left-to-right with time. view.aggregated is newest-first.
590
+ rows = list(reversed(view.aggregated))
591
+ _lib_share = _share_load_lib()
592
+ columns = (
593
+ _lib_share.ColumnSpec(key="month", label="Month", align="left"),
594
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
595
+ emphasis=True),
596
+ _lib_share.ColumnSpec(key="tokens", label="Tokens", align="right"),
597
+ _lib_share.ColumnSpec(key="delta", label="Δ vs prior", align="right"),
598
+ )
599
+ snap_rows: list = []
600
+ chart_pts: list = []
601
+ prev_cost: float | None = None
602
+ for i, r in enumerate(rows):
603
+ # `BucketUsage.bucket` is typed `str` ("YYYY-MM"); guard against
604
+ # empty / unparseable but skip the dead `dt.date` branch.
605
+ bucket = getattr(r, "bucket", None)
606
+ month_str = bucket if isinstance(bucket, str) and bucket else "—"
607
+ cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
608
+ total_tokens = int(getattr(r, "total_tokens", 0) or 0)
609
+ if prev_cost is not None and prev_cost > 0:
610
+ delta_pct = (cost_usd - prev_cost) / prev_cost * 100.0
611
+ delta_cell = _lib_share.DeltaCell(value=delta_pct, unit="%")
612
+ else:
613
+ delta_cell = _lib_share.TextCell("—")
614
+ snap_rows.append(_lib_share.Row(cells={
615
+ "month": _lib_share.TextCell(month_str),
616
+ "cost": _lib_share.MoneyCell(cost_usd),
617
+ "tokens": _lib_share.TextCell(f"{total_tokens:,}"),
618
+ "delta": delta_cell,
619
+ }))
620
+ chart_pts.append(_lib_share.ChartPoint(
621
+ x_label=month_str,
622
+ x_value=float(i),
623
+ y_value=cost_usd,
624
+ ))
625
+ prev_cost = cost_usd
626
+ chart = (
627
+ _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
628
+ if chart_pts else None
629
+ )
630
+ sum_cost = sum(p.y_value for p in chart_pts)
631
+ avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
632
+ totals = (
633
+ _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
634
+ _lib_share.Totalled(label="Months", value=str(len(chart_pts))),
635
+ _lib_share.Totalled(label="Avg / month", value=f"${avg_cost:,.2f}"),
636
+ )
637
+ if rows:
638
+ title = (
639
+ f"Monthly usage — {period_start.strftime('%Y-%m')} → "
640
+ f"{period_end.strftime('%Y-%m')}"
641
+ )
642
+ else:
643
+ title = "Monthly usage — no data"
644
+ period_label = (
645
+ f"{period_start.strftime('%Y-%m')} → "
646
+ f"{period_end.strftime('%Y-%m')} ({display_tz})"
647
+ )
648
+ subtitle = " · ".join([
649
+ period_label,
650
+ theme,
651
+ "real projects" if reveal_projects else "projects anonymized",
652
+ ])
653
+ return _lib_share.ShareSnapshot(
654
+ cmd="monthly",
655
+ title=title,
656
+ subtitle=subtitle,
657
+ period=_lib_share.PeriodSpec(
658
+ start=period_start, end=period_end,
659
+ display_tz=display_tz, label=period_label,
660
+ ),
661
+ columns=columns, rows=tuple(snap_rows),
662
+ chart=chart, totals=totals, notes=(),
663
+ generated_at=_share_now_utc(), version=version,
664
+ )
665
+
666
+
667
+ def _build_weekly_snapshot(
668
+ view: "WeeklyView",
669
+ *,
670
+ period_start: dt.datetime,
671
+ period_end: dt.datetime,
672
+ display_tz: str,
673
+ version: str,
674
+ theme: str,
675
+ reveal_projects: bool,
676
+ breakdown_model: bool,
677
+ ) -> "ShareSnapshot":
678
+ """Build a ShareSnapshot for `cctally weekly`.
679
+
680
+ Consumes the unified WeeklyView (spec §6.3). `view.aggregated` is
681
+ the gap-free BucketUsage tuple newest-first; `view.overlay` is the
682
+ parallel `(used_pct, dollars_per_pct)` tuple. We reverse both for
683
+ chronological iteration so BarChart bars render left-to-right
684
+ with time.
685
+
686
+ Each bucket carries `bucket` (week_start_date as "YYYY-MM-DD"),
687
+ `cost_usd`, `total_tokens`, and `model_breakdowns` (list[dict]
688
+ sorted by cost desc, each `{modelName, ..., cost}`). Either
689
+ overlay component may be `None` for a week with no captured
690
+ snapshot — surfaces in the snapshot row as a `0.0` PercentCell so
691
+ the column stays aligned (matching the table renderer's "no data
692
+ → 0%" behavior).
693
+
694
+ Deviations from the plan sketch (which assumed dict rows with keys
695
+ `week_start_date` / `used_pct` / `cost_usd` / `sessions` /
696
+ `model_breakdown` and a `breakdown_model: bool` derived from
697
+ `args.breakdown == "model"`):
698
+
699
+ - Rows are `BucketUsage` dataclasses; per-week `used_pct` lives in the
700
+ separate `overlay` list — neither shape matches the plan literal.
701
+ - The plan's `Sessions` column has no source — `BucketUsage` carries
702
+ no session count and `_aggregate_weekly` never computes one.
703
+ Substituted with a `Tokens` column (`total_tokens` formatted with
704
+ thousands separators).
705
+ - `args.breakdown` for `cmd_weekly` is `action="store_true"` (not a
706
+ `{model,project}` choice), so `breakdown_model` is just the boolean
707
+ `args.breakdown` from the gate site.
708
+ - `model_breakdowns` is a list-of-dicts (`modelName` / `cost`), not a
709
+ `{model: cost}` mapping; we coerce to a dict before key lookup.
710
+
711
+ Honors `breakdown_model` by appending one `m_<model>` column per
712
+ distinct model and populating `BarChart.stacks` with per-model series.
713
+ All model-axis iteration uses a single sorted list (`all_model_keys`)
714
+ so column / stack ordering is deterministic across runs.
715
+
716
+ `theme` and `reveal_projects` flow into the subtitle directly so the
717
+ builder owns the canonical subtitle shape — no post-build re-stamp at
718
+ the gate site.
719
+ """
720
+ # view.aggregated / view.overlay are newest-first; reverse for asc
721
+ # so BarChart bars are chronological.
722
+ rows = list(reversed(view.aggregated))
723
+ overlay = list(reversed(view.overlay))
724
+ _lib_share = _share_load_lib()
725
+ columns_list: list = [
726
+ _lib_share.ColumnSpec(key="week", label="Week Start", align="left"),
727
+ _lib_share.ColumnSpec(key="used", label="% Used", align="right"),
728
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
729
+ emphasis=True),
730
+ _lib_share.ColumnSpec(key="tokens", label="Tokens", align="right"),
731
+ ]
732
+ # Per-row model→cost lookup (BucketUsage exposes a list-of-dicts;
733
+ # collapse to dict here so per-row column population is O(1) per
734
+ # model). All breakdown-aware iteration goes through `all_model_keys`
735
+ # for deterministic ordering.
736
+ per_row_model_costs: list[dict[str, float]] = []
737
+ for r in rows:
738
+ breakdowns = getattr(r, "model_breakdowns", None) or []
739
+ per_row_model_costs.append({
740
+ (b.get("modelName") or "—"): float(b.get("cost") or 0.0)
741
+ for b in breakdowns
742
+ })
743
+ if breakdown_model:
744
+ all_model_keys = sorted({m for d in per_row_model_costs for m in d})
745
+ for m in all_model_keys:
746
+ columns_list.append(_lib_share.ColumnSpec(
747
+ key=f"m_{m}", label=m, align="right",
748
+ ))
749
+ else:
750
+ all_model_keys = []
751
+
752
+ snap_rows: list = []
753
+ chart_pts: list = []
754
+ stacks: dict[str, list] = {}
755
+ for i, r in enumerate(rows):
756
+ # `BucketUsage.bucket` is typed `str` ("YYYY-MM-DD"); guard against
757
+ # empty / unparseable but skip the dead `dt.date` branch.
758
+ bucket = getattr(r, "bucket", None)
759
+ if isinstance(bucket, str) and bucket:
760
+ try:
761
+ week_label = dt.date.fromisoformat(bucket).strftime("%b %d")
762
+ except ValueError:
763
+ week_label = bucket
764
+ else:
765
+ week_label = "—"
766
+ cost_usd = float(getattr(r, "cost_usd", 0.0) or 0.0)
767
+ total_tokens = int(getattr(r, "total_tokens", 0) or 0)
768
+ # `used_pct` is None when the week lacks a `weekly_usage_snapshots`
769
+ # row — render as em-dash to match terminal `_render_weekly_table`.
770
+ # Coercing to 0.0 would conflate "no snapshot recorded" with "0%
771
+ # used," same divergence the report builder fixes. cost_usd from
772
+ # session_entries is genuinely 0 when there are no entries (not
773
+ # missing data) so that path keeps MoneyCell(0.0).
774
+ used_pct_raw = (
775
+ overlay[i][0] if i < len(overlay) else None
776
+ )
777
+ cells = {
778
+ "week": _lib_share.TextCell(week_label),
779
+ "used": (
780
+ _lib_share.PercentCell(float(used_pct_raw))
781
+ if used_pct_raw is not None else _lib_share.TextCell("—")
782
+ ),
783
+ "cost": _lib_share.MoneyCell(cost_usd),
784
+ "tokens": _lib_share.TextCell(f"{total_tokens:,}"),
785
+ }
786
+ if breakdown_model:
787
+ row_costs = per_row_model_costs[i]
788
+ for m in all_model_keys:
789
+ m_cost = float(row_costs.get(m) or 0.0)
790
+ cells[f"m_{m}"] = _lib_share.MoneyCell(m_cost)
791
+ stacks.setdefault(m, []).append(_lib_share.ChartPoint(
792
+ x_label=week_label,
793
+ x_value=float(i),
794
+ y_value=m_cost,
795
+ series_key=m,
796
+ ))
797
+ snap_rows.append(_lib_share.Row(cells=cells))
798
+ chart_pts.append(_lib_share.ChartPoint(
799
+ x_label=week_label,
800
+ x_value=float(i),
801
+ y_value=cost_usd,
802
+ ))
803
+ # `BarChart.stacks` is `Mapping[str, tuple[ChartPoint, ...]] | None`
804
+ # (Implementor 1's tightening); convert dict-of-lists to dict-of-tuples.
805
+ stacks_immut = (
806
+ {k: tuple(v) for k, v in stacks.items()} if stacks else None
807
+ )
808
+ chart = (
809
+ _lib_share.BarChart(
810
+ points=tuple(chart_pts), y_label="$", stacks=stacks_immut,
811
+ )
812
+ if chart_pts else None
813
+ )
814
+ sum_cost = sum(p.y_value for p in chart_pts)
815
+ pct_values = [
816
+ float(o[0]) for o in overlay
817
+ if o is not None and o[0] is not None
818
+ ]
819
+ avg_pct = (sum(pct_values) / len(pct_values)) if pct_values else 0.0
820
+ peak_pct = max(pct_values, default=0.0)
821
+ totals = (
822
+ _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
823
+ _lib_share.Totalled(label="Avg %/wk", value=f"{avg_pct:.1f}%"),
824
+ _lib_share.Totalled(label="Peak %", value=f"{peak_pct:.1f}%"),
825
+ )
826
+ title = (
827
+ f"Weekly usage — last {len(rows)} weeks"
828
+ if rows
829
+ else "Weekly usage — no data"
830
+ )
831
+ period_label = _share_period_label(period_start, period_end, display_tz)
832
+ subtitle = " · ".join([
833
+ period_label,
834
+ theme,
835
+ "real projects" if reveal_projects else "projects anonymized",
836
+ ])
837
+ return _lib_share.ShareSnapshot(
838
+ cmd="weekly",
839
+ title=title,
840
+ subtitle=subtitle,
841
+ period=_lib_share.PeriodSpec(
842
+ start=period_start, end=period_end,
843
+ display_tz=display_tz, label=period_label,
844
+ ),
845
+ columns=tuple(columns_list), rows=tuple(snap_rows),
846
+ chart=chart, totals=totals, notes=(),
847
+ generated_at=_share_now_utc(), version=version,
848
+ )
849
+
850
+
851
+ def _build_forecast_snapshot(
852
+ *,
853
+ week_start: dt.datetime,
854
+ week_end: dt.datetime,
855
+ display_tz: str,
856
+ version: str,
857
+ theme: str,
858
+ reveal_projects: bool,
859
+ actual_series: list[tuple[str, float, float]],
860
+ projected_series: list[tuple[str, float, float]],
861
+ current_pct: float,
862
+ projected_low_pct: float,
863
+ projected_high_pct: float,
864
+ days_remaining: float,
865
+ dollars_per_percent: float,
866
+ dollars_per_percent_source: str,
867
+ low_conf: bool,
868
+ notes: tuple[str, ...] = (),
869
+ ) -> "ShareSnapshot":
870
+ """Build a ShareSnapshot for `cctally forecast`.
871
+
872
+ `actual_series` is a list of `(x_label, x_value, y_value)` tuples drawn
873
+ from `weekly_usage_snapshots` for the current week — each sample's
874
+ `captured_at_utc` is the x_label (formatted compactly), `x_value` is
875
+ elapsed-hours-since-week-start (a monotonic float so the LineChart
876
+ renders left→right), and `y_value` is `weekly_percent` at that capture.
877
+
878
+ `projected_series` is a parallel list of `(x_label, x_value, y_value)`
879
+ tuples for the projection ray — the simplest form is a 2-point line
880
+ from `(now, current_pct)` to `(week_end, projected_eow_pct)`. The
881
+ renderer treats it as a `multi_series` overlay on top of the actual line.
882
+
883
+ Deviations from the plan sketch (which assumed a single
884
+ `_compute_forecast_data(args) -> dict` helper and `dpp_week_avg` /
885
+ `dpp_24h` as separate columns):
886
+
887
+ - `cmd_forecast` already exposes the data as `ForecastOutput` (which
888
+ wraps `ForecastInputs`). No helper extraction was needed; we pass
889
+ the actual scalars in directly.
890
+ - `ForecastInputs` carries a single `dollars_per_percent` value plus
891
+ a `dollars_per_percent_source` enum (`this_week` /
892
+ `trailing_4wk_median` / `this_week_sparse`); there is no separate
893
+ `dpp_week_avg` and `dpp_24h`. The table renders one $/1% row with
894
+ the source as a paren suffix in the metric cell.
895
+ - The plan's single `projected_eow_pct` is split into a low/high
896
+ range (matching `--render-forecast-terminal`'s "Forecast 80–95%"
897
+ band). The table shows both ends; the projected_series ray uses
898
+ the high end so the overlay aligns with the conservative budget
899
+ consumers expect from the chart.
900
+
901
+ Reference lines at 90%/100% are LineChart-stable across all samples;
902
+ severities `warn` (90%) and `alarm` (100%) drive the renderer's
903
+ color mapping.
904
+
905
+ `theme` and `reveal_projects` flow into the subtitle directly so the
906
+ builder owns the canonical subtitle shape — no post-build re-stamp
907
+ at the gate site.
908
+
909
+ `notes`, when non-empty, overrides the auto-emitted "LOW CONF — data
910
+ thin" note. The empty-data fast-path passes a clearer "no snapshots
911
+ recorded" note so the artifact says what's actually wrong; the
912
+ confidence-thin terminal path passes nothing and falls back to the
913
+ auto LOW CONF banner.
914
+ """
915
+ _lib_share = _share_load_lib()
916
+ actual_pts = tuple(
917
+ _lib_share.ChartPoint(x_label=lbl, x_value=float(xv), y_value=float(yv))
918
+ for lbl, xv, yv in actual_series
919
+ )
920
+ projected_pts = tuple(
921
+ _lib_share.ChartPoint(x_label=lbl, x_value=float(xv), y_value=float(yv))
922
+ for lbl, xv, yv in projected_series
923
+ )
924
+ chart = (
925
+ _lib_share.LineChart(
926
+ points=actual_pts,
927
+ y_label="cumulative %",
928
+ reference_lines=(
929
+ (90.0, "90%", "warn"),
930
+ (100.0, "100%", "alarm"),
931
+ ),
932
+ multi_series={"projected": projected_pts} if projected_pts else None,
933
+ )
934
+ if actual_pts else None
935
+ )
936
+
937
+ columns = (
938
+ _lib_share.ColumnSpec(key="metric", label="Metric", align="left"),
939
+ _lib_share.ColumnSpec(key="value", label="Value", align="right",
940
+ emphasis=True),
941
+ )
942
+ # Render the projected band as "low-high%" so a single PercentCell
943
+ # carries the two-rate forecast spread. When the rates collapse to a
944
+ # single value (no recent-24h sample), low == high.
945
+ # 0.05 threshold: below .1f display precision — tighter spreads would
946
+ # render as identical decimals, so collapse to a single value.
947
+ if abs(projected_high_pct - projected_low_pct) < 0.05:
948
+ projected_text = f"{projected_high_pct:.1f}%"
949
+ else:
950
+ projected_text = (
951
+ f"{projected_low_pct:.1f}% — {projected_high_pct:.1f}%"
952
+ )
953
+ dpp_source_label = dollars_per_percent_source.replace("_", " ")
954
+ snap_rows = (
955
+ _lib_share.Row(cells={
956
+ "metric": _lib_share.TextCell("Current %"),
957
+ "value": _lib_share.PercentCell(float(current_pct)),
958
+ }),
959
+ _lib_share.Row(cells={
960
+ "metric": _lib_share.TextCell("Projected end-of-week %"),
961
+ "value": _lib_share.TextCell(projected_text),
962
+ }),
963
+ _lib_share.Row(cells={
964
+ "metric": _lib_share.TextCell("Days remaining"),
965
+ "value": _lib_share.TextCell(f"{days_remaining:.1f}"),
966
+ }),
967
+ _lib_share.Row(cells={
968
+ "metric": _lib_share.TextCell(f"$ / 1% ({dpp_source_label})"),
969
+ "value": _lib_share.MoneyCell(float(dollars_per_percent)),
970
+ }),
971
+ )
972
+ # Caller-provided `notes` (e.g., empty-data path's clearer message)
973
+ # take precedence over the auto LOW CONF banner. Sibling builders
974
+ # don't expose this knob; forecast does because its empty-state and
975
+ # thin-confidence states need different copy.
976
+ final_notes = notes if notes else (
977
+ ("LOW CONF — data thin",) if low_conf else ()
978
+ )
979
+ if actual_pts:
980
+ title = f"Forecast — week of {week_start.strftime('%b %d')}"
981
+ else:
982
+ title = "Forecast — no data"
983
+ # Reuse the shared period-label helper so forecast's subtitle period
984
+ # format matches sibling builders (cmd_daily / cmd_project / etc.).
985
+ period_label = _share_period_label(week_start, week_end, display_tz)
986
+ subtitle = " · ".join([
987
+ period_label,
988
+ theme,
989
+ "real projects" if reveal_projects else "projects anonymized",
990
+ ])
991
+ return _lib_share.ShareSnapshot(
992
+ cmd="forecast",
993
+ title=title,
994
+ subtitle=subtitle,
995
+ period=_lib_share.PeriodSpec(
996
+ start=week_start, end=week_end,
997
+ display_tz=display_tz, label=period_label,
998
+ ),
999
+ columns=columns, rows=snap_rows,
1000
+ chart=chart, totals=(), notes=final_notes,
1001
+ generated_at=_share_now_utc(), version=version,
1002
+ )
1003
+
1004
+
1005
+ def _build_project_snapshot(
1006
+ rows: list[dict],
1007
+ *,
1008
+ period_start: dt.datetime,
1009
+ period_end: dt.datetime,
1010
+ display_tz: str,
1011
+ version: str,
1012
+ theme: str,
1013
+ reveal_projects: bool,
1014
+ ) -> "ShareSnapshot":
1015
+ """Build a ShareSnapshot for `cctally project`.
1016
+
1017
+ `rows` is the in-memory per-project aggregate list produced inside
1018
+ `cmd_project` (`project_rows.values()` post-sort). Each row is a
1019
+ dict with: `key` (a `ProjectKey` carrying `display_key` /
1020
+ `bucket_path`), `cost_usd`, `attributed_pct` (`float | None`),
1021
+ `sessions` (a `set` of session-IDs).
1022
+
1023
+ Privacy invariant (Section 8.4 / Section 5.3): the builder populates
1024
+ `ProjectCell.label` AND `ChartPoint.project_label` (and `x_label`,
1025
+ which is the project axis on a HorizontalBarChart) with the REAL
1026
+ `display_key`. The `_share_render_and_emit` wrapper then runs
1027
+ `_lib_share._scrub` BEFORE rendering — that's the single chokepoint
1028
+ that rewrites every project label to `project-1` / `project-2` /
1029
+ ... unless `--reveal-projects` is passed. The Section 8.4 canary
1030
+ test (`test_anonymized_output_contains_zero_original_tokens`) and
1031
+ the wrapper-level regression
1032
+ (`test_share_render_and_emit_scrubs_project_labels`) both anchor
1033
+ this contract.
1034
+
1035
+ Deviations from the plan sketch (which assumed dict rows with keys
1036
+ `project` / `cost_usd` / `used_pct` / `sessions`):
1037
+
1038
+ - Rows are dicts whose `key` field is a `ProjectKey` dataclass; the
1039
+ project label comes from `key.display_key`. The plan's `project`
1040
+ key does not exist on the actual `cmd_project` data shape.
1041
+ - `attributed_pct` may be `None` for projects whose contributing
1042
+ weeks all lacked a `weekly_usage_snapshots` row; the table renders
1043
+ that as em-dash (parity with terminal `_render_project_table`).
1044
+ - Sessions is a `set`; the cell carries its `len(...)` as text.
1045
+
1046
+ `HorizontalBarChart.cap=12` matches the plan; when more than 12
1047
+ projects exist, a note clarifies that the table includes all rows
1048
+ while the chart shows only the top 12 by cost.
1049
+
1050
+ Caller MUST pass `rows` already sorted in the desired order
1051
+ (cmd_project honors `--sort` / `--order` upstream). The builder
1052
+ preserves caller order for the table — terminal / JSON / share
1053
+ artifacts all show the same row ordering. Internally the builder
1054
+ ALSO computes a descending-cost copy that drives the HBar chart
1055
+ and the basename-disambiguation rank (both must match
1056
+ `_build_anon_mapping`'s descending-cost sort so `project-1` stays
1057
+ glued to the highest-cost bar regardless of `--sort`). Anonymization
1058
+ is row-identity based (`id(r)` → augmented label), not position
1059
+ based, so the table sees the same disambiguated label as the chart.
1060
+
1061
+ `theme` and `reveal_projects` flow into the subtitle directly so the
1062
+ builder owns the canonical subtitle shape — no post-build re-stamp
1063
+ at the gate site.
1064
+ """
1065
+ _lib_share = _share_load_lib()
1066
+ columns = (
1067
+ _lib_share.ColumnSpec(key="project", label="Project", align="left"),
1068
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
1069
+ emphasis=True),
1070
+ _lib_share.ColumnSpec(key="used", label="% Used", align="right"),
1071
+ _lib_share.ColumnSpec(key="sessions", label="Sessions", align="right"),
1072
+ )
1073
+ # Two orderings — same rows, different consumers:
1074
+ #
1075
+ # * `rows` (caller order) drives the table. `cmd_project` upstream
1076
+ # has already applied `--sort` / `--order`, so the share artifact's
1077
+ # table matches terminal / JSON output for any of `--sort cost`,
1078
+ # `--sort name`, `--sort sessions` × `--order asc|desc`.
1079
+ #
1080
+ # * `cost_sorted_rows` (descending cost) drives the HBar chart and
1081
+ # the basename-disambiguation rank — both must align with
1082
+ # `_build_anon_mapping`'s descending-cost sort so `project-1` stays
1083
+ # glued to the highest-cost bar regardless of `--sort` choice.
1084
+ cost_sorted_rows = sorted(
1085
+ rows, key=lambda r: -float(r.get("cost_usd") or 0.0)
1086
+ )
1087
+ # Basename-collision disambiguation: mirrors `_render_project_table`'s
1088
+ # terminal logic. Computed on cost_sorted_rows; mapped back to row
1089
+ # identity so the caller-ordered table picks up the same augmented
1090
+ # label as the chart (anonymization is row-identity based, not
1091
+ # position based). Without disambiguation, two `app` projects under
1092
+ # different parent dirs collapse to ONE anonymous `project-N` after
1093
+ # scrub — losing both privacy uniqueness and chart rank meaning.
1094
+ augmented_by_index = _project_disambiguate_labels(cost_sorted_rows)
1095
+ augmented_by_row_id: dict[int, str] = {
1096
+ id(cost_sorted_rows[idx]): label
1097
+ for idx, label in augmented_by_index.items()
1098
+ }
1099
+
1100
+ def _proj_label_for(r: dict) -> str:
1101
+ bare = getattr(r.get("key"), "display_key", None) or "(unknown)"
1102
+ return augmented_by_row_id.get(id(r), bare)
1103
+
1104
+ # Table rows in CALLER order (--sort / --order parity).
1105
+ snap_rows: list = []
1106
+ for r in rows:
1107
+ proj_label = _proj_label_for(r)
1108
+ cost = float(r.get("cost_usd") or 0.0)
1109
+ attr_pct = r.get("attributed_pct")
1110
+ sessions = r.get("sessions")
1111
+ sessions_count = len(sessions) if sessions is not None else 0
1112
+ snap_rows.append(_lib_share.Row(cells={
1113
+ "project": _lib_share.ProjectCell(proj_label),
1114
+ "cost": _lib_share.MoneyCell(cost),
1115
+ # Preserve None vs 0.0 — terminal renders missing as em-dash.
1116
+ # Coercing None -> 0.0 would conflate "no usage snapshot for
1117
+ # any week this project touched" with "0% attributed."
1118
+ "used": (
1119
+ _lib_share.PercentCell(float(attr_pct))
1120
+ if attr_pct is not None else _lib_share.TextCell("—")
1121
+ ),
1122
+ "sessions": _lib_share.TextCell(str(sessions_count)),
1123
+ }))
1124
+
1125
+ # Chart points in COST-SORTED order (HBar shows top-N by cost).
1126
+ chart_pts: list = []
1127
+ for r in cost_sorted_rows:
1128
+ proj_label = _proj_label_for(r)
1129
+ cost = float(r.get("cost_usd") or 0.0)
1130
+ chart_pts.append(_lib_share.ChartPoint(
1131
+ x_label=proj_label,
1132
+ x_value=cost,
1133
+ y_value=cost,
1134
+ project_label=proj_label,
1135
+ ))
1136
+ chart = (
1137
+ _lib_share.HorizontalBarChart(
1138
+ points=tuple(chart_pts), x_label="$", cap=12,
1139
+ )
1140
+ if chart_pts else None
1141
+ )
1142
+ notes: tuple[str, ...] = ()
1143
+ if chart is not None and len(chart_pts) > 12:
1144
+ notes = (
1145
+ f"Showing top 12 in chart; table includes all {len(chart_pts)}.",
1146
+ )
1147
+ sum_cost = sum(p.y_value for p in chart_pts)
1148
+ totals = (
1149
+ _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
1150
+ _lib_share.Totalled(label="Projects", value=str(len(chart_pts))),
1151
+ )
1152
+ if rows:
1153
+ title = (
1154
+ f"Per-project usage — {period_start.strftime('%b %d')} → "
1155
+ f"{period_end.strftime('%b %d')}"
1156
+ )
1157
+ else:
1158
+ title = "Per-project usage — no data"
1159
+ period_label = _share_period_label(period_start, period_end, display_tz)
1160
+ subtitle = " · ".join([
1161
+ period_label,
1162
+ theme,
1163
+ "real projects" if reveal_projects else "projects anonymized",
1164
+ ])
1165
+ return _lib_share.ShareSnapshot(
1166
+ cmd="project",
1167
+ title=title,
1168
+ subtitle=subtitle,
1169
+ period=_lib_share.PeriodSpec(
1170
+ start=period_start, end=period_end,
1171
+ display_tz=display_tz, label=period_label,
1172
+ ),
1173
+ columns=columns, rows=tuple(snap_rows),
1174
+ chart=chart, totals=totals, notes=notes,
1175
+ generated_at=_share_now_utc(), version=version,
1176
+ )
1177
+
1178
+
1179
+ def _build_five_hour_blocks_snapshot(
1180
+ view: "BlocksView",
1181
+ *,
1182
+ period_start: dt.datetime,
1183
+ period_end: dt.datetime,
1184
+ display_tz: str,
1185
+ version: str,
1186
+ theme: str,
1187
+ reveal_projects: bool,
1188
+ tz: "ZoneInfo | None",
1189
+ ) -> "ShareSnapshot":
1190
+ """Build a ShareSnapshot for `cctally five-hour-blocks`.
1191
+
1192
+ `view` is the ``BlocksView`` produced by
1193
+ ``build_blocks_view_from_table_rows`` (issue #56). The
1194
+ API-anchored block dicts (sqlite Row → dict with the
1195
+ ``__is_active`` / ``__credits`` side-channels attached) live on
1196
+ ``view.aggregated``; reset-aware totals come from
1197
+ ``view.total_cost_usd`` so the share footer reads from the typed
1198
+ single source rather than re-summing inline. Schema fields used
1199
+ from each dict: ``block_start_at`` (ISO timestamp),
1200
+ ``total_cost_usd``, ``final_five_hour_percent``,
1201
+ ``crossed_seven_day_reset`` (0/1 int),
1202
+ ``seven_day_pct_at_block_start``, ``seven_day_pct_at_block_end``,
1203
+ plus the synthetic ``__is_active`` flag.
1204
+
1205
+ Deviations from the plan sketch (which assumed dict rows with keys
1206
+ `block_start` / `cost_usd` / `used_pct_5h` / `top_model` /
1207
+ `cross_reset`):
1208
+
1209
+ - Rows are sqlite-Row-derived dicts with snake_case schema column
1210
+ names — `block_start_at`, `total_cost_usd`,
1211
+ `final_five_hour_percent`, `crossed_seven_day_reset`. The plan
1212
+ keys `block_start` / `cost_usd` / `used_pct_5h` / `cross_reset`
1213
+ do not exist on the actual data shape.
1214
+ - `top_model` does not live on the `five_hour_blocks` row at all;
1215
+ `_load_breakdown` would have to be invoked per-block to derive
1216
+ it. Per share-spec convention (matches cmd_daily / cmd_monthly),
1217
+ the `--breakdown` flag is a no-op under `--format` and the
1218
+ headline snapshot omits the per-model "top model" column.
1219
+ - `crossed_seven_day_reset` is an INTEGER 0/1 (sqlite); coerce to
1220
+ `bool` for cell formatting.
1221
+
1222
+ Cross-reset markers (spec §6.5):
1223
+ - `chart_pts` — `▲` (U+25B2) prefix in `x_label` so the SVG
1224
+ x-axis label visually flags the crossed-reset blocks.
1225
+ - `snap_rows` — `⚡` (U+26A1) glyph in the `cross_reset` cell
1226
+ text so the markdown / HTML table cell carries the same
1227
+ signal. The two glyphs are distinct (triangle for chart axis,
1228
+ bolt for table cell) so the legend reads correctly in either
1229
+ surface.
1230
+
1231
+ `theme` and `reveal_projects` flow into the subtitle directly so
1232
+ the builder owns the canonical subtitle shape — no post-build
1233
+ re-stamp at the gate site.
1234
+
1235
+ Caller MUST pass a view whose ``aggregated`` block dicts are
1236
+ already in the desired chronological order (cmd_five_hour_blocks
1237
+ pulls newest-first; we reverse here so the BarChart bars line up
1238
+ oldest→newest left-to-right). Tabular row order in the snapshot is
1239
+ irrelevant because the snapshot is what gets rendered (the gate
1240
+ site short-circuits the table renderer).
1241
+ """
1242
+ _lib_share = _share_load_lib()
1243
+ columns = (
1244
+ _lib_share.ColumnSpec(key="block_start", label="Block Start",
1245
+ align="left"),
1246
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
1247
+ emphasis=True),
1248
+ _lib_share.ColumnSpec(key="used_pct", label="5h %",
1249
+ align="right"),
1250
+ _lib_share.ColumnSpec(key="cross_reset", label="Reset",
1251
+ align="left"),
1252
+ )
1253
+ # `view.aggregated` carries the newest-first DESC block dicts the
1254
+ # caller built from the SELECT. Reverse so BarChart x-axis runs
1255
+ # oldest→newest; table-row order tracks chart order so consumer
1256
+ # expectations align.
1257
+ rows = list(view.aggregated)
1258
+ chrono_rows = list(reversed(rows))
1259
+ snap_rows: list = []
1260
+ chart_pts: list = []
1261
+ for i, r in enumerate(chrono_rows):
1262
+ block_iso = r.get("block_start_at") or ""
1263
+ # Compact label respecting --tz; previously hard-coded to UTC
1264
+ # (parsed.strftime renders the wall-clock IN the parsed tz, and
1265
+ # `parsed` is tz-aware UTC after fromisoformat). UTC-vs-display_tz
1266
+ # is orthogonal to the SVG x-axis width budget — both render at
1267
+ # the same character count. Route through `format_display_dt`
1268
+ # with suffix=False to satisfy the chokepoint rule while keeping
1269
+ # the bar label compact (the subtitle's period_label already
1270
+ # carries the active tz).
1271
+ try:
1272
+ parsed = dt.datetime.fromisoformat(
1273
+ block_iso.replace("Z", "+00:00")
1274
+ )
1275
+ block_lbl = format_display_dt(
1276
+ parsed, tz, fmt="%b %d %H:%M", suffix=False,
1277
+ )
1278
+ except (ValueError, AttributeError):
1279
+ block_lbl = str(block_iso)
1280
+ cost_usd = float(r.get("total_cost_usd") or 0.0)
1281
+ used_pct = float(r.get("final_five_hour_percent") or 0.0)
1282
+ crossed = bool(r.get("crossed_seven_day_reset"))
1283
+ cell_text = "⚡" if crossed else "—"
1284
+ # Spec §5.1.1 (Codex r2 finding 3): consume the ``__credits``
1285
+ # side-channel set by ``cmd_five_hour_blocks`` and append a
1286
+ # ``⚡ -Xpp, -Ypp`` chip to the block_start cell. Pure-string
1287
+ # cell content flows uniformly through markdown / HTML table /
1288
+ # SVG text renderers without per-format additions. Symmetric to
1289
+ # the existing ⚡ glyph in the cross_reset cell — by position
1290
+ # (block_start suffix vs. dedicated column) the two annotations
1291
+ # remain visually distinguishable.
1292
+ credits = r.get("__credits") or []
1293
+ block_cell = block_lbl
1294
+ if credits:
1295
+ deltas = ", ".join(f"{c['deltaPp']:+.0f}pp" for c in credits)
1296
+ block_cell = f"{block_lbl} ⚡ {deltas}"
1297
+ snap_rows.append(_lib_share.Row(cells={
1298
+ "block_start": _lib_share.TextCell(block_cell),
1299
+ "cost": _lib_share.MoneyCell(cost_usd),
1300
+ "used_pct": _lib_share.PercentCell(used_pct),
1301
+ "cross_reset": _lib_share.TextCell(cell_text),
1302
+ }))
1303
+ x_label = f"▲ {block_lbl}" if crossed else block_lbl
1304
+ chart_pts.append(_lib_share.ChartPoint(
1305
+ x_label=x_label,
1306
+ x_value=float(i),
1307
+ y_value=cost_usd,
1308
+ ))
1309
+ chart = (
1310
+ _lib_share.BarChart(points=tuple(chart_pts), y_label="$")
1311
+ if chart_pts else None
1312
+ )
1313
+ # Reset-aware total comes from the BlocksView (issue #56); avg
1314
+ # divides by `chart_pts` count so the share footer "Sum" totalled
1315
+ # and the per-block `chart_pts` cost values share a single source-
1316
+ # of-truth at `view.total_cost_usd`.
1317
+ sum_cost = view.total_cost_usd
1318
+ avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
1319
+ crossed_count = sum(
1320
+ 1 for r in chrono_rows if bool(r.get("crossed_seven_day_reset"))
1321
+ )
1322
+ totals_list = [
1323
+ _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
1324
+ _lib_share.Totalled(label="Blocks", value=str(len(chart_pts))),
1325
+ _lib_share.Totalled(label="Avg / block", value=f"${avg_cost:,.2f}"),
1326
+ ]
1327
+ if crossed_count:
1328
+ totals_list.append(_lib_share.Totalled(
1329
+ label="Crossed reset", value=str(crossed_count),
1330
+ ))
1331
+ totals = tuple(totals_list)
1332
+ notes: tuple[str, ...] = ()
1333
+ if crossed_count:
1334
+ notes = (
1335
+ "▲ / ⚡ marks blocks that crossed the weekly reset boundary.",
1336
+ )
1337
+ if rows:
1338
+ title = f"5-hour blocks — last {len(rows)} blocks"
1339
+ else:
1340
+ title = "5-hour blocks — no data"
1341
+ period_label = _share_period_label(period_start, period_end, display_tz)
1342
+ subtitle = " · ".join([
1343
+ period_label,
1344
+ theme,
1345
+ "real projects" if reveal_projects else "projects anonymized",
1346
+ ])
1347
+ return _lib_share.ShareSnapshot(
1348
+ cmd="five-hour-blocks",
1349
+ title=title,
1350
+ subtitle=subtitle,
1351
+ period=_lib_share.PeriodSpec(
1352
+ start=period_start, end=period_end,
1353
+ display_tz=display_tz, label=period_label,
1354
+ ),
1355
+ columns=columns, rows=tuple(snap_rows),
1356
+ chart=chart, totals=totals, notes=notes,
1357
+ generated_at=_share_now_utc(), version=version,
1358
+ )
1359
+
1360
+
1361
+ def _session_disambiguate_labels(
1362
+ sessions: list["ClaudeSessionUsage"],
1363
+ ) -> dict[int, str]:
1364
+ """Return ``{session_index: disambiguated_label}`` for sessions whose
1365
+ bare ``project_path`` basename collides with another session's.
1366
+
1367
+ Session-specific sibling of ``_project_disambiguate_labels`` (which
1368
+ operates over project rollup rows whose `key` is a ``ProjectKey``).
1369
+ Sessions carry only a `project_path` string — we derive the
1370
+ basename, count collisions, and append a parent-dir suffix
1371
+ ``" (parent)"`` to colliding rows so the post-scrub anonymization
1372
+ still produces unique anonymous labels (otherwise two `app/`
1373
+ sessions under different parents collapse to a single
1374
+ ``project-N``, breaking both privacy uniqueness and the chart's
1375
+ visual rank meaning).
1376
+
1377
+ Sessions without collisions are absent from the returned dict;
1378
+ callers fall back to the bare basename.
1379
+ """
1380
+ basenames: list[str] = []
1381
+ for s in sessions:
1382
+ path = s.project_path or ""
1383
+ basenames.append(os.path.basename(path) or path or "(unknown)")
1384
+ counts: dict[str, int] = {}
1385
+ for bn in basenames:
1386
+ counts[bn] = counts.get(bn, 0) + 1
1387
+ augmented: dict[int, str] = {}
1388
+ for idx, s in enumerate(sessions):
1389
+ bn = basenames[idx]
1390
+ # Skip suffixing the literal "(unknown)" bare label even on
1391
+ # collision: `_build_anon_mapping` literal-passthrough-protects
1392
+ # exact "(unknown)" only — a suffixed form like "(unknown) (/)"
1393
+ # would be mapped to a regular `project-N` slot, losing the
1394
+ # (unknown) semantic in the anonymized output.
1395
+ if counts[bn] > 1 and bn != "(unknown)":
1396
+ path = s.project_path or ""
1397
+ parent = os.path.basename(os.path.dirname(path)) or "/"
1398
+ augmented[idx] = f"{bn} ({parent})"
1399
+ return augmented
1400
+
1401
+
1402
+ def _build_session_snapshot(
1403
+ view: "SessionsView",
1404
+ *,
1405
+ period_start: dt.datetime,
1406
+ period_end: dt.datetime,
1407
+ display_tz: str,
1408
+ version: str,
1409
+ theme: str,
1410
+ reveal_projects: bool,
1411
+ top_n: int | None,
1412
+ tz: "ZoneInfo | None",
1413
+ ) -> "ShareSnapshot":
1414
+ """Build a ShareSnapshot for `cctally session`.
1415
+
1416
+ Consumes the unified ``SessionsView`` (spec §6.5). ``view.aggregated``
1417
+ is the ``ClaudeSessionUsage`` tuple — the shape this builder needs
1418
+ for ``source_paths`` / ``model_breakdowns`` / ``last_activity``
1419
+ (fields ``view.rows`` / ``TuiSessionRow`` doesn't carry). The
1420
+ in-memory shape is unchanged at the read boundary — only the
1421
+ parameter container differs.
1422
+
1423
+ Each ``ClaudeSessionUsage`` has: ``session_id`` (UUID),
1424
+ ``project_path`` (filesystem path), ``cost_usd``,
1425
+ ``last_activity`` (``dt.datetime``), ``models`` (first-seen-order
1426
+ ``list[str]``), and the token aggregates.
1427
+
1428
+ Privacy invariant (Section 8.4 / Section 5.3): the builder populates
1429
+ `ProjectCell.label`, `ChartPoint.project_label`, and
1430
+ `ChartPoint.x_label` with the REAL `project_path` basename. The
1431
+ `_share_render_and_emit` wrapper runs `_lib_share._scrub` BEFORE
1432
+ rendering — that's the single chokepoint that rewrites every
1433
+ project label to `project-1` / `project-2` / ... unless
1434
+ `--reveal-projects` is passed.
1435
+
1436
+ Deviations from the plan sketch (which assumed dict rows with keys
1437
+ `session_id` / `started_at` / `project_path` / `cost_usd` /
1438
+ `models`):
1439
+
1440
+ - Sessions are `ClaudeSessionUsage` dataclasses; we read fields by
1441
+ attribute. `last_activity` is the canonical timestamp (no
1442
+ `started_at` field — sessions span a window via
1443
+ `first_activity` → `last_activity`).
1444
+ - The `project_path` column's basename can collide across two
1445
+ different parent dirs. We use the session-specific
1446
+ `_session_disambiguate_labels` helper (sibling of
1447
+ `_project_disambiguate_labels`, which expects `ProjectKey` rows
1448
+ not present on session data) to suffix `" (parent)"` on
1449
+ collisions before the scrubber runs.
1450
+
1451
+ Caller MUST pass ``view`` whose ``aggregated`` tuple is already
1452
+ sorted in the desired order (``cmd_session`` keeps the
1453
+ aggregator's descending-by-last_activity sort); the builder
1454
+ re-sorts internally by descending cost so the chart's HBar bars
1455
+ rank consistently with the anonymization-mapping
1456
+ (``_build_anon_mapping`` also sorts by descending cost) — keeping
1457
+ ``project-1`` aligned with the highest-cost bar in the chart even
1458
+ when the user asked for ``--order asc``.
1459
+
1460
+ `top_n`, when set (must be `>= 1`; caller validates), truncates
1461
+ BOTH the table rows and the chart points to the top-N by cost.
1462
+ The title shifts to `"Top N sessions"` whenever `top_n` actually
1463
+ truncated (so users know rows were dropped). When more rows exist
1464
+ than the chart cap (15) but `top_n` is None or `>= len(sessions)`,
1465
+ the table includes all rows while the chart shows the top 15 by
1466
+ cost (a note clarifies).
1467
+
1468
+ `theme` and `reveal_projects` flow into the subtitle directly so
1469
+ the builder owns the canonical subtitle shape — no post-build
1470
+ re-stamp at the gate site.
1471
+ """
1472
+ _lib_share = _share_load_lib()
1473
+ columns = (
1474
+ _lib_share.ColumnSpec(key="session", label="Session", align="left"),
1475
+ _lib_share.ColumnSpec(key="project", label="Project", align="left"),
1476
+ _lib_share.ColumnSpec(key="cost", label="$ Cost", align="right",
1477
+ emphasis=True),
1478
+ _lib_share.ColumnSpec(key="last_activity", label="Last Activity",
1479
+ align="left"),
1480
+ _lib_share.ColumnSpec(key="models", label="Models", align="left"),
1481
+ )
1482
+ # Sort by descending cost so the snapshot's chart-order matches the
1483
+ # `_build_anon_mapping` sort key (also descending cost).
1484
+ sorted_sessions = sorted(
1485
+ view.aggregated,
1486
+ key=lambda s: -float(getattr(s, "cost_usd", 0.0) or 0.0),
1487
+ )
1488
+ # Apply --top-n truncation (caller validated >= 1). Truncation status
1489
+ # gates the title shape below.
1490
+ truncated = (
1491
+ top_n is not None and top_n < len(sorted_sessions)
1492
+ )
1493
+ if top_n is not None:
1494
+ sorted_sessions = sorted_sessions[:top_n]
1495
+ # Basename-collision disambiguation: session-specific sibling of
1496
+ # `_project_disambiguate_labels`. Without this, two `app/` sessions
1497
+ # under different parents collapse to a single `project-N` after
1498
+ # scrub — losing both privacy uniqueness and chart rank meaning.
1499
+ augmented = _session_disambiguate_labels(sorted_sessions)
1500
+ snap_rows: list = []
1501
+ chart_pts: list = []
1502
+ for idx, s in enumerate(sorted_sessions):
1503
+ bare_label = (
1504
+ os.path.basename(s.project_path or "")
1505
+ or s.project_path
1506
+ or "(unknown)"
1507
+ )
1508
+ proj_label = augmented.get(idx, bare_label)
1509
+ cost_usd = float(getattr(s, "cost_usd", 0.0) or 0.0)
1510
+ sid_short = (s.session_id[:8] if s.session_id else "—") or "—"
1511
+ # Datetime chokepoint rule: route human-displayed timestamps
1512
+ # through `format_display_dt` so `--tz` is honored (was
1513
+ # `.astimezone()` which used host-local regardless of `--tz`).
1514
+ # `suffix=False` keeps the cell width tight — the subtitle's
1515
+ # period_label already carries the active tz.
1516
+ last_str = format_display_dt(
1517
+ s.last_activity, tz, fmt="%Y-%m-%d %H:%M", suffix=False,
1518
+ )
1519
+ models_text = ", ".join(s.models) if s.models else "—"
1520
+ snap_rows.append(_lib_share.Row(cells={
1521
+ "session": _lib_share.TextCell(sid_short),
1522
+ "project": _lib_share.ProjectCell(proj_label),
1523
+ "cost": _lib_share.MoneyCell(cost_usd),
1524
+ "last_activity": _lib_share.TextCell(last_str),
1525
+ "models": _lib_share.TextCell(models_text),
1526
+ }))
1527
+ chart_pts.append(_lib_share.ChartPoint(
1528
+ x_label=proj_label,
1529
+ x_value=cost_usd,
1530
+ y_value=cost_usd,
1531
+ project_label=proj_label,
1532
+ ))
1533
+ chart = (
1534
+ _lib_share.HorizontalBarChart(
1535
+ points=tuple(chart_pts), x_label="$", cap=15,
1536
+ )
1537
+ if chart_pts else None
1538
+ )
1539
+ notes: tuple[str, ...] = ()
1540
+ if chart is not None and len(chart_pts) > 15:
1541
+ notes = (
1542
+ f"Showing top 15 in chart; table includes all {len(chart_pts)}.",
1543
+ )
1544
+ sum_cost = sum(p.y_value for p in chart_pts)
1545
+ totals = (
1546
+ _lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
1547
+ _lib_share.Totalled(label="Sessions", value=str(len(chart_pts))),
1548
+ )
1549
+ if sorted_sessions:
1550
+ if truncated:
1551
+ title = f"Top {len(snap_rows)} sessions"
1552
+ else:
1553
+ title = (
1554
+ f"Sessions — {period_start.strftime('%b %d')} → "
1555
+ f"{period_end.strftime('%b %d')}"
1556
+ )
1557
+ else:
1558
+ title = "Sessions — no data"
1559
+ period_label = _share_period_label(period_start, period_end, display_tz)
1560
+ subtitle = " · ".join([
1561
+ period_label,
1562
+ theme,
1563
+ "real projects" if reveal_projects else "projects anonymized",
1564
+ ])
1565
+ return _lib_share.ShareSnapshot(
1566
+ cmd="session",
1567
+ title=title,
1568
+ subtitle=subtitle,
1569
+ period=_lib_share.PeriodSpec(
1570
+ start=period_start, end=period_end,
1571
+ display_tz=display_tz, label=period_label,
1572
+ ),
1573
+ columns=columns, rows=tuple(snap_rows),
1574
+ chart=chart, totals=totals, notes=notes,
1575
+ generated_at=_share_now_utc(), version=version,
1576
+ )
1577
+
1578
+
1579
+ # ---- v2 share panel_data builders (spec §5.2, plan M1.6) -------------
1580
+ #
1581
+ # These translate the live dashboard `DataSnapshot` into the dict shapes
1582
+ # the M1.4 Recap builders (in `bin/_lib_share_templates.py`) consume.
1583
+ # They're a thin extract step — the DataSnapshot was already built by
1584
+ # the sync thread, so this path doesn't re-query the DB on the share
1585
+ # hot path.
1586
+ #
1587
+ # Per-panel shape contracts live in each Recap builder's docstring in
1588
+ # `bin/_lib_share_templates.py` (see `_build_<panel>_recap`); the keys
1589
+ # below MUST stay in lockstep with those docstrings — the
1590
+ # producer/consumer contract.
1591
+ #
1592
+ # When the snapshot has no data for a given panel (fresh install, no
1593
+ # sync yet), the builder returns a minimal empty-shaped dict that the
1594
+ # downstream Recap builder renders as a "no data" snapshot (kernel
1595
+ # handles empty `weeks=[]` / `days=[]` / etc.).
1596
+
1597
+
1598
+ def _share_iso(value) -> "str | None":
1599
+ """Coerce a datetime / ISO-string into an ISO-8601 string with `Z` suffix.
1600
+
1601
+ DataSnapshot mixes attribute types (`week_start_at` is a
1602
+ `dt.datetime`; `WeeklyPeriodRow.week_start_at` is already a string).
1603
+ Recap builders' `_parse_iso_utc` accepts both shapes via fromisoformat
1604
+ + `Z`-swap, but normalizing here keeps the wire format consistent.
1605
+ """
1606
+ if value is None:
1607
+ return None
1608
+ if isinstance(value, dt.datetime):
1609
+ v = value if value.tzinfo else value.replace(tzinfo=dt.timezone.utc)
1610
+ return v.astimezone(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1611
+ return str(value)
1612
+
1613
+
1614
+ # ---- Period override (spec §6.2 Q4 + Codex P2 on PR #35) ----
1615
+ #
1616
+ # The share modal's Period control offers three kinds — current, previous,
1617
+ # custom — but the original render path consumed the dashboard's cached
1618
+ # DataSnapshot directly, which only ever holds "current" data. Override
1619
+ # semantics by panel:
1620
+ #
1621
+ # panel current previous custom (start/end)
1622
+ # -------- ------------------ -------------------- -------------------
1623
+ # weekly this subscription one week earlier week containing end
1624
+ # week
1625
+ # daily last 7 display-tz 7 days earlier 7 days ending at end
1626
+ # days ending today
1627
+ # monthly last 12 months 12 months earlier 12 months ending at end
1628
+ # ending now
1629
+ # trend last 8 weeks 8 weeks earlier 8 weeks ending at end
1630
+ # ending now
1631
+ # blocks recent 5h blocks blocks ending one blocks ending at end
1632
+ # 5h-window earlier
1633
+ # forecast future projection (rejected: previous
1634
+ # from now forecast doesn't exist)
1635
+ # current-week this subscription (rejected: panel IS current)
1636
+ # week
1637
+ # sessions recent sessions (deferred: ambiguous semantics — could
1638
+ # mean "older sessions" or "sessions in
1639
+ # date range"; revisit when use case clear)
1640
+ #
1641
+ # Override mechanics: derive a `now_utc` from the period option and
1642
+ # re-build only the relevant DataSnapshot field by calling the same
1643
+ # `_dashboard_build_*` function the sync thread uses, just with a
1644
+ # shifted `now_utc`. `dataclasses.replace` returns a new DataSnapshot
1645
+ # with that field swapped; everything downstream (panel_data builder,
1646
+ # template builder, kernel render) consumes it unchanged.
1647
+ #
1648
+ # Validation failures land on the request as HTTP 400 with
1649
+ # `field: "options.period.<key>"` so the UI can highlight the offending
1650
+ # control.
1651
+
1652
+ def _share_render_and_emit(snap, args) -> None:
1653
+ """End-to-end: scrub -> render -> emit -> optional open.
1654
+
1655
+ Lazy-imports `_lib_share` so non-share invocations don't pay the import
1656
+ cost. The kernel module stays I/O-pure; this wrapper does all the
1657
+ side-effecting glue (destination resolution, file writes, clipboard,
1658
+ post-write `--open` launch).
1659
+
1660
+ Caller contract: ``args.format`` MUST be set ("md", "html", or "svg").
1661
+ The wrapper raises ValueError if called without it — surfaces the
1662
+ contract failure at the chokepoint instead of producing junk filenames
1663
+ like ``cctally-daily-<date>.None``.
1664
+ """
1665
+ if args.format is None:
1666
+ raise ValueError("_share_render_and_emit called without args.format")
1667
+ if args.open_after_write and args.format == "md":
1668
+ # Spec Section 4.4: --open is only meaningful for html/svg writes.
1669
+ # Reject explicitly with exit 2 instead of silently no-opping (which
1670
+ # the prior implementation did because the open-after-write branch
1671
+ # gates on ``kind == "file"``, and md routes to stdout by default).
1672
+ print(
1673
+ "cctally: --open is only valid with --format html or --format svg",
1674
+ file=sys.stderr,
1675
+ )
1676
+ sys.exit(2)
1677
+ # Routed through `_share_load_lib` so wrapper / builders / test harness
1678
+ # share one cached module object — see helper docstring for the
1679
+ # class-identity invariant this enforces.
1680
+ _lib_share = _share_load_lib()
1681
+
1682
+ scrubbed = _lib_share._scrub(snap, reveal_projects=args.reveal_projects)
1683
+ rendered = _lib_share.render(
1684
+ scrubbed,
1685
+ format=args.format,
1686
+ theme=args.theme,
1687
+ branding=not args.no_branding,
1688
+ )
1689
+
1690
+ utc_date = snap.generated_at.astimezone(dt.timezone.utc).strftime("%Y-%m-%d")
1691
+ kind, value = _resolve_destination(args, cmd=snap.cmd, generated_at_utc_date=utc_date)
1692
+ _emit(rendered, kind=kind, value=value)
1693
+
1694
+ if args.open_after_write and kind == "file":
1695
+ _share_open_file(pathlib.Path(value))
1696
+
1697
+
1698
+ def _share_open_file(path: pathlib.Path) -> None:
1699
+ """Run `open` (macOS) / `xdg-open` (Linux). Silent fail if launcher missing."""
1700
+ for launcher in ("open", "xdg-open"):
1701
+ if shutil.which(launcher):
1702
+ subprocess.Popen(
1703
+ [launcher, str(path)],
1704
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
1705
+ )
1706
+ return
1707
+ sys.stderr.write("cctally: --open requires `open` or `xdg-open` on PATH; skipped\n")