cctally 1.8.1 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -242,10 +242,22 @@ def _setup_local_bin_dir() -> pathlib.Path:
242
242
  @dataclasses.dataclass
243
243
  class _SetupSymlinkResult:
244
244
  name: str
245
- status: str # "created" | "already" | "replaced" | "failed"
245
+ status: str # "created" | "already" | "replaced" | "failed" | "removed-stale"
246
246
  detail: str = ""
247
247
 
248
248
 
249
+ # Symlink names cctally USED to install but no longer does. We keep
250
+ # cleaning them up for one major-version's worth of upgraders so
251
+ # `~/.local/bin/` doesn't accumulate dangling symlinks pointing at
252
+ # scripts the current cctally checkout doesn't ship. Removal is
253
+ # conservative: we only unlink symlinks whose `readlink()` target's
254
+ # basename matches the stale name — a user's hand-rolled symlink with
255
+ # the same name pointing elsewhere is left alone.
256
+ _SETUP_STALE_SYMLINK_NAMES = (
257
+ "cctally-release", # Removed in v1.9.0; release tooling went private.
258
+ )
259
+
260
+
249
261
  def _setup_resolve_symlink_source(repo_root: pathlib.Path, name: str) -> pathlib.Path:
250
262
  """Resolve the symlink target for a given PATH-name.
251
263
 
@@ -326,6 +338,148 @@ def _setup_create_symlinks(
326
338
  return results
327
339
 
328
340
 
341
+ def _setup_cleanup_stale_symlinks(
342
+ dst_dir: pathlib.Path,
343
+ ) -> list[_SetupSymlinkResult]:
344
+ """Unlink stale `cctally-*` symlinks left over from prior versions.
345
+
346
+ For each entry in :data:`_SETUP_STALE_SYMLINK_NAMES`, removes the
347
+ matching symlink in ``dst_dir`` when its readlink target basename
348
+ matches the stale name AND the target points at a "retired" cctally
349
+ install — meaning either (a) the target is *dangling* (no longer
350
+ resolves to a real file, e.g. an old checkout that was deleted) OR
351
+ (b) the target lives under a *foreign* cctally install root
352
+ (Homebrew keg, npm ``node_modules/cctally/``), i.e. an install root
353
+ other than the one currently running ``cctally setup``.
354
+
355
+ The foreign-root clause is what handles the common upgrade path:
356
+ after ``brew upgrade cctally`` or ``npm i -g cctally@<new>``, the
357
+ *prior* keg / module dir often lingers on disk until ``brew
358
+ cleanup`` (or the next npm install GC), so a legacy
359
+ ``~/.local/bin/cctally-release`` symlink from a pre-v1.9.0 ``cctally
360
+ setup`` still resolves to an existing file under the *old* install
361
+ root. The dangling-only predicate would skip it, leaving the retired
362
+ command on PATH; the foreign-root check retires it instead.
363
+
364
+ Both clauses still preserve the maintainer's intentional manual
365
+ link to the *current* checkout's still-shipped retired tooling
366
+ (e.g. ``~/.local/bin/cctally-release ->
367
+ <cctally-dev>/bin/cctally-release``) — that target is neither
368
+ dangling nor foreign, so it's left alone. Likewise a hand-rolled
369
+ link pointing somewhere unrelated (``~/scripts/...``) survives.
370
+
371
+ Returns a list of :class:`_SetupSymlinkResult` entries for the
372
+ actions taken (so callers can fold them into install output).
373
+ """
374
+ results: list[_SetupSymlinkResult] = []
375
+ repo_root = _setup_resolve_repo_root()
376
+ for name in _SETUP_STALE_SYMLINK_NAMES:
377
+ dst = dst_dir / name
378
+ if not _setup_symlink_is_retired(dst, name, repo_root):
379
+ continue
380
+ try:
381
+ dst.unlink()
382
+ results.append(
383
+ _SetupSymlinkResult(name, "removed-stale", "stale (from prior version)")
384
+ )
385
+ except OSError as exc:
386
+ results.append(
387
+ _SetupSymlinkResult(name, "failed", f"unlink failed: {exc}")
388
+ )
389
+ return results
390
+
391
+
392
+ # Path tokens that identify a "foreign" cctally install root — i.e. a
393
+ # directory tree managed by a different distribution channel (or a
394
+ # different version of the same channel) than the one currently running
395
+ # ``cctally setup``. A symlink whose target sits under one of these is
396
+ # almost certainly a legacy auto-installed link from a prior version
397
+ # whose install root the current run does NOT manage. Pre-resolved (no
398
+ # trailing slash) so substring matching works on either UNIX or
399
+ # resolved forms.
400
+ _SETUP_FOREIGN_INSTALL_ROOT_TOKENS = (
401
+ # Homebrew keeps every installed version under
402
+ # ``<prefix>/Cellar/cctally/<version>/``. A target inside any of
403
+ # these directories points at a brew install — either the current
404
+ # one (when ``cctally setup`` runs from a brew install — possible
405
+ # but rare) or an older keg still on disk pending ``brew cleanup``.
406
+ "/Cellar/cctally/",
407
+ # npm globals land at ``<prefix>/lib/node_modules/cctally/``; npm
408
+ # locals at ``<project>/node_modules/cctally/``. Either way the
409
+ # ``/node_modules/cctally/`` segment is the discriminator. pnpm and
410
+ # yarn variants keep the segment, so this catches them too.
411
+ "/node_modules/cctally/",
412
+ )
413
+
414
+
415
+ def _setup_symlink_is_retired(
416
+ dst: pathlib.Path, name: str, repo_root: pathlib.Path,
417
+ ) -> bool:
418
+ """Detection predicate for retired auto-symlinks at install time.
419
+
420
+ Returns True iff ``dst`` is a symlink, its readlink target basename
421
+ matches ``name``, AND the target points at a "retired" cctally
422
+ install — i.e. one the current ``cctally setup`` run does not
423
+ manage. Two retirement classes:
424
+
425
+ 1. **Dangling target** — readlink target does not resolve to an
426
+ existing filesystem entry. Covers the deleted-checkout case.
427
+ 2. **Foreign install root** — target path contains one of
428
+ :data:`_SETUP_FOREIGN_INSTALL_ROOT_TOKENS` (``/Cellar/cctally/``
429
+ or ``/node_modules/cctally/``) AND that token does NOT also
430
+ appear in the current ``repo_root``. The second clause is what
431
+ keeps the (rare) case of running ``cctally setup`` *from* an
432
+ npm/brew install correctly — a symlink at the same install root
433
+ is NOT foreign, so it's left to the active-symlinks loop in
434
+ :func:`_setup_install` / :func:`_setup_uninstall`.
435
+
436
+ Targets that exist but live outside any recognized install root
437
+ (e.g. a maintainer's manual link to ``<checkout>/bin/cctally-release``
438
+ or a hand-rolled link at ``~/scripts/cctally-release``) are
439
+ preserved — they're either explicit operator setups or genuine
440
+ user-managed scripts that happen to share the name.
441
+
442
+ Shared by the cleanup site (``_setup_cleanup_stale_symlinks``) and
443
+ the read-only detection site (``_setup_detect_stale_symlinks``) so
444
+ ``--status`` and ``setup`` agree on what they call "stale".
445
+ """
446
+ if not dst.is_symlink():
447
+ return False
448
+ try:
449
+ target = os.readlink(dst)
450
+ except OSError:
451
+ return False
452
+ if pathlib.Path(target).name != name:
453
+ # User-managed symlink that happens to share the name; leave alone.
454
+ return False
455
+ # Resolve target relative to the symlink's parent so relative
456
+ # readlinks (rare for cctally setup-installed links, but possible
457
+ # for hand-rolled ones) classify correctly. Use lexists()-style
458
+ # check via Path.exists() — broken links return False, which is
459
+ # the "dangling, treat as stale" branch we want.
460
+ target_path = pathlib.Path(target)
461
+ if not target_path.is_absolute():
462
+ target_path = dst.parent / target_path
463
+ try:
464
+ target_exists = target_path.exists()
465
+ except OSError:
466
+ # Permission errors etc. — be conservative and don't remove.
467
+ return False
468
+ if not target_exists:
469
+ return True
470
+ # Target exists — retire only if it lives under a *foreign* install
471
+ # root (a different brew keg / npm module dir than the one running
472
+ # this setup). Compare against ``repo_root`` so a setup running
473
+ # *from* an npm/brew install doesn't classify its own siblings as
474
+ # foreign — that's the active-symlinks loop's job.
475
+ target_str = str(target_path)
476
+ repo_root_str = str(repo_root)
477
+ for token in _SETUP_FOREIGN_INSTALL_ROOT_TOKENS:
478
+ if token in target_str and token not in repo_root_str:
479
+ return True
480
+ return False
481
+
482
+
329
483
  def _setup_path_includes_local_bin() -> bool:
330
484
  local_bin = str(_setup_local_bin_dir())
331
485
  return local_bin in os.environ.get("PATH", "").split(os.pathsep)
@@ -816,12 +970,38 @@ def _setup_compute_symlink_state(
816
970
  return out
817
971
 
818
972
 
973
+ def _setup_detect_stale_symlinks(dst_dir: pathlib.Path) -> list[str]:
974
+ """Return names of stale-but-still-present cctally symlinks in ``dst_dir``.
975
+
976
+ Mirrors :func:`_setup_cleanup_stale_symlinks` detection (without
977
+ removing): a symlink is "stale" when its name appears in
978
+ :data:`_SETUP_STALE_SYMLINK_NAMES`, its readlink target basename
979
+ matches that name, AND the target is "retired" — either dangling
980
+ OR pointing at a foreign cctally install root (see
981
+ :func:`_setup_symlink_is_retired`). Routing through the same
982
+ predicate keeps ``--status`` and ``setup`` in lockstep so a
983
+ manually-maintained link to a still-shipped retired tool
984
+ (``~/.local/bin/cctally-release -> <checkout>/bin/cctally-release``)
985
+ is neither reported as stale nor removed, while a legacy link left
986
+ over from a pre-v1.9.0 brew/npm install (target still resolves into
987
+ the old keg / ``node_modules`` tree) IS retired.
988
+ """
989
+ found: list[str] = []
990
+ repo_root = _setup_resolve_repo_root()
991
+ for name in _SETUP_STALE_SYMLINK_NAMES:
992
+ dst = dst_dir / name
993
+ if _setup_symlink_is_retired(dst, name, repo_root):
994
+ found.append(name)
995
+ return found
996
+
997
+
819
998
  def _setup_status(args: argparse.Namespace) -> int:
820
999
  c = _cctally()
821
1000
  repo_root = _setup_resolve_repo_root()
822
1001
  dst_dir = _setup_local_bin_dir()
823
1002
  sym_state = _setup_compute_symlink_state(repo_root, dst_dir)
824
1003
  sym_ok = sum(1 for _, s in sym_state if s == "ok")
1004
+ stale_syms = _setup_detect_stale_symlinks(dst_dir)
825
1005
  on_path = _setup_path_includes_local_bin()
826
1006
  try:
827
1007
  settings = c._load_claude_settings()
@@ -842,6 +1022,7 @@ def _setup_status(args: argparse.Namespace) -> int:
842
1022
  "install": {
843
1023
  "symlinks_present": sym_ok,
844
1024
  "symlinks_total": len(c.SETUP_SYMLINK_NAMES),
1025
+ "symlinks_stale": stale_syms,
845
1026
  "path_includes": on_path,
846
1027
  },
847
1028
  "hooks": {ev: hook_counts[ev] for ev in c.SETUP_HOOK_EVENTS},
@@ -869,6 +1050,11 @@ def _setup_status(args: argparse.Namespace) -> int:
869
1050
  out.append("Install")
870
1051
  sym_marker = "✓" if sym_ok == len(c.SETUP_SYMLINK_NAMES) else "✗"
871
1052
  out.append(f" Symlinks {sym_ok}/{len(c.SETUP_SYMLINK_NAMES)} present at {dst_dir}/ {sym_marker}")
1053
+ if stale_syms:
1054
+ out.append(
1055
+ f" Stale symlinks {len(stale_syms)} from prior version: {', '.join(stale_syms)} ⚠"
1056
+ )
1057
+ out.append(" run `cctally setup` to remove")
872
1058
  out.append(f" PATH includes {'yes' if on_path else 'no'} "
873
1059
  f"{'✓' if on_path else '⚠'}")
874
1060
  out.append(f"Hooks ({c.CLAUDE_SETTINGS_PATH})")
@@ -950,6 +1136,50 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
950
1136
  sym_removed += 1
951
1137
  except OSError as exc:
952
1138
  eprint(f"setup: failed to remove {dst}: {exc}")
1139
+ # Also clean up legacy symlinks that older cctally versions used to
1140
+ # install but the current version no longer manages (see
1141
+ # :data:`_SETUP_STALE_SYMLINK_NAMES`). Without this loop, an
1142
+ # upgrader who ran ``cctally setup`` on a prior version and now runs
1143
+ # ``cctally setup --uninstall`` would keep e.g.
1144
+ # ``~/.local/bin/cctally-release`` on PATH.
1145
+ #
1146
+ # Two removal predicates compose here:
1147
+ # 1. ``target == _setup_resolve_symlink_source(repo_root, name)``
1148
+ # — the legacy auto-installed link points at *this* install
1149
+ # root's binary (the common case for git-checkout upgraders
1150
+ # who ran ``cctally setup`` on a prior version, then ``git
1151
+ # pull``ed; symmetric with how the active-symlink loop above
1152
+ # treats user-pointed `cctally` itself).
1153
+ # 2. ``_setup_symlink_is_retired`` — target is dangling OR lives
1154
+ # under a *foreign* cctally install root (old brew keg /
1155
+ # ``node_modules/cctally/`` left behind by ``brew upgrade``
1156
+ # or ``npm i -g cctally@<new>``). Without this, the common
1157
+ # brew/npm upgrade-then-uninstall path leaves the legacy
1158
+ # symlink in place because the prior keg's binary still
1159
+ # exists on disk (``target != expected``).
1160
+ #
1161
+ # A maintainer's hand-rolled link pointing somewhere unrelated
1162
+ # (``~/scripts/...``) is preserved by both predicates.
1163
+ for name in _SETUP_STALE_SYMLINK_NAMES:
1164
+ dst = dst_dir / name
1165
+ if not dst.is_symlink():
1166
+ continue
1167
+ try:
1168
+ target = pathlib.Path(os.readlink(dst))
1169
+ except OSError:
1170
+ continue
1171
+ expected = _setup_resolve_symlink_source(repo_root, name)
1172
+ should_remove = (
1173
+ target == expected
1174
+ or _setup_symlink_is_retired(dst, name, repo_root)
1175
+ )
1176
+ if not should_remove:
1177
+ continue
1178
+ try:
1179
+ dst.unlink()
1180
+ sym_removed += 1
1181
+ except OSError as exc:
1182
+ eprint(f"setup: failed to remove {dst}: {exc}")
953
1183
  out.append(f"Removed {sym_removed} symlinks from {dst_dir}/")
954
1184
 
955
1185
  legacy = _setup_detect_legacy_snippet()
@@ -1336,6 +1566,13 @@ def _setup_install(args: argparse.Namespace) -> int:
1336
1566
  eprint(f"setup: {exc}")
1337
1567
  return 1
1338
1568
 
1569
+ # Clean up symlinks left behind by prior cctally versions whose
1570
+ # subcommand surface has changed (e.g. v1.9.0 retired
1571
+ # `cctally-release` when release tooling went private). Only unlinks
1572
+ # symlinks whose target's basename matches the stale name; never
1573
+ # disturbs a user's hand-rolled symlink sharing the name.
1574
+ stale_results = _setup_cleanup_stale_symlinks(dst_dir)
1575
+
1339
1576
  sym_results = _setup_create_symlinks(repo_root, dst_dir)
1340
1577
  failed = [r for r in sym_results if r.status == "failed"]
1341
1578
  if failed:
@@ -1354,6 +1591,16 @@ def _setup_install(args: argparse.Namespace) -> int:
1354
1591
  detail_parts.append(f"{repl_count} re-pointed")
1355
1592
  detail = ", ".join(detail_parts) or "no changes"
1356
1593
  out.append(f"✓ Symlinks at {dst_dir}/: {len(sym_results)}/{len(sym_results)} ({detail})")
1594
+ removed_stale = [r for r in stale_results if r.status == "removed-stale"]
1595
+ failed_stale = [r for r in stale_results if r.status == "failed"]
1596
+ if removed_stale:
1597
+ out.append(
1598
+ "✓ Cleaned up stale symlink(s) from prior version: "
1599
+ + ", ".join(r.name for r in removed_stale)
1600
+ )
1601
+ for r in failed_stale:
1602
+ out.append(f"⚠ Could not remove stale {r.name}: {r.detail}")
1603
+ warnings += 1
1357
1604
 
1358
1605
  if not _setup_path_includes_local_bin():
1359
1606
  warnings += 1
@@ -329,6 +329,10 @@ def _dashboard_build_blocks_panel(*args, **kwargs):
329
329
  return sys.modules["cctally"]._dashboard_build_blocks_panel(*args, **kwargs)
330
330
 
331
331
 
332
+ def _dashboard_build_blocks_view(*args, **kwargs):
333
+ return sys.modules["cctally"]._dashboard_build_blocks_view(*args, **kwargs)
334
+
335
+
332
336
  def _dashboard_build_daily_panel(*args, **kwargs):
333
337
  return sys.modules["cctally"]._dashboard_build_daily_panel(*args, **kwargs)
334
338
 
@@ -822,27 +826,15 @@ from _lib_view_models import ( # noqa: E402
822
826
  )
823
827
 
824
828
 
825
- @dataclass
826
- class BlocksPanelRow:
827
- """One row of the dashboard's Blocks panel.
828
-
829
- Subset of the `Block` dataclass — drops token counts (panel is
830
- cost-driven; tokens belong to a future modal), drops `entries_count`
831
- / `is_gap` / `burn_rate` / `projection` (panel doesn't render them),
832
- and pre-formats `label` server-side for the local-tz "HH:MM MMM DD"
833
- display.
834
- """
835
- start_at: str # ISO-8601 UTC
836
- end_at: str # ISO-8601 UTC, start_at + 5h
837
- anchor: str # 'recorded' | 'heuristic'
838
- is_active: bool # now_utc < end_at AND entries_count > 0
839
- cost_usd: float
840
- models: list[dict[str, Any]] # ModelCostRow shape, sorted desc by cost
841
- label: str # "HH:MM MMM DD" in local tz, e.g. "14:00 Apr 26"
842
-
843
-
844
- # DailyPanelRow + TuiSessionRow moved to bin/_lib_view_models.py — re-export.
845
- from _lib_view_models import DailyPanelRow, TuiSessionRow # noqa: E402
829
+ # BlocksPanelRow + DailyPanelRow + TuiSessionRow moved to
830
+ # bin/_lib_view_models.py — re-exported here so historical
831
+ # ``from _cctally_tui import BlocksPanelRow`` (or ``ns["BlocksPanelRow"]``
832
+ # direct-dict reads in tests) keep resolving.
833
+ from _lib_view_models import ( # noqa: E402
834
+ BlocksPanelRow,
835
+ DailyPanelRow,
836
+ TuiSessionRow,
837
+ )
846
838
 
847
839
 
848
840
  @dataclass
@@ -1043,7 +1035,32 @@ class DataSnapshot:
1043
1035
  monthly_total_tokens: int = 0
1044
1036
  weekly_total_cost_usd: float = 0.0
1045
1037
  weekly_total_tokens: int = 0
1038
+ # Blocks domain (issue #56). ``BlocksPanelRow`` doesn't carry token
1039
+ # columns, so the cost total alone preserves the structural
1040
+ # ``total === sum(visible rows).cost_usd`` invariant; ``blocks_total_tokens``
1041
+ # is sourced from the same ``BlocksView`` build so both scalars
1042
+ # come from a single typed pass.
1043
+ blocks_total_cost_usd: float = 0.0
1044
+ blocks_total_tokens: int = 0
1046
1045
  trend_avg_dollars_per_pct: float | None = None
1046
+ # Trend modal median (issue #59). Sourced from
1047
+ # ``build_trend_view``'s ``median_dpp_non_current_4w`` field — the
1048
+ # last-4-non-current dpp median TrendModal.tsx used to compute
1049
+ # client-side. Populated by the sync thread off the 12-row history
1050
+ # build (NOT the 8-row panel build); the dashboard envelope adapter
1051
+ # emits this as ``trend.history_median_dpp``. ``None`` for fixture
1052
+ # modules that construct ``DataSnapshot`` positionally without
1053
+ # going through ``_tui_build_snapshot``; the React modal keeps a
1054
+ # client-side fallback for that case.
1055
+ trend_history_median_dpp: float | None = None
1056
+ # Forecast domain (issue #57). ``ForecastView`` wraps
1057
+ # ``ForecastOutput`` and surfaces the per-method projection /
1058
+ # verdict / header-routing / budget fields the dashboard envelope
1059
+ # adapter used to re-derive inline. Field is ``None`` for fixture
1060
+ # modules that construct ``DataSnapshot`` directly without going
1061
+ # through ``_tui_build_snapshot``; the envelope adapter falls
1062
+ # back to the legacy inline routing in that case.
1063
+ forecast_view: Any | None = None
1047
1064
 
1048
1065
  @classmethod
1049
1066
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -1426,11 +1443,32 @@ def _tui_build_forecast(
1426
1443
  *,
1427
1444
  skip_sync: bool = False,
1428
1445
  ):
1429
- """Call into existing forecast internals. Returns a ForecastOutput or None."""
1430
- inputs = _load_forecast_inputs(conn, now_utc, skip_sync=skip_sync)
1431
- if inputs is None:
1432
- return None
1433
- return _compute_forecast(inputs, [100, 90])
1446
+ """Build the TUI/dashboard sync-thread forecast.
1447
+
1448
+ Issue #57: routes through ``build_forecast_view`` (the kernel-pattern
1449
+ wrapper) and unwraps to a ``ForecastOutput`` for backward-compat with
1450
+ every existing ``snap.forecast`` consumer (TUI panels, envelope
1451
+ adapter, share builder). Use ``_tui_build_forecast_view`` when the
1452
+ full view is needed (e.g. ``snap.forecast_view`` population).
1453
+ """
1454
+ view = _tui_build_forecast_view(conn, now_utc, skip_sync=skip_sync)
1455
+ return view.output if view is not None else None
1456
+
1457
+
1458
+ def _tui_build_forecast_view(
1459
+ conn: sqlite3.Connection,
1460
+ now_utc: dt.datetime,
1461
+ *,
1462
+ skip_sync: bool = False,
1463
+ ):
1464
+ """Build the ``ForecastView`` (issue #57). Returns ``None`` only on
1465
+ error in callers — the empty-state View is constructed by the
1466
+ builder itself with ``output=None`` + ``verdict="LOW CONF"``.
1467
+ """
1468
+ c = _cctally()
1469
+ return c.build_forecast_view(
1470
+ conn, now_utc=now_utc, targets=(100, 90), skip_sync=skip_sync,
1471
+ )
1434
1472
 
1435
1473
 
1436
1474
  def _tui_build_trend(
@@ -1470,9 +1508,46 @@ def _tui_build_weekly_history(
1470
1508
  than parameterising the call site keeps the snapshot fields
1471
1509
  semantically distinct (panel data vs. modal data) and avoids
1472
1510
  accidental cross-contamination.
1511
+
1512
+ Issue #59: list-returning shim kept for back-compat with the
1513
+ public re-export at ``bin/cctally:13871``. New callers (the sync
1514
+ thread populating ``snap.weekly_history`` + the modal-median
1515
+ scalar) should prefer ``_tui_build_weekly_history_view`` so they
1516
+ pick up the pre-computed ``median_dpp_non_current_4w`` scalar
1517
+ without re-deriving.
1518
+ """
1519
+ return list(
1520
+ _tui_build_weekly_history_view(
1521
+ conn, now_utc, skip_sync=skip_sync, count=count,
1522
+ display_tz=display_tz,
1523
+ ).rows
1524
+ )
1525
+
1526
+
1527
+ def _tui_build_weekly_history_view(
1528
+ conn: sqlite3.Connection,
1529
+ now_utc: dt.datetime,
1530
+ *,
1531
+ skip_sync: bool = False, # noqa: ARG001 — unused today, kept for API symmetry
1532
+ count: int = 12,
1533
+ display_tz: "ZoneInfo | None" = None,
1534
+ ):
1535
+ """Build the full ``TrendView`` for the dashboard Trend modal
1536
+ (issue #59).
1537
+
1538
+ Wraps ``build_trend_view`` with the 12-row default the modal
1539
+ consumes. The returned ``TrendView`` carries the
1540
+ ``median_dpp_non_current_4w`` pre-computed scalar so the sync
1541
+ thread can populate ``DataSnapshot.trend_history_median_dpp``
1542
+ without re-running the median derivation client-side. The 8-row
1543
+ panel call (``_tui_build_trend``) goes through the same
1544
+ ``build_trend_view`` kernel; both builds carry their own median
1545
+ field but only the 12-row build's value reaches the envelope
1546
+ (``trend.history_median_dpp``).
1473
1547
  """
1474
- return _tui_build_trend(
1475
- conn, now_utc, skip_sync=skip_sync, count=count, display_tz=display_tz,
1548
+ c = _cctally()
1549
+ return c.build_trend_view(
1550
+ conn, now_utc=now_utc, n=max(1, count), display_tz=display_tz,
1476
1551
  )
1477
1552
 
1478
1553
 
@@ -1651,8 +1726,14 @@ def _tui_build_snapshot(
1651
1726
  cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
1652
1727
  except Exception as exc:
1653
1728
  errors.append(f"current-week: {exc}")
1729
+ fc_view = None
1654
1730
  try:
1655
- fc = _tui_build_forecast(conn, now_utc, skip_sync=skip_sync)
1731
+ # Issue #57: build the ForecastView once so we capture both
1732
+ # the legacy ``ForecastOutput`` (for ``snap.forecast``, which
1733
+ # the many TUI panel consumers still read) and the surface
1734
+ # fields the envelope adapter used to re-derive inline.
1735
+ fc_view = _tui_build_forecast_view(conn, now_utc, skip_sync=skip_sync)
1736
+ fc = fc_view.output if fc_view is not None else None
1656
1737
  except Exception as exc:
1657
1738
  errors.append(f"forecast: {exc}")
1658
1739
  # Trend: source from build_trend_view so we capture the 3-sample
@@ -1685,10 +1766,19 @@ def _tui_build_snapshot(
1685
1766
  milestones = _tui_build_percent_milestones(conn)
1686
1767
  except Exception as exc:
1687
1768
  errors.append(f"milestones: {exc}")
1769
+ history: list = []
1770
+ history_median_dpp: "float | None" = None
1688
1771
  try:
1689
- history = _tui_build_weekly_history(
1772
+ # Issue #59: build the full TrendView so we capture the
1773
+ # pre-computed 4-week-median-non-current scalar alongside
1774
+ # the row list; the dashboard envelope adapter surfaces
1775
+ # the scalar as ``trend.history_median_dpp`` so
1776
+ # TrendModal.tsx stops re-deriving it client-side.
1777
+ history_view = _tui_build_weekly_history_view(
1690
1778
  conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
1691
1779
  )
1780
+ history = list(history_view.rows)
1781
+ history_median_dpp = history_view.median_dpp_non_current_4w
1692
1782
  except Exception as exc:
1693
1783
  errors.append(f"weekly-history: {exc}")
1694
1784
  # ---- v2.1 additions: dashboard Weekly / Monthly panels ----
@@ -1742,15 +1832,25 @@ def _tui_build_snapshot(
1742
1832
  except Exception as exc:
1743
1833
  errors.append(f"monthly-periods: {exc}")
1744
1834
  # ---- v2.2 additions: dashboard Blocks / Daily panels ----
1835
+ # Issue #56: build the BlocksView once and read both rows
1836
+ # (presentation) and totals (envelope scalars) from the same
1837
+ # pass. ``_dashboard_build_blocks_view`` is the view-returning
1838
+ # counterpart to ``_dashboard_build_blocks_panel`` (which is
1839
+ # kept as a thin shim for monkeypatch surfaces).
1840
+ blocks_total_cost_usd = 0.0
1841
+ blocks_total_tokens = 0
1745
1842
  try:
1746
1843
  if cw is not None:
1747
- blocks_panel = _dashboard_build_blocks_panel(
1844
+ _blocks_view = _dashboard_build_blocks_view(
1748
1845
  conn, now_utc,
1749
1846
  week_start_at=cw.week_start_at,
1750
1847
  week_end_at=cw.week_end_at,
1751
1848
  skip_sync=skip_sync,
1752
1849
  display_tz=_build_display_tz,
1753
1850
  )
1851
+ blocks_panel = list(_blocks_view.rows)
1852
+ blocks_total_cost_usd = _blocks_view.total_cost_usd
1853
+ blocks_total_tokens = _blocks_view.total_tokens
1754
1854
  except Exception as exc:
1755
1855
  errors.append(f"blocks-panel: {exc}")
1756
1856
  # Sync-thread view-model totals (Bundle 1 / spec §6.6):
@@ -1818,7 +1918,11 @@ def _tui_build_snapshot(
1818
1918
  monthly_total_tokens=monthly_total_tokens,
1819
1919
  weekly_total_cost_usd=weekly_total_cost_usd,
1820
1920
  weekly_total_tokens=weekly_total_tokens,
1921
+ blocks_total_cost_usd=blocks_total_cost_usd,
1922
+ blocks_total_tokens=blocks_total_tokens,
1821
1923
  trend_avg_dollars_per_pct=trend_avg_dpp,
1924
+ trend_history_median_dpp=history_median_dpp,
1925
+ forecast_view=fc_view,
1822
1926
  )
1823
1927
  finally:
1824
1928
  conn.close()
@@ -2218,6 +2322,16 @@ class _TuiSyncThread:
2218
2322
  self._ref.set(snap)
2219
2323
  except Exception as exc:
2220
2324
  # Don't crash the thread on unexpected errors — surface in UI.
2325
+ # Carry every additive view-model scalar through verbatim so
2326
+ # the prior frame's panel rows and their envelope totals stay
2327
+ # consistent. Bundle 1 / #56 / #57 / #59 each added envelope
2328
+ # scalars the React panels now trust over a client-side
2329
+ # ``rows.reduce``; without preserving them here, a sync crash
2330
+ # leaves populated rows next to a ``$0.00`` footer (the
2331
+ # dataclass defaults kick in for any field not explicitly
2332
+ # passed). The structural-equality invariant
2333
+ # ``total === sum(visible rows).cost_usd`` must survive a
2334
+ # crash recovery, not just the happy path.
2221
2335
  prev = self._ref.get()
2222
2336
  self._ref.set(DataSnapshot(
2223
2337
  current_week=prev.current_week,
@@ -2233,6 +2347,17 @@ class _TuiSyncThread:
2233
2347
  monthly_periods=prev.monthly_periods,
2234
2348
  blocks_panel=prev.blocks_panel,
2235
2349
  daily_panel=prev.daily_panel,
2350
+ daily_total_cost_usd=prev.daily_total_cost_usd,
2351
+ daily_total_tokens=prev.daily_total_tokens,
2352
+ monthly_total_cost_usd=prev.monthly_total_cost_usd,
2353
+ monthly_total_tokens=prev.monthly_total_tokens,
2354
+ weekly_total_cost_usd=prev.weekly_total_cost_usd,
2355
+ weekly_total_tokens=prev.weekly_total_tokens,
2356
+ blocks_total_cost_usd=prev.blocks_total_cost_usd,
2357
+ blocks_total_tokens=prev.blocks_total_tokens,
2358
+ trend_avg_dollars_per_pct=prev.trend_avg_dollars_per_pct,
2359
+ trend_history_median_dpp=prev.trend_history_median_dpp,
2360
+ forecast_view=prev.forecast_view,
2236
2361
  ))
2237
2362
  # Wait up to interval, or until forced.
2238
2363
  for _ in range(int(max(1, self._interval * 10))):
@@ -116,10 +116,16 @@ What stays in bin/cctally:
116
116
  write surface in cmd_dashboard works unchanged; moved code
117
117
  reads via ``c.X``.
118
118
  - ``eprint``, ``_now_utc`` (used by moved code via shim/accessor),
119
- ``_release_read_latest_release_version`` (stays in cctally per
120
- spec §6.7 — 6+ external callers, file I/O over CHANGELOG.md),
119
+ ``_release_read_latest_release_version`` (impl moved to
120
+ ``_lib_changelog._read_latest_changelog_version``; cctally
121
+ re-exports the historical name. The shim below routes through
122
+ ``sys.modules['cctally']`` so test ``monkeypatch.setitem(ns, ...)``
123
+ on the historical name still overrides the 5 internal callers
124
+ here),
121
125
  ``_release_parse_semver`` / ``_release_semver_sort_key`` (lives
122
- in ``_lib_semver`` and re-exported by cctally),
126
+ in ``_lib_semver``; shims below route directly to that module —
127
+ the cctally re-exports were removed in the release-command split
128
+ privatization),
123
129
  ``load_config`` (lives in ``_cctally_config``; re-exported),
124
130
  ``_BANNER_SUPPRESSED_COMMANDS`` (lives in ``_cctally_db``;
125
131
  re-exported by cctally — composed with the update-only
@@ -210,17 +216,35 @@ def load_config(*args, **kwargs):
210
216
 
211
217
 
212
218
  def _release_read_latest_release_version(*args, **kwargs):
219
+ """Back-compat shim. The implementation now lives in
220
+ ``bin/_lib_changelog._read_latest_changelog_version``; ``bin/cctally``
221
+ re-exports it under the historical name. This shim routes through
222
+ the ``cctally`` namespace (not directly to ``_lib_changelog``) so
223
+ that ``monkeypatch.setitem(ns, "_release_read_latest_release_version", ...)``
224
+ in tests/test_update.py + tests/test_release_internals.py overrides
225
+ the 5+ internal callers in this module. New code should call
226
+ ``_lib_changelog._read_latest_changelog_version`` directly.
227
+ """
213
228
  return sys.modules["cctally"]._release_read_latest_release_version(
214
229
  *args, **kwargs
215
230
  )
216
231
 
217
232
 
233
+ # The semver shims now resolve via ``_lib_semver`` directly — the
234
+ # cctally re-exports (``_release_parse_semver`` / ``_release_semver_sort_key``)
235
+ # were removed in the release-command split privatization. Kept as
236
+ # call-time shims (vs. module-top ``from _lib_semver import ...``) so
237
+ # tests can still ``monkeypatch.setitem(ns, "_release_parse_semver", ...)``
238
+ # on this module's namespace if needed; the bare-name lookup inside this
239
+ # module resolves here, not at the import site.
218
240
  def _release_parse_semver(*args, **kwargs):
219
- return sys.modules["cctally"]._release_parse_semver(*args, **kwargs)
241
+ import _lib_semver
242
+ return _lib_semver._release_parse_semver(*args, **kwargs)
220
243
 
221
244
 
222
245
  def _release_semver_sort_key(*args, **kwargs):
223
- return sys.modules["cctally"]._release_semver_sort_key(*args, **kwargs)
246
+ import _lib_semver
247
+ return _lib_semver._release_semver_sort_key(*args, **kwargs)
224
248
 
225
249
 
226
250
  def _normalize_alerts_enabled_value(*args, **kwargs):