cctally 1.8.2 → 1.10.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.
@@ -0,0 +1,44 @@
1
+ """Public helper: read the latest stamped release header from CHANGELOG.md.
2
+
3
+ Read-only. Pure with respect to inputs (CHANGELOG.md contents). The
4
+ historical name ``_release_read_latest_release_version`` carried a
5
+ ``_release_`` prefix because the helper originated with the release-
6
+ automation work, but the function is not release-machinery: doctor,
7
+ the share kernel, and ``cctally --version`` all read it. Lives in a
8
+ public sibling so the maintainer-only release tooling can move to a
9
+ private artifact without dragging the version reader with it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+
16
+
17
+ def _cctally():
18
+ """Call-time accessor for the ``cctally`` module (project memory
19
+ ``_cctally() accessor pattern``). Avoids module-top ``import cctally``
20
+ so monkeypatch-sensitive globals (``CHANGELOG_PATH`` and
21
+ ``RELEASE_HEADER_RE``) stay reachable for tests."""
22
+ return sys.modules["cctally"]
23
+
24
+
25
+ def _read_latest_changelog_version() -> tuple[str, str] | None:
26
+ """Read latest ``## [X.Y.Z] - YYYY-MM-DD`` header from
27
+ ``CHANGELOG_PATH``. Returns ``(version, date)`` or ``None`` if the
28
+ file is missing or has no stamped release header.
29
+
30
+ Body is byte-equivalent to the original
31
+ ``_release_read_latest_release_version`` definition in ``bin/cctally``
32
+ (the rename is the only intentional change); the regex
33
+ ``RELEASE_HEADER_RE`` is read from the ``cctally`` module so any
34
+ in-process update to the pattern remains the single source of truth.
35
+ """
36
+ c = _cctally()
37
+ try:
38
+ text = c.CHANGELOG_PATH.read_text(encoding="utf-8")
39
+ except FileNotFoundError:
40
+ return None
41
+ m = c.RELEASE_HEADER_RE.search(text)
42
+ if not m:
43
+ return None
44
+ return (m.group(1), m.group(2))
@@ -76,7 +76,7 @@ def _release_compute_next_version(
76
76
  return _release_format_semver(nxt_maj, nxt_min, nxt_pat, prerelease_id, 1)
77
77
 
78
78
  if is_prerelease:
79
- raise ValueError("current version is a prerelease; run 'cctally release finalize' first or use --bump in a prerelease bump")
79
+ raise ValueError("current version is a prerelease; run 'cctally-release finalize' first or use --bump in a prerelease bump")
80
80
 
81
81
  if kind == "patch":
82
82
  return _release_format_semver(cur_maj, cur_min, cur_pat + 1)
@@ -37,6 +37,9 @@ SHARE_CAPABLE_PANELS: frozenset[str] = frozenset({
37
37
  "blocks",
38
38
  "forecast",
39
39
  "sessions",
40
+ # Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
41
+ # 9th share-capable panel — three templates land in this module.
42
+ "projects",
40
43
  })
41
44
 
42
45
 
@@ -289,8 +292,10 @@ def _release_version() -> str:
289
292
  at `bin/cctally:86`). Falls back to `"dev"` when CHANGELOG is unreadable
290
293
  or has no stamped release entry yet (pre-release dev builds).
291
294
 
292
- Parallel to `_release_read_latest_release_version` in `bin/cctally` —
293
- intentionally duplicated so the template module stays free of any
295
+ Parallel to `_lib_changelog._read_latest_changelog_version`
296
+ (re-exported on `bin/cctally` under the historical
297
+ `_release_read_latest_release_version` name) — intentionally
298
+ duplicated so the template module stays free of any
294
299
  `bin/cctally` import. If CHANGELOG header format changes, update both.
295
300
  """
296
301
  from pathlib import Path
@@ -1550,6 +1555,176 @@ def _build_sessions_detail(*, panel_data, options):
1550
1555
  )
1551
1556
 
1552
1557
 
1558
+ # --- Projects panel + modal builders (spec §7.6) ---
1559
+ #
1560
+ # The three Projects-panel templates share a single internal kernel so
1561
+ # the table / chart / KPI layout stays consistent across archetypes:
1562
+ # only the "what's emphasized" shape differs (recap = top-N table,
1563
+ # visual = chart-forward, detail = full table + chart). All three
1564
+ # consume `panel_data` from `_build_projects_share_panel_data` in
1565
+ # `bin/_cctally_dashboard.py`, NOT `_build_project_snapshot` directly —
1566
+ # that kernel takes `ProjectKey` objects, but the dashboard envelope
1567
+ # emits already-disambiguated string `key`s. Both paths end up at the
1568
+ # same column shape: `Project | $ Cost | % Used | Sessions`.
1569
+
1570
+ _PROJECTS_TABLE_COLUMNS = (
1571
+ _LS.ColumnSpec(key="project", label="Project", align="left"),
1572
+ _LS.ColumnSpec(key="cost", label="$ Cost", align="right", emphasis=True),
1573
+ _LS.ColumnSpec(key="used", label="% Used", align="right"),
1574
+ _LS.ColumnSpec(key="sessions", label="Sessions", align="right"),
1575
+ )
1576
+
1577
+
1578
+ def _projects_rows_for_template(rows: list[dict], cap: int) -> tuple:
1579
+ """Build `Row` tuple for the Projects table from the dashboard
1580
+ panel_data row shape.
1581
+
1582
+ `rows` is a list of dicts {key, bucket_path, cost_usd,
1583
+ attributed_pct, sessions_count} (post-disambiguation, sorted by
1584
+ caller). `cap` truncates to top-N by caller order. Mirrors
1585
+ `_build_project_snapshot`'s row layout in bin/cctally:11774 but
1586
+ with string keys (no ProjectKey objects).
1587
+ """
1588
+ out: list = []
1589
+ for r in (rows or [])[:cap]:
1590
+ attr = r.get("attributed_pct")
1591
+ out.append(_LS.Row(cells={
1592
+ "project": _LS.ProjectCell(label=str(r["key"])),
1593
+ "cost": _LS.MoneyCell(usd=float(r["cost_usd"])),
1594
+ "used": (
1595
+ _LS.PercentCell(float(attr)) if attr is not None
1596
+ else _LS.TextCell("—")
1597
+ ),
1598
+ "sessions": _LS.TextCell(str(int(r.get("sessions_count", 0) or 0))),
1599
+ }))
1600
+ return tuple(out)
1601
+
1602
+
1603
+ def _projects_chart_for_template(rows: list[dict], cap: int):
1604
+ """Build the HorizontalBarChart for the Projects share artifact.
1605
+ Cap=12 mirrors `_build_project_snapshot` (bin/cctally:11813).
1606
+ """
1607
+ if not rows:
1608
+ return None
1609
+ points = []
1610
+ for r in rows[:cap]:
1611
+ label = str(r["key"])
1612
+ cost = float(r["cost_usd"])
1613
+ points.append(_LS.ChartPoint(
1614
+ x_label=label,
1615
+ x_value=cost,
1616
+ y_value=cost,
1617
+ project_label=label,
1618
+ ))
1619
+ return _LS.HorizontalBarChart(
1620
+ points=tuple(points), x_label="$", cap=cap,
1621
+ )
1622
+
1623
+
1624
+ def _projects_period(panel_data: dict, options: dict):
1625
+ start = panel_data["period_start"]
1626
+ end = panel_data["period_end"]
1627
+ weeks = int(panel_data.get("window_weeks", 1) or 1)
1628
+ label = "This week" if weeks == 1 else f"Last {weeks} weeks"
1629
+ return _period(start, end, label=label,
1630
+ display_tz=_display_tz(options))
1631
+
1632
+
1633
+ def _projects_subtitle(options: dict, total_rows: int) -> str:
1634
+ reveal = options.get("reveal_projects", True)
1635
+ return " · ".join([
1636
+ f"{total_rows} project{'' if total_rows == 1 else 's'}",
1637
+ "real projects" if reveal else "projects anonymized",
1638
+ ])
1639
+
1640
+
1641
+ def _build_projects_recap(*, panel_data, options):
1642
+ """Projects recap — top-N table + KPI strip + HBar chart.
1643
+
1644
+ Mirrors `weekly-recap`'s balanced shape (table + chart + KPIs).
1645
+ Top-N defaults to 5 (matches the panel's top-5 rows from spec §2.3).
1646
+ """
1647
+ rows = panel_data.get("rows") or []
1648
+ cap = int(options.get("top_n", 5))
1649
+ total = float(panel_data.get("total_cost_usd", 0.0) or 0.0)
1650
+ return _LS.ShareSnapshot(
1651
+ cmd="projects",
1652
+ title=f"Projects recap — top {min(cap, len(rows))}",
1653
+ subtitle=_projects_subtitle(options, len(rows)),
1654
+ period=_projects_period(panel_data, options),
1655
+ columns=_PROJECTS_TABLE_COLUMNS,
1656
+ rows=_projects_rows_for_template(rows, cap),
1657
+ chart=_projects_chart_for_template(rows, cap=12),
1658
+ totals=_kpi_strip(
1659
+ ("$ spent", f"${total:,.2f}"),
1660
+ ("Projects", str(len(rows))),
1661
+ ),
1662
+ notes=(),
1663
+ generated_at=_utc_now(),
1664
+ version=_release_version(),
1665
+ )
1666
+
1667
+
1668
+ def _build_projects_visual(*, panel_data, options):
1669
+ """Projects visual — chart-forward HBar of top-12 projects by cost.
1670
+
1671
+ Mirrors `weekly-visual` / `current-week-visual` — chart-led, no
1672
+ table. Spec §7.6 routes the visual archetype to a HorizontalBarChart
1673
+ of the top-12 projects.
1674
+ """
1675
+ rows = panel_data.get("rows") or []
1676
+ cap_chart = 12
1677
+ total = float(panel_data.get("total_cost_usd", 0.0) or 0.0)
1678
+ return _LS.ShareSnapshot(
1679
+ cmd="projects",
1680
+ title=f"Projects — top {min(cap_chart, len(rows))} by cost",
1681
+ subtitle=_projects_subtitle(options, len(rows)),
1682
+ period=_projects_period(panel_data, options),
1683
+ columns=_PROJECTS_TABLE_COLUMNS,
1684
+ rows=(), # visual archetype: empty table
1685
+ chart=_projects_chart_for_template(rows, cap=cap_chart),
1686
+ totals=_kpi_strip(
1687
+ ("$ spent", f"${total:,.2f}"),
1688
+ ("Projects", str(len(rows))),
1689
+ ),
1690
+ notes=(),
1691
+ generated_at=_utc_now(),
1692
+ version=_release_version(),
1693
+ )
1694
+
1695
+
1696
+ def _build_projects_detail(*, panel_data, options):
1697
+ """Projects detail — full table + chart. Closest fit to today's CLI
1698
+ `cctally project --format` output (spec §7.6 routes detail here).
1699
+
1700
+ Default top_n=50 means "all rows" for typical caches.
1701
+ """
1702
+ rows = panel_data.get("rows") or []
1703
+ cap = int(options.get("top_n", 50))
1704
+ total = float(panel_data.get("total_cost_usd", 0.0) or 0.0)
1705
+ notes: tuple[str, ...] = ()
1706
+ if len(rows) > 12:
1707
+ notes = (
1708
+ f"Showing top 12 in chart; table includes all {len(rows)}.",
1709
+ )
1710
+ return _LS.ShareSnapshot(
1711
+ cmd="projects",
1712
+ title=f"Projects detail — {len(rows)} project{'' if len(rows) == 1 else 's'}",
1713
+ subtitle=_projects_subtitle(options, len(rows)),
1714
+ period=_projects_period(panel_data, options),
1715
+ columns=_PROJECTS_TABLE_COLUMNS,
1716
+ rows=_projects_rows_for_template(rows, cap),
1717
+ chart=_projects_chart_for_template(rows, cap=12),
1718
+ totals=_kpi_strip(
1719
+ ("$ spent", f"${total:,.2f}"),
1720
+ ("Projects", str(len(rows))),
1721
+ ),
1722
+ notes=notes,
1723
+ generated_at=_utc_now(),
1724
+ version=_release_version(),
1725
+ )
1726
+
1727
+
1553
1728
  # --- Register Recap templates ---
1554
1729
 
1555
1730
  _RECAP = (
@@ -1585,6 +1760,11 @@ _RECAP = (
1585
1760
  description="Top-N sessions table + total",
1586
1761
  default_options={"top_n": 15, "show_chart": False, "show_table": True},
1587
1762
  builder=_build_sessions_recap),
1763
+ # Projects panel + modal (spec §7.6).
1764
+ ShareTemplate(id="projects-recap", panel="projects", label="Recap",
1765
+ description="Top-N projects table + total + HBar chart",
1766
+ default_options={"top_n": 5, "show_chart": True, "show_table": True},
1767
+ builder=_build_projects_recap),
1588
1768
  )
1589
1769
 
1590
1770
  SHARE_TEMPLATES = SHARE_TEMPLATES + _RECAP
@@ -1625,6 +1805,11 @@ _VISUAL = (
1625
1805
  description="Horizontal bar of top-N sessions by cost",
1626
1806
  default_options={"top_n": 8, "show_chart": True, "show_table": False},
1627
1807
  builder=_build_sessions_visual),
1808
+ # Projects panel + modal (spec §7.6).
1809
+ ShareTemplate(id="projects-visual", panel="projects", label="Visual",
1810
+ description="Chart-forward HBar of top-12 projects by cost",
1811
+ default_options={"top_n": 12, "show_chart": True, "show_table": False},
1812
+ builder=_build_projects_visual),
1628
1813
  )
1629
1814
 
1630
1815
 
@@ -1663,6 +1848,13 @@ _DETAIL = (
1663
1848
  description="Top-50 sessions with full columns",
1664
1849
  default_options={"top_n": 50, "show_chart": False, "show_table": True},
1665
1850
  builder=_build_sessions_detail),
1851
+ # Projects panel + modal (spec §7.6). Default top_n=50 means "all
1852
+ # rows" for typical caches; matches today's CLI `cctally project
1853
+ # --format` table shape.
1854
+ ShareTemplate(id="projects-detail", panel="projects", label="Detail",
1855
+ description="Full projects table + HBar chart (top-12)",
1856
+ default_options={"top_n": 50, "show_chart": True, "show_table": True},
1857
+ builder=_build_projects_detail),
1666
1858
  )
1667
1859
 
1668
1860
  SHARE_TEMPLATES = SHARE_TEMPLATES + _VISUAL + _DETAIL
@@ -243,6 +243,20 @@ class TuiSessionRow:
243
243
  cache_hit_pct: float | None
244
244
  project_label: str # basename of project_path
245
245
  session_id: str # full session UUID (v2: needed for session-detail modal)
246
+ # Disambiguated display key (matches the Projects panel envelope's
247
+ # `current_week.rows[].key` / `trend.projects[].key`). Populated by
248
+ # the sync-thread builder after `_build_projects_envelope` runs so
249
+ # the SessionsPanel → ProjectsModal cross-nav (spec §4.1) routes by
250
+ # a stable identity. Defaults to ``None`` for fixture modules that
251
+ # construct ``TuiSessionRow`` positionally without the Bundle 6 /
252
+ # projects-panel additions; the client renders the cell as plain
253
+ # text in that case per spec §4.1 stopgap.
254
+ project_key: str | None = None
255
+ # Absolute project_path (NULL ⇒ ``(unknown)`` resolved upstream).
256
+ # Carried so the sync-thread builder can compute ``project_key``
257
+ # without re-reading ``session_files`` and so the share path can
258
+ # privacy-scrub via ``_lib_share._scrub``.
259
+ project_path: str | None = None
246
260
 
247
261
 
248
262
  # === Internal helpers ======================================================
@@ -1066,6 +1080,12 @@ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None):
1066
1080
  _os.path.basename(s.project_path) or s.project_path
1067
1081
  ),
1068
1082
  session_id=s.session_id,
1083
+ # `project_key` is populated downstream by `_tui_build_snapshot`
1084
+ # after `_build_projects_envelope` runs (so the disambiguated
1085
+ # display_key matches the Projects envelope exactly). Stash
1086
+ # the absolute path here so that pass can map back without
1087
+ # re-reading session_files.
1088
+ project_path=s.project_path or None,
1069
1089
  ))
1070
1090
  total_cost += s.cost_usd
1071
1091