cctally 1.9.0 → 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 +24 -0
- package/bin/_cctally_dashboard.py +1095 -0
- package/bin/_cctally_tui.py +228 -10
- package/bin/_lib_share_templates.py +190 -0
- package/bin/_lib_view_models.py +20 -0
- package/bin/cctally +9 -0
- 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 +1 -1
- package/dashboard/static/assets/index-Dv5Dzag5.css +0 -1
- package/dashboard/static/assets/index-cWE5HB8O.js +0 -18
package/bin/_cctally_tui.py
CHANGED
|
@@ -1061,6 +1061,16 @@ class DataSnapshot:
|
|
|
1061
1061
|
# through ``_tui_build_snapshot``; the envelope adapter falls
|
|
1062
1062
|
# back to the legacy inline routing in that case.
|
|
1063
1063
|
forecast_view: Any | None = None
|
|
1064
|
+
# Projects panel + modal envelope block (spec §5.2 /
|
|
1065
|
+
# 2026-05-19-projects-panel-design.md). Populated on the sync
|
|
1066
|
+
# thread by ``_build_projects_envelope`` (per-tick DB-touching
|
|
1067
|
+
# aggregation that runs alongside the existing per-panel builds);
|
|
1068
|
+
# the dashboard's pure ``snapshot_to_envelope`` reads this back
|
|
1069
|
+
# unchanged and assigns it to ``envelope["projects"]``. ``None``
|
|
1070
|
+
# on first tick before sync completes — the TS envelope mirror
|
|
1071
|
+
# declares ``ProjectsEnvelope | null`` and the client renders the
|
|
1072
|
+
# panel-empty state until the next tick replaces it.
|
|
1073
|
+
projects_envelope: dict | None = None
|
|
1064
1074
|
|
|
1065
1075
|
@classmethod
|
|
1066
1076
|
def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
|
|
@@ -1612,6 +1622,95 @@ class TuiSessionDetail:
|
|
|
1612
1622
|
cost_total_usd: float
|
|
1613
1623
|
|
|
1614
1624
|
|
|
1625
|
+
def _tui_build_session_detail_indexed(
|
|
1626
|
+
session_id: str,
|
|
1627
|
+
range_start: dt.datetime,
|
|
1628
|
+
range_end: dt.datetime,
|
|
1629
|
+
) -> Any | None:
|
|
1630
|
+
"""Indexed direct lookup for one session by id.
|
|
1631
|
+
|
|
1632
|
+
Walks ``session_files`` (indexed by ``session_id`` — migration
|
|
1633
|
+
``idx_session_files_session_id``) for the 1-3 source_paths the
|
|
1634
|
+
session lives in, then fetches ONLY the entries from those paths
|
|
1635
|
+
in the supplied range. Aggregates the filtered list and returns
|
|
1636
|
+
the single matching ``ClaudeSessionUsage`` row.
|
|
1637
|
+
|
|
1638
|
+
Returns ``None`` on three indistinguishable misses (the caller's
|
|
1639
|
+
fallback path handles them all):
|
|
1640
|
+
|
|
1641
|
+
1. session_files row hasn't been backfilled yet for this id
|
|
1642
|
+
(CLAUDE.md "session_files is populated lazily" — first run
|
|
1643
|
+
after deploy).
|
|
1644
|
+
2. cache DB unavailable (open / lock contention).
|
|
1645
|
+
3. session_id is genuinely unknown.
|
|
1646
|
+
|
|
1647
|
+
Falling back uniformly preserves correctness without distinguishing
|
|
1648
|
+
the cases; if the slow path also misses, the modal renders 404.
|
|
1649
|
+
"""
|
|
1650
|
+
c = _cctally()
|
|
1651
|
+
open_cache_db = c.open_cache_db
|
|
1652
|
+
_JoinedClaudeEntry = c._JoinedClaudeEntry
|
|
1653
|
+
try:
|
|
1654
|
+
conn = open_cache_db()
|
|
1655
|
+
except (sqlite3.DatabaseError, OSError):
|
|
1656
|
+
return None
|
|
1657
|
+
try:
|
|
1658
|
+
# 1) Source paths for this session id (indexed lookup).
|
|
1659
|
+
rows = conn.execute(
|
|
1660
|
+
"SELECT path FROM session_files WHERE session_id = ?",
|
|
1661
|
+
(session_id,),
|
|
1662
|
+
).fetchall()
|
|
1663
|
+
if not rows:
|
|
1664
|
+
return None
|
|
1665
|
+
paths = [r[0] for r in rows]
|
|
1666
|
+
# 2) Entries restricted to those paths in the range. Typical
|
|
1667
|
+
# path-count is 1-3 (resume across files), well below SQLite's
|
|
1668
|
+
# 999 parameter cap.
|
|
1669
|
+
start_iso = range_start.astimezone(dt.timezone.utc).isoformat()
|
|
1670
|
+
end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
|
|
1671
|
+
placeholders = ",".join("?" * len(paths))
|
|
1672
|
+
cur = conn.execute(
|
|
1673
|
+
f"SELECT se.timestamp_utc, se.model, "
|
|
1674
|
+
f" se.input_tokens, se.output_tokens, "
|
|
1675
|
+
f" se.cache_create_tokens, se.cache_read_tokens, "
|
|
1676
|
+
f" se.source_path, sf.session_id, sf.project_path, "
|
|
1677
|
+
f" se.cost_usd_raw "
|
|
1678
|
+
f"FROM session_entries se "
|
|
1679
|
+
f"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
1680
|
+
f"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ? "
|
|
1681
|
+
f" AND se.source_path IN ({placeholders}) "
|
|
1682
|
+
f"ORDER BY se.timestamp_utc ASC",
|
|
1683
|
+
[start_iso, end_iso, *paths],
|
|
1684
|
+
)
|
|
1685
|
+
entries = [
|
|
1686
|
+
_JoinedClaudeEntry(
|
|
1687
|
+
timestamp=dt.datetime.fromisoformat(row[0]),
|
|
1688
|
+
model=row[1],
|
|
1689
|
+
input_tokens=row[2],
|
|
1690
|
+
output_tokens=row[3],
|
|
1691
|
+
cache_creation_tokens=row[4],
|
|
1692
|
+
cache_read_tokens=row[5],
|
|
1693
|
+
source_path=row[6],
|
|
1694
|
+
session_id=row[7],
|
|
1695
|
+
project_path=row[8],
|
|
1696
|
+
cost_usd=row[9],
|
|
1697
|
+
)
|
|
1698
|
+
for row in cur
|
|
1699
|
+
]
|
|
1700
|
+
if not entries:
|
|
1701
|
+
return None
|
|
1702
|
+
sessions = _aggregate_claude_sessions(entries)
|
|
1703
|
+
for s in sessions:
|
|
1704
|
+
if s.session_id == session_id:
|
|
1705
|
+
return s
|
|
1706
|
+
return None
|
|
1707
|
+
finally:
|
|
1708
|
+
try:
|
|
1709
|
+
conn.close()
|
|
1710
|
+
except sqlite3.Error:
|
|
1711
|
+
pass
|
|
1712
|
+
|
|
1713
|
+
|
|
1615
1714
|
def _tui_build_session_detail(
|
|
1616
1715
|
session_id: str,
|
|
1617
1716
|
*,
|
|
@@ -1619,19 +1718,34 @@ def _tui_build_session_detail(
|
|
|
1619
1718
|
) -> TuiSessionDetail | None:
|
|
1620
1719
|
"""Look up one session by ID; return None if not found.
|
|
1621
1720
|
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1721
|
+
Fast path: ``_tui_build_session_detail_indexed`` reads
|
|
1722
|
+
``session_files`` by id, scopes the entries SELECT to the matching
|
|
1723
|
+
source_paths, and aggregates only those rows — turning the lookup
|
|
1724
|
+
from "build every session in 365 days" into an indexed direct
|
|
1725
|
+
fetch (~3000× fewer rows on real DBs).
|
|
1726
|
+
|
|
1727
|
+
Slow-path fallback: when the indexed lookup misses (session_files
|
|
1728
|
+
not yet backfilled, cache unavailable, or genuinely unknown), the
|
|
1729
|
+
legacy bulk-fetch + linear scan still runs so the modal renders
|
|
1730
|
+
consistently with the panel's session list during the lazy-
|
|
1731
|
+
backfill window.
|
|
1625
1732
|
"""
|
|
1626
1733
|
now_utc = now_utc or dt.datetime.now(dt.timezone.utc)
|
|
1627
1734
|
range_start = now_utc - dt.timedelta(days=365)
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1735
|
+
match: Any | None = _tui_build_session_detail_indexed(
|
|
1736
|
+
session_id, range_start, now_utc,
|
|
1737
|
+
)
|
|
1738
|
+
if match is None:
|
|
1739
|
+
# Fall back to the bulk-aggregate path. Same shape as before,
|
|
1740
|
+
# used only when the index lookup couldn't conclude.
|
|
1741
|
+
entries = get_claude_session_entries(
|
|
1742
|
+
range_start, now_utc, skip_sync=True,
|
|
1743
|
+
)
|
|
1744
|
+
sessions = _aggregate_claude_sessions(entries)
|
|
1745
|
+
for s in sessions:
|
|
1746
|
+
if s.session_id == session_id:
|
|
1747
|
+
match = s
|
|
1748
|
+
break
|
|
1635
1749
|
if match is None:
|
|
1636
1750
|
return None
|
|
1637
1751
|
duration_min = (match.last_activity - match.first_activity).total_seconds() / 60.0
|
|
@@ -1896,6 +2010,109 @@ def _tui_build_snapshot(
|
|
|
1896
2010
|
fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
|
|
1897
2011
|
except Exception as exc:
|
|
1898
2012
|
errors.append(f"five-hour-milestones: {exc}")
|
|
2013
|
+
# ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
|
|
2014
|
+
# Per-tick aggregation lives on the sync thread; the dashboard's
|
|
2015
|
+
# pure ``snapshot_to_envelope`` reads ``snap.projects_envelope``
|
|
2016
|
+
# back unchanged. Errors are recorded on ``last_sync_error`` —
|
|
2017
|
+
# the client renders the panel-empty state when the field is
|
|
2018
|
+
# None (first tick, or sub-build failure).
|
|
2019
|
+
#
|
|
2020
|
+
# ATTACH cache.db onto the open stats conn so
|
|
2021
|
+
# ``_build_projects_envelope`` (which reads ``session_entries`` +
|
|
2022
|
+
# ``session_files`` + ``weekly_usage_snapshots`` off one conn —
|
|
2023
|
+
# the test contract per tests/test_projects_envelope.py) sees
|
|
2024
|
+
# all three tables. ATTACH/DETACH is cheap and scoped to this
|
|
2025
|
+
# sub-build; no schema migration / lock acquisition is needed.
|
|
2026
|
+
projects_envelope_block: dict | None = None
|
|
2027
|
+
try:
|
|
2028
|
+
c = _cctally()
|
|
2029
|
+
cache_db_path = c.CACHE_DB_PATH
|
|
2030
|
+
conn.execute(
|
|
2031
|
+
"ATTACH DATABASE ? AS cache_db",
|
|
2032
|
+
(str(cache_db_path),),
|
|
2033
|
+
)
|
|
2034
|
+
# session_entries / session_files live in cache.db; the
|
|
2035
|
+
# builder reads them via raw SQL keyed by the unqualified
|
|
2036
|
+
# table names. SQLite's name resolution prefers the `main`
|
|
2037
|
+
# schema, so create temporary views in `main` that point
|
|
2038
|
+
# at the attached schema's tables. Aliasing via VIEWs keeps
|
|
2039
|
+
# the builder portable: unit tests pass one conn carrying
|
|
2040
|
+
# both schemas; production wiring uses an attached cache.
|
|
2041
|
+
conn.execute(
|
|
2042
|
+
"CREATE TEMP VIEW IF NOT EXISTS session_entries AS "
|
|
2043
|
+
"SELECT * FROM cache_db.session_entries"
|
|
2044
|
+
)
|
|
2045
|
+
conn.execute(
|
|
2046
|
+
"CREATE TEMP VIEW IF NOT EXISTS session_files AS "
|
|
2047
|
+
"SELECT * FROM cache_db.session_files"
|
|
2048
|
+
)
|
|
2049
|
+
projects_envelope_block = c._build_projects_envelope(
|
|
2050
|
+
conn,
|
|
2051
|
+
now_utc=now_utc,
|
|
2052
|
+
current_week=cw,
|
|
2053
|
+
weeks_back=12,
|
|
2054
|
+
)
|
|
2055
|
+
except Exception as exc:
|
|
2056
|
+
errors.append(f"projects-envelope: {exc}")
|
|
2057
|
+
finally:
|
|
2058
|
+
try:
|
|
2059
|
+
conn.execute("DROP VIEW IF EXISTS session_entries")
|
|
2060
|
+
conn.execute("DROP VIEW IF EXISTS session_files")
|
|
2061
|
+
except Exception:
|
|
2062
|
+
pass
|
|
2063
|
+
try:
|
|
2064
|
+
conn.execute("DETACH DATABASE cache_db")
|
|
2065
|
+
except Exception:
|
|
2066
|
+
pass
|
|
2067
|
+
# Late-bind disambiguated `project_key` onto each SessionsPanel
|
|
2068
|
+
# row so the SessionsPanel → ProjectsModal cross-nav (spec §4.1)
|
|
2069
|
+
# routes by the same identity the Projects envelope emits.
|
|
2070
|
+
# Cheap dict-lookup per row; no second aggregation pass.
|
|
2071
|
+
#
|
|
2072
|
+
# `key_by_bucket_path` is indexed by git-root bucket_path (the
|
|
2073
|
+
# envelope builder calls `_resolve_project_key(..., "git-root")`
|
|
2074
|
+
# in `_build_projects_envelope`), but `srow.project_path` is the
|
|
2075
|
+
# raw cwd from `_aggregate_claude_sessions` — typically a
|
|
2076
|
+
# subdirectory of the repo root for monorepo sessions. We must
|
|
2077
|
+
# resolve each cwd through the same production resolver so the
|
|
2078
|
+
# lookup hits; otherwise `project_key` stays None and the
|
|
2079
|
+
# cross-nav button degrades to plain text.
|
|
2080
|
+
if projects_envelope_block is not None:
|
|
2081
|
+
try:
|
|
2082
|
+
key_by_bucket_path: dict[str, str] = {}
|
|
2083
|
+
for r in projects_envelope_block.get(
|
|
2084
|
+
"current_week", {}
|
|
2085
|
+
).get("rows", []):
|
|
2086
|
+
bp = r.get("bucket_path")
|
|
2087
|
+
k = r.get("key")
|
|
2088
|
+
if bp and k:
|
|
2089
|
+
key_by_bucket_path[bp] = k
|
|
2090
|
+
for r in projects_envelope_block.get(
|
|
2091
|
+
"trend", {}
|
|
2092
|
+
).get("projects", []):
|
|
2093
|
+
bp = r.get("bucket_path")
|
|
2094
|
+
k = r.get("key")
|
|
2095
|
+
if bp and k and bp not in key_by_bucket_path:
|
|
2096
|
+
key_by_bucket_path[bp] = k
|
|
2097
|
+
_resolve = _cctally()._resolve_project_key
|
|
2098
|
+
resolver_cache: dict = {}
|
|
2099
|
+
annotated: list[TuiSessionRow] = []
|
|
2100
|
+
for srow in sessions:
|
|
2101
|
+
pkey = None
|
|
2102
|
+
if srow.project_path:
|
|
2103
|
+
bp = _resolve(
|
|
2104
|
+
srow.project_path, "git-root", resolver_cache,
|
|
2105
|
+
).bucket_path
|
|
2106
|
+
pkey = key_by_bucket_path.get(bp)
|
|
2107
|
+
if pkey is None:
|
|
2108
|
+
annotated.append(srow)
|
|
2109
|
+
else:
|
|
2110
|
+
annotated.append(
|
|
2111
|
+
dataclasses.replace(srow, project_key=pkey)
|
|
2112
|
+
)
|
|
2113
|
+
sessions = annotated
|
|
2114
|
+
except Exception as exc:
|
|
2115
|
+
errors.append(f"projects-cross-nav-bind: {exc}")
|
|
1899
2116
|
return DataSnapshot(
|
|
1900
2117
|
current_week=cw,
|
|
1901
2118
|
forecast=fc,
|
|
@@ -1923,6 +2140,7 @@ def _tui_build_snapshot(
|
|
|
1923
2140
|
trend_avg_dollars_per_pct=trend_avg_dpp,
|
|
1924
2141
|
trend_history_median_dpp=history_median_dpp,
|
|
1925
2142
|
forecast_view=fc_view,
|
|
2143
|
+
projects_envelope=projects_envelope_block,
|
|
1926
2144
|
)
|
|
1927
2145
|
finally:
|
|
1928
2146
|
conn.close()
|
|
@@ -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
|
|
|
@@ -1552,6 +1555,176 @@ def _build_sessions_detail(*, panel_data, options):
|
|
|
1552
1555
|
)
|
|
1553
1556
|
|
|
1554
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
|
+
|
|
1555
1728
|
# --- Register Recap templates ---
|
|
1556
1729
|
|
|
1557
1730
|
_RECAP = (
|
|
@@ -1587,6 +1760,11 @@ _RECAP = (
|
|
|
1587
1760
|
description="Top-N sessions table + total",
|
|
1588
1761
|
default_options={"top_n": 15, "show_chart": False, "show_table": True},
|
|
1589
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),
|
|
1590
1768
|
)
|
|
1591
1769
|
|
|
1592
1770
|
SHARE_TEMPLATES = SHARE_TEMPLATES + _RECAP
|
|
@@ -1627,6 +1805,11 @@ _VISUAL = (
|
|
|
1627
1805
|
description="Horizontal bar of top-N sessions by cost",
|
|
1628
1806
|
default_options={"top_n": 8, "show_chart": True, "show_table": False},
|
|
1629
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),
|
|
1630
1813
|
)
|
|
1631
1814
|
|
|
1632
1815
|
|
|
@@ -1665,6 +1848,13 @@ _DETAIL = (
|
|
|
1665
1848
|
description="Top-50 sessions with full columns",
|
|
1666
1849
|
default_options={"top_n": 50, "show_chart": False, "show_table": True},
|
|
1667
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),
|
|
1668
1858
|
)
|
|
1669
1859
|
|
|
1670
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
|
|
package/bin/cctally
CHANGED
|
@@ -766,6 +766,7 @@ _build_monthly_share_panel_data = _cctally_dashboard._build_monthly_share_panel_
|
|
|
766
766
|
_build_forecast_share_panel_data = _cctally_dashboard._build_forecast_share_panel_data
|
|
767
767
|
_build_blocks_share_panel_data = _cctally_dashboard._build_blocks_share_panel_data
|
|
768
768
|
_build_sessions_share_panel_data = _cctally_dashboard._build_sessions_share_panel_data
|
|
769
|
+
_build_projects_share_panel_data = _cctally_dashboard._build_projects_share_panel_data
|
|
769
770
|
_SnapshotRef = _cctally_dashboard._SnapshotRef
|
|
770
771
|
SSEHub = _cctally_dashboard.SSEHub
|
|
771
772
|
STATIC_DIR = _cctally_dashboard.STATIC_DIR
|
|
@@ -781,6 +782,13 @@ _dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
|
|
|
781
782
|
_dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
|
|
782
783
|
_empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
|
|
783
784
|
_iso_z = _cctally_dashboard._iso_z
|
|
785
|
+
# Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
|
|
786
|
+
# Re-export so the sync-thread builder at `_cctally_tui._tui_build_snapshot`
|
|
787
|
+
# can reach the dashboard sibling's aggregator via `c = _cctally()`.
|
|
788
|
+
_build_projects_envelope = _cctally_dashboard._build_projects_envelope
|
|
789
|
+
_projects_reset_memo = _cctally_dashboard._projects_reset_memo
|
|
790
|
+
_project_detail_for_window = _cctally_dashboard._project_detail_for_window
|
|
791
|
+
_handle_get_project_detail_impl = _cctally_dashboard._handle_get_project_detail_impl
|
|
784
792
|
_select_current_block_for_envelope = _cctally_dashboard._select_current_block_for_envelope
|
|
785
793
|
_build_alerts_envelope_array = _cctally_dashboard._build_alerts_envelope_array
|
|
786
794
|
snapshot_to_envelope = _cctally_dashboard.snapshot_to_envelope
|
|
@@ -12442,6 +12450,7 @@ _tui_build_trend = _cctally_tui._tui_build_trend
|
|
|
12442
12450
|
_tui_build_weekly_history = _cctally_tui._tui_build_weekly_history
|
|
12443
12451
|
_tui_build_sessions = _cctally_tui._tui_build_sessions
|
|
12444
12452
|
_tui_build_session_detail = _cctally_tui._tui_build_session_detail
|
|
12453
|
+
_tui_build_session_detail_indexed = _cctally_tui._tui_build_session_detail_indexed
|
|
12445
12454
|
_tui_build_snapshot = _cctally_tui._tui_build_snapshot
|
|
12446
12455
|
_tui_empty_snapshot = _cctally_tui._tui_empty_snapshot
|
|
12447
12456
|
# Key reader + dispatcher + sync thread base class
|