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.
- package/CHANGELOG.md +33 -0
- package/bin/_cctally_dashboard.py +1095 -0
- package/bin/_cctally_setup.py +248 -1
- package/bin/_cctally_tui.py +228 -10
- package/bin/_cctally_update.py +29 -5
- package/bin/_lib_changelog.py +44 -0
- package/bin/_lib_semver.py +1 -1
- package/bin/_lib_share_templates.py +194 -2
- package/bin/_lib_view_models.py +20 -0
- package/bin/cctally +44 -1474
- package/dashboard/static/assets/index-DUKjFlG8.js +18 -0
- package/dashboard/static/assets/index-Dp14ELVt.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -3
- package/bin/_cctally_release.py +0 -751
- package/bin/cctally-release +0 -3
- package/dashboard/static/assets/index-Dv5Dzag5.css +0 -1
- package/dashboard/static/assets/index-cWE5HB8O.js +0 -18
|
@@ -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))
|
package/bin/_lib_semver.py
CHANGED
|
@@ -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
|
|
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 `
|
|
293
|
-
|
|
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
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -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
|
|