From 8bf1acdb88a602dae45ae72d588087638004a4aa Mon Sep 17 00:00:00 2001
From: Haoxiang Cheng <2739441541@qq.com>
Date: Fri, 4 Sep 2026 17:53:54 +0800
Subject: [PATCH 1/5] docs: define analytics movement and profile navigation
---
.../2026-09-03-admin-user-value-analytics.md | 11 ++++++++++
...09-03-admin-user-value-analytics-design.md | 21 +++++++++++++++++++
2 files changed, 32 insertions(+)
diff --git a/docs/superpowers/plans/2026-09-03-admin-user-value-analytics.md b/docs/superpowers/plans/2026-09-03-admin-user-value-analytics.md
index 2ef7dc5b..74c0300a 100644
--- a/docs/superpowers/plans/2026-09-03-admin-user-value-analytics.md
+++ b/docs/superpowers/plans/2026-09-03-admin-user-value-analytics.md
@@ -31,6 +31,17 @@
- Use synthetic fixtures and fake repositories. Tests require no real API key, Stripe call, provider call, production database, or copied production identity.
- Never stage or commit `dashboard/storage/data/backtest.db`, `.superpowers/`, `work/`, secrets, or generated mockup artifacts.
+## Follow-up UI Navigation and Movement Ranges
+
+The Direction of travel card uses a URL-backed `5D / 1W / 1M / 1Y` selector,
+defaulting to `5D`. The lifecycle contract returns display-safe movement points
+with their selected range and daily/weekly/monthly granularity. The broader
+Analytics date filters remain independent. Priority-user identities expose a
+real profile link into the existing User Analytics Profile surface. Profile
+navigation adds a history entry, breadcrumb, and back behavior that restores
+the parent filters, pagination, and scroll position. Direct links remain valid
+and fall back to the overview when no parent history entry is available.
+
## Locked File Structure
- `dashboard/backend/domain/analytics/lifecycle.py`: pure meaningful-activity, lifecycle, operational, commercial-tier, and cohort-date rules with no I/O.
diff --git a/docs/superpowers/specs/2026-09-03-admin-user-value-analytics-design.md b/docs/superpowers/specs/2026-09-03-admin-user-value-analytics-design.md
index ed20d17f..6baa92a8 100644
--- a/docs/superpowers/specs/2026-09-03-admin-user-value-analytics-design.md
+++ b/docs/superpowers/specs/2026-09-03-admin-user-value-analytics-design.md
@@ -319,6 +319,27 @@ existing Users workspace. Analytics remains read-only.
### User Analytics Profile
+The priority-user list and other user tables provide a direct, display-safe
+link to a dedicated User Analytics Profile route. The profile is a separate
+workspace surface rather than an inline expansion so the list remains scannable
+while Timeline, Runs, Usage, and Sessions can grow independently. A breadcrumb
+and an explicit back action return to the exact Analytics list state, including
+filters, date range, pagination, and scroll position. Browser back/forward and
+deep links follow the same URL state. Opening a profile records a history entry;
+switching profile sections replaces only the current entry, and a direct link
+falls back to the Analytics overview when no parent history entry exists.
+
+### Lifecycle Movement Ranges
+
+The Direction of travel chart defaults to the most recent five UTC calendar
+days. A compact range control in the card header offers `5D`, `1W`, `1M`, and
+`1Y`. Five-day and one-week views use daily snapshots; one-month uses weekly
+snapshots; and one-year uses monthly snapshots. The API returns the selected
+range, granularity, and display-safe period points so the client never relabels
+weekly data as daily data. Missing historical snapshots remain partial or empty
+states; the system never fabricates zero-valued history. The selected movement
+range is URL-backed independently from the broader Analytics date filters.
+
The existing dedicated User Analytics Profile remains the full inspection
surface with Overview, Timeline, Runs, Usage, and Sessions. Its Overview adds:
From 565b6e2224c37a58f58ec4d4fbbf7a10c37869b9 Mon Sep 17 00:00:00 2001
From: Haoxiang Cheng <2739441541@qq.com>
Date: Fri, 4 Sep 2026 18:44:01 +0800
Subject: [PATCH 2/5] feat: add analytics movement ranges and profile
navigation
---
.../backend/api/routers/admin_analytics.py | 10 +-
.../backend/domain/analytics/value_queries.py | 89 ++++++++++--
.../domain/analytics/test_value_queries.py | 27 ++++
.../fixtures/admin_analytics/lifecycle.json | 28 ++++
.../backend/tests/test_admin_analytics_api.py | 25 ++++
.../tests/test_admin_analytics_frontend.py | 8 +-
.../test_admin_analytics_value_frontend.py | 12 ++
.../test_backtest_comparison_frontend.py | 2 +-
.../tests/test_credit_format_frontend.py | 2 +-
.../backend/tests/test_frontend_fast_boot.py | 4 +-
dashboard/frontend/app.html | 30 ++--
.../frontend/js/admin-analytics-value.js | 131 +++++++++++++++---
dashboard/frontend/js/admin-analytics.js | 70 ++++++++--
dashboard/frontend/styles.css | 94 +++++++++++++
14 files changed, 478 insertions(+), 54 deletions(-)
diff --git a/dashboard/backend/api/routers/admin_analytics.py b/dashboard/backend/api/routers/admin_analytics.py
index f45bb3a3..63f6e22b 100644
--- a/dashboard/backend/api/routers/admin_analytics.py
+++ b/dashboard/backend/api/routers/admin_analytics.py
@@ -54,6 +54,7 @@
_LIFECYCLE_SEGMENTS = {"new", "onboarding", "growing", "core", "at_risk", "dormant"}
_OPERATIONAL_STATES = {"blocked", "needs_attention", "healthy"}
_COMMERCIAL_TIERS = {"unpaid", "starter", "invested", "high_value"}
+_LIFECYCLE_MOVEMENT_RANGES = {"5d", "1w", "1m", "1y"}
_MAX_VALUE_RANGE_DAYS = 180
@@ -406,12 +407,19 @@ def get_lifecycle(
request: Request,
service: ValueAnalyticsQueryService = Depends(get_value_analytics_query_service),
):
- start, end, include_internal, _values = _value_range(request)
+ start, end, include_internal, values = _value_range(
+ request,
+ additional={"movement_range"},
+ )
+ movement_range = values.get("movement_range", "5d")
+ if movement_range not in _LIFECYCLE_MOVEMENT_RANGES:
+ _invalid_query()
try:
return service.get_lifecycle(
start=start,
end=end,
include_internal=include_internal,
+ movement_range=movement_range,
)
except Exception as exc:
_raise_service_error(exc)
diff --git a/dashboard/backend/domain/analytics/value_queries.py b/dashboard/backend/domain/analytics/value_queries.py
index 6853ee3d..53481cb0 100644
--- a/dashboard/backend/domain/analytics/value_queries.py
+++ b/dashboard/backend/domain/analytics/value_queries.py
@@ -54,6 +54,12 @@
"high_value",
)
_TIER_RANK = {tier: rank for rank, tier in enumerate(_COMMERCIAL_TIERS)}
+_MOVEMENT_WINDOWS: dict[str, tuple[int, Literal["day", "week", "month"]]] = {
+ "5d": (5, "day"),
+ "1w": (7, "day"),
+ "1m": (31, "week"),
+ "1y": (365, "month"),
+}
_PRIORITY_RANK = {
"blocked": 0,
"needs_attention": 1,
@@ -77,6 +83,14 @@ def _week_start(value: date) -> date:
return value - timedelta(days=value.weekday())
+def _period_start(value: date, granularity: Literal["day", "week", "month"]) -> date:
+ if granularity == "day":
+ return value
+ if granularity == "week":
+ return _week_start(value)
+ return value.replace(day=1)
+
+
def _parse_timestamp(value: object) -> datetime:
parsed = datetime.fromisoformat(str(value))
if parsed.tzinfo is None or parsed.utcoffset() is None:
@@ -123,6 +137,16 @@ class WeeklyLifecycleCount(BaseModel):
data_quality: Literal["complete", "partial"]
+class LifecycleMovementPoint(BaseModel):
+ """A display-safe lifecycle snapshot at the selected chart granularity."""
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ period_start: date
+ segment_counts: dict[LifecycleSegment, int]
+ data_quality: Literal["complete", "partial"]
+
+
class LifecycleTransition(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
@@ -141,6 +165,9 @@ class LifecycleAnalyticsResponse(BaseModel):
headline: LifecycleHeadline
segment_counts: dict[LifecycleSegment, int]
weekly_segments: Sequence[WeeklyLifecycleCount]
+ movement_range: Literal["5d", "1w", "1m", "1y"] = "5d"
+ movement_granularity: Literal["day", "week", "month"] = "day"
+ movement_segments: Sequence[LifecycleMovementPoint] = Field(default_factory=tuple)
transitions: Sequence[LifecycleTransition]
availability: dict[str, SectionAvailability]
@@ -452,19 +479,31 @@ def _history(
start: date,
end: date,
use_anonymous_rollups: bool,
+ movement_start: date | None = None,
+ movement_range: str = "5d",
) -> tuple[
- list[WeeklyLifecycleCount], list[LifecycleTransition], SectionAvailability
+ list[WeeklyLifecycleCount],
+ list[LifecycleMovementPoint],
+ list[LifecycleTransition],
+ SectionAvailability,
]:
+ if movement_range not in _MOVEMENT_WINDOWS:
+ raise ValueError("unsupported lifecycle movement range")
+ window_days, granularity = _MOVEMENT_WINDOWS[movement_range]
+ selected_movement_start = movement_start or end - timedelta(days=window_days)
+ if selected_movement_start >= end:
+ raise ValueError("lifecycle movement range is empty")
+ history_start = min(start, selected_movement_start)
rows = self._daily(
user_ids,
- start=start - timedelta(days=1),
+ start=history_start - timedelta(days=1),
end=end,
)
by_date: dict[date, list[UserLifecycleDailySnapshot]] = defaultdict(list)
for row in rows:
by_date[row.snapshot_date].append(row)
rollups = (
- self.query_store.rollups.list_rollups(start=start, end=end)
+ self.query_store.rollups.list_rollups(start=history_start, end=end)
if use_anonymous_rollups
else []
)
@@ -481,7 +520,7 @@ def _history(
daily_counts: dict[date, dict[LifecycleSegment, int]] = {}
daily_quality: dict[date, str] = {}
- direct_dates = {day for day in by_date if start <= day < end}
+ direct_dates = {day for day in by_date if history_start <= day < end}
for day in direct_dates:
counts = Counter(row.lifecycle_segment for row in by_date[day])
daily_counts[day] = {
@@ -503,6 +542,8 @@ def _history(
weekly: list[WeeklyLifecycleCount] = []
by_week: dict[date, list[date]] = defaultdict(list)
for day in daily_counts:
+ if not start <= day < end:
+ continue
by_week[_week_start(day)].append(day)
for week, dates in sorted(by_week.items()):
latest = max(dates)
@@ -514,6 +555,21 @@ def _history(
)
)
+ movement: list[LifecycleMovementPoint] = []
+ by_period: dict[date, list[date]] = defaultdict(list)
+ for day in daily_counts:
+ if selected_movement_start <= day < end:
+ by_period[_period_start(day, granularity)].append(day)
+ for period, dates in sorted(by_period.items()):
+ latest = max(dates)
+ movement.append(
+ LifecycleMovementPoint(
+ period_start=period,
+ segment_counts=daily_counts[latest],
+ data_quality=daily_quality[latest],
+ )
+ )
+
transition_counts: Counter[tuple[str, str]] = Counter()
transition_partial: set[tuple[str, str]] = set()
by_user_date = {(row.user_id, row.snapshot_date): row for row in rows}
@@ -564,14 +620,18 @@ def _history(
status="building",
)
else:
- partial = any(value == "partial" for value in daily_quality.values())
+ partial = (
+ any(value == "partial" for value in daily_quality.values())
+ or coverage[0] > history_start
+ or coverage[-1] < end - timedelta(days=1)
+ )
availability = SectionAvailability(
available=True,
status="partial" if partial else "ready",
coverage_start=coverage[0],
coverage_end=coverage[-1],
)
- return weekly, transitions, availability
+ return weekly, movement, transitions, availability
def get_lifecycle(
self,
@@ -579,9 +639,14 @@ def get_lifecycle(
start: date,
end: date,
include_internal: bool = False,
+ movement_range: str = "5d",
now: datetime | None = None,
) -> LifecycleAnalyticsResponse:
start, end = _validate_dates(start, end)
+ if movement_range not in _MOVEMENT_WINDOWS:
+ raise ValueError("unsupported lifecycle movement range")
+ window_days, _granularity = _MOVEMENT_WINDOWS[movement_range]
+ movement_start = end - timedelta(days=window_days)
current_time = _utc(now or datetime.now(UTC), "now")
users = self._eligible_users(include_internal=include_internal)
current = self._current(users)
@@ -611,14 +676,16 @@ def get_lifecycle(
status="unavailable",
)
try:
- weekly, transitions, history_availability = self._history(
+ weekly, movement, transitions, history_availability = self._history(
self._ids(users),
start=start,
end=end,
use_anonymous_rollups=not include_internal,
+ movement_start=movement_start,
+ movement_range=movement_range,
)
except Exception:
- weekly, transitions = [], []
+ weekly, movement, transitions = [], [], []
history_availability = SectionAvailability(
available=False,
status="unavailable",
@@ -636,6 +703,9 @@ def get_lifecycle(
),
segment_counts=segment_counts,
weekly_segments=weekly,
+ movement_range=movement_range,
+ movement_granularity=_MOVEMENT_WINDOWS[movement_range][1],
+ movement_segments=movement,
transitions=transitions,
availability=availability,
)
@@ -1046,7 +1116,7 @@ def get_user_profile(
start=_day_start(start),
end=_day_start(end),
)
- _weekly, transitions, _availability = self._history(
+ _weekly, _movement, transitions, _availability = self._history(
[subject_id],
start=start,
end=end,
@@ -1069,6 +1139,7 @@ def get_user_profile(
"CommercialPeriodSummary",
"LifecycleAnalyticsResponse",
"LifecycleHeadline",
+ "LifecycleMovementPoint",
"LifecycleTransition",
"OperationalAnalyticsResponse",
"PaginatedValueUsers",
diff --git a/dashboard/backend/tests/domain/analytics/test_value_queries.py b/dashboard/backend/tests/domain/analytics/test_value_queries.py
index 6affc220..f9c68208 100644
--- a/dashboard/backend/tests/domain/analytics/test_value_queries.py
+++ b/dashboard/backend/tests/domain/analytics/test_value_queries.py
@@ -303,6 +303,33 @@ def test_date_filter_changes_history_not_current_lifecycle_identity():
assert short.weekly_segments != long.weekly_segments
+@pytest.mark.parametrize(
+ ("movement_range", "granularity", "expected_max_points"),
+ [("5d", "day", 5), ("1w", "day", 7), ("1m", "week", 5), ("1y", "month", 12)],
+)
+def test_lifecycle_movement_returns_selected_range_and_granularity(
+ movement_range, granularity, expected_max_points
+):
+ snapshots = {1: _snapshot(1)}
+ daily = [
+ _daily(1, date(2025, 10, 1) + timedelta(days=offset), "core")
+ for offset in range(365)
+ ]
+ service, _value_store, _legacy = _service(snapshots=snapshots, daily=daily)
+
+ response = service.get_lifecycle(
+ start=date(2026, 4, 5),
+ end=date(2026, 10, 1),
+ movement_range=movement_range,
+ now=datetime(2026, 10, 1, 12, tzinfo=UTC),
+ )
+
+ assert response.movement_range == movement_range
+ assert response.movement_granularity == granularity
+ assert 0 < len(response.movement_segments) <= expected_max_points
+ assert all(point.period_start for point in response.movement_segments)
+
+
def test_retention_uses_nulls_for_immature_cells_and_weighted_mature_summary():
first_week = date(2026, 7, 6)
second_week = date(2026, 7, 13)
diff --git a/dashboard/backend/tests/fixtures/admin_analytics/lifecycle.json b/dashboard/backend/tests/fixtures/admin_analytics/lifecycle.json
index 416c4828..34ec8db1 100644
--- a/dashboard/backend/tests/fixtures/admin_analytics/lifecycle.json
+++ b/dashboard/backend/tests/fixtures/admin_analytics/lifecycle.json
@@ -40,6 +40,34 @@
"data_quality": "partial"
}
],
+ "movement_range": "5d",
+ "movement_granularity": "day",
+ "movement_segments": [
+ {
+ "period_start": "2026-08-30",
+ "segment_counts": {
+ "new": 3,
+ "onboarding": 6,
+ "growing": 7,
+ "core": 8,
+ "at_risk": 5,
+ "dormant": 4
+ },
+ "data_quality": "complete"
+ },
+ {
+ "period_start": "2026-08-31",
+ "segment_counts": {
+ "new": 3,
+ "onboarding": 6,
+ "growing": 7,
+ "core": 8,
+ "at_risk": 5,
+ "dormant": 4
+ },
+ "data_quality": "partial"
+ }
+ ],
"transitions": [
{
"from_segment": "growing",
diff --git a/dashboard/backend/tests/test_admin_analytics_api.py b/dashboard/backend/tests/test_admin_analytics_api.py
index d9b082e1..196006bb 100644
--- a/dashboard/backend/tests/test_admin_analytics_api.py
+++ b/dashboard/backend/tests/test_admin_analytics_api.py
@@ -539,6 +539,31 @@ def test_admin_value_sections_have_independent_contracts(
assert call["billing_mode"] == "platform_credits"
+@pytest.mark.parametrize("movement_range", ["5d", "1w", "1m", "1y"])
+def test_lifecycle_accepts_documented_movement_ranges(admin_analytics_api, movement_range):
+ api = admin_analytics_api
+ response = api["client"].get(
+ "/api/admin/analytics/lifecycle",
+ params={"from": "2026-08-01", "to": "2026-08-31", "movement_range": movement_range},
+ headers=api["admin_headers"],
+ )
+
+ assert response.status_code == 200, response.text
+ name, call = api["value_query_service"].calls[-1]
+ assert name == "lifecycle"
+ assert call["movement_range"] == movement_range
+
+
+def test_lifecycle_rejects_unknown_movement_range(admin_analytics_api):
+ response = admin_analytics_api["client"].get(
+ "/api/admin/analytics/lifecycle",
+ params={"movement_range": "2q"},
+ headers=admin_analytics_api["admin_headers"],
+ )
+ assert response.status_code == 422
+ assert response.json() == {"detail": "Invalid Analytics query."}
+
+
def test_admin_overview_accepts_documented_filters(admin_analytics_api):
api = admin_analytics_api
response = api["client"].get(
diff --git a/dashboard/backend/tests/test_admin_analytics_frontend.py b/dashboard/backend/tests/test_admin_analytics_frontend.py
index 0f0f6bd5..ac1ac59b 100644
--- a/dashboard/backend/tests/test_admin_analytics_frontend.py
+++ b/dashboard/backend/tests/test_admin_analytics_frontend.py
@@ -109,7 +109,7 @@ def test_admin_analytics_surface_and_module_exist():
assert 'id="adminPanelAnalytics"' in APP_HTML
assert 'id="adminAnalyticsOverview"' in APP_HTML
assert 'id="adminAnalyticsProfile"' in APP_HTML
- assert 'js/admin-analytics.js?v=5' in APP_HTML
+ assert 'js/admin-analytics.js?v=6' in APP_HTML
assert ANALYTICS_JS_PATH.exists()
assert ".admin-analytics-overview" in STYLES
assert ".admin-analytics-profile" in STYLES
@@ -225,10 +225,10 @@ def test_app_lifecycle_and_cache_versions_are_wired():
assert "window.AdminAnalytics.refresh()" in APP_JS
assert "window.AdminAnalyticsValue.syncAuth(user)" in APP_JS
assert "window.AdminAnalyticsValue.onEnter()" in APP_JS
- assert 'styles.css?v=135' in APP_HTML
+ assert 'styles.css?v=136' in APP_HTML
assert 'app.js?v=128' in APP_HTML
- assert 'js/admin-analytics.js?v=5' in APP_HTML
- assert 'js/admin-analytics-value.js?v=3' in APP_HTML
+ assert 'js/admin-analytics.js?v=6' in APP_HTML
+ assert 'js/admin-analytics-value.js?v=4' in APP_HTML
assert 'js/admin-tabs.js?v=4' in APP_HTML
diff --git a/dashboard/backend/tests/test_admin_analytics_value_frontend.py b/dashboard/backend/tests/test_admin_analytics_value_frontend.py
index 255141a2..4b2897c4 100644
--- a/dashboard/backend/tests/test_admin_analytics_value_frontend.py
+++ b/dashboard/backend/tests/test_admin_analytics_value_frontend.py
@@ -190,6 +190,18 @@ def test_charts_disclosures_and_controls_have_semantic_state():
assert "autocomplete=" in fragment
+def test_movement_ranges_and_profile_navigation_are_discoverable():
+ source = value_source()
+ for movement_range in ("5d", "1w", "1m", "1y"):
+ assert f'data-movement-range="{movement_range}"' in APP_HTML
+ assert "analyticsMovementRange" in source
+ assert "movement_granularity" in source
+ assert "admin-priority-profile-link" in source
+ assert "admin-help-btn" in APP_HTML
+ assert 'aria-label="How segments work"' in APP_HTML
+ assert 'id="adminAnalyticsProfileBreadcrumbParent"' in APP_HTML
+
+
def test_value_formatting_uses_intl_and_dialogs_bound_scroll():
source = value_source()
assert "Intl.NumberFormat" in source
diff --git a/dashboard/backend/tests/test_backtest_comparison_frontend.py b/dashboard/backend/tests/test_backtest_comparison_frontend.py
index 035f0210..ffff3f77 100644
--- a/dashboard/backend/tests/test_backtest_comparison_frontend.py
+++ b/dashboard/backend/tests/test_backtest_comparison_frontend.py
@@ -192,7 +192,7 @@ def test_exact_raw_ties_mark_every_tied_series_best():
def test_comparison_script_and_semantic_table_ship_before_app():
helper = ''
app = ''
- assert 'href="styles.css?v=135"' in APP_HTML
+ assert 'href="styles.css?v=136"' in APP_HTML
assert APP_HTML.index(helper) < APP_HTML.index(app)
for element_id in (
"performanceLegend",
diff --git a/dashboard/backend/tests/test_credit_format_frontend.py b/dashboard/backend/tests/test_credit_format_frontend.py
index dd1e710e..ada9a01a 100644
--- a/dashboard/backend/tests/test_credit_format_frontend.py
+++ b/dashboard/backend/tests/test_credit_format_frontend.py
@@ -76,7 +76,7 @@ def test_credit_formatter_loads_before_every_consumer():
for asset in (
'src="js/credits.js?v=8"',
'src="js/admin-credits.js?v=6"',
- 'src="js/admin-analytics.js?v=5"',
+ 'src="js/admin-analytics.js?v=6"',
):
assert formatter_at < APP_HTML.index(asset)
diff --git a/dashboard/backend/tests/test_frontend_fast_boot.py b/dashboard/backend/tests/test_frontend_fast_boot.py
index 49207865..d8a4e893 100644
--- a/dashboard/backend/tests/test_frontend_fast_boot.py
+++ b/dashboard/backend/tests/test_frontend_fast_boot.py
@@ -193,10 +193,10 @@ def test_cache_busters_bumped():
# round of follow-ups (#347/#348).
assert "app.js?v=128" in APP_HTML
assert "js/agent-editor.js?v=30" in APP_HTML
- assert "styles.css?v=135" in APP_HTML
+ assert "styles.css?v=136" in APP_HTML
assert "js/leaderboard.js?v=32" in APP_HTML
assert "home-page.js?v=50" in APP_HTML
assert "js/credit-format.js?v=1" in APP_HTML
assert "js/credits.js?v=8" in APP_HTML
assert "js/admin-credits.js?v=6" in APP_HTML
- assert "js/admin-analytics.js?v=5" in APP_HTML
+ assert "js/admin-analytics.js?v=6" in APP_HTML
diff --git a/dashboard/frontend/app.html b/dashboard/frontend/app.html
index 17376dad..5b3a1287 100644
--- a/dashboard/frontend/app.html
+++ b/dashboard/frontend/app.html
@@ -13,7 +13,7 @@
because every API call is a CORS request. -->
-
+
@@ -2131,7 +2131,7 @@
Analytics
See who reached value, who returned, and where attention can change an outcome.
@@ -2164,11 +2164,20 @@ Analytics
-
Direction of travel
Eight-week movement
-
Incomplete data
+
Direction of travel
Recent 5-day movement
+
+
+ 5D
+ 1W
+ 1M
+ 1Y
+
+
daily snapshots
+
Incomplete data
+
-
- Lifecycle segment counts by week Week New Onboarding Growing Core At risk Dormant
+
+ Lifecycle segment counts by period Period New Onboarding Growing Core At risk Dormant
@@ -2308,6 +2317,11 @@ Activation funnel
+
+ Analytics
+ /
+ User analytics profile
+
Back to analytics overview
@@ -2645,8 +2659,8 @@
Refund Credits purchase
-
-
+
+
diff --git a/dashboard/frontend/js/admin-analytics-value.js b/dashboard/frontend/js/admin-analytics-value.js
index 14aad953..2d9bb64c 100644
--- a/dashboard/frontend/js/admin-analytics-value.js
+++ b/dashboard/frontend/js/admin-analytics-value.js
@@ -46,6 +46,12 @@
needs_attention: 'A supported issue needs operator review but may not block every action.',
healthy: 'No supported current blocker or attention condition matched.',
});
+ const MOVEMENT_RANGES = Object.freeze({
+ '5d': { label: '5D', title: 'Recent 5-day movement', granularity: 'daily' },
+ '1w': { label: '1W', title: '7-day movement', granularity: 'daily' },
+ '1m': { label: '1M', title: 'Monthly movement', granularity: 'weekly' },
+ '1y': { label: '1Y', title: 'Yearly movement', granularity: 'monthly' },
+ });
const CHART_COLORS = Object.freeze({
new: '#94a3b8',
onboarding: '#38bdf8',
@@ -60,6 +66,7 @@
commercial: 'analyticsCommercial',
user: 'analyticsUser',
profile: 'analyticsProfile',
+ movementRange: 'analyticsMovementRange',
});
const returnFocus = new Map();
@@ -68,6 +75,7 @@
active: false,
requestSeq: 0,
range: null,
+ movementRange: '5d',
includeInternal: false,
userFilters: {
lifecycle: '',
@@ -157,6 +165,8 @@
start: validDate(start) ? start : defaults.start,
end: validDate(end) ? end : defaults.end,
};
+ const movementRange = params.get(URL_KEYS.movementRange) || '5d';
+ state.movementRange = Object.hasOwn(MOVEMENT_RANGES, movementRange) ? movementRange : '5d';
state.includeInternal = params.get('analyticsInternal') === 'true';
const lifecycle = params.get(URL_KEYS.lifecycle) || '';
const operational = params.get(URL_KEYS.operational) || '';
@@ -189,6 +199,7 @@
setOrDelete(url.searchParams, URL_KEYS.commercial, state.userFilters.commercial);
setOrDelete(url.searchParams, 'analyticsUserQuery', state.userFilters.query);
setOrDelete(url.searchParams, URL_KEYS.profile, state.userFilters.profile);
+ url.searchParams.set(URL_KEYS.movementRange, state.movementRange);
setOrDelete(url.searchParams, 'analyticsPanel', [...state.openDisclosures].sort().join(','));
window.history.replaceState(window.history.state, '', url);
}
@@ -201,6 +212,11 @@
element('adminPriorityLifecycle').value = state.userFilters.lifecycle;
element('adminPriorityOperational').value = state.userFilters.operational;
element('adminPriorityCommercial').value = state.userFilters.commercial;
+ document.querySelectorAll('[data-movement-range]').forEach((button) => {
+ const selected = button.dataset.movementRange === state.movementRange;
+ button.setAttribute('aria-pressed', selected ? 'true' : 'false');
+ button.tabIndex = selected ? 0 : -1;
+ });
}
function rangeParams() {
@@ -322,29 +338,56 @@
: 'Current snapshot';
}
- function replaceHiddenMovementRows(series) {
+ function movementPointDate(point) {
+ return point?.period_start || point?.week_start;
+ }
+
+ function replaceHiddenMovementRows(series, granularity) {
const body = element('adminLifecycleMovementTable')?.querySelector('tbody');
clear(body);
- series.forEach((week) => {
+ const table = element('adminLifecycleMovementTable');
+ const periodLabel = granularity === 'day' ? 'Day' : granularity === 'month' ? 'Month' : 'Week';
+ if (table) {
+ const caption = table.querySelector('caption');
+ if (caption) caption.textContent = `Lifecycle segment counts by ${periodLabel.toLowerCase()}`;
+ const heading = table.querySelector('thead th');
+ if (heading) heading.textContent = periodLabel;
+ }
+ series.forEach((point) => {
const row = document.createElement('tr');
- row.appendChild(node('th', '', formatDate(week.week_start)));
+ row.appendChild(node('th', '', formatDate(movementPointDate(point))));
row.firstChild.scope = 'row';
LIFECYCLE_SEGMENTS.forEach((segment) => {
- row.appendChild(node('td', '', number(week.segment_counts?.[segment] || 0)));
+ row.appendChild(node('td', '', number(point.segment_counts?.[segment] || 0)));
});
body.appendChild(row);
});
}
- function renderLifecycleMovement(series) {
- const rows = Array.isArray(series) ? series : [];
- replaceHiddenMovementRows(rows);
+ function renderLifecycleMovement(payload) {
+ const hasMovementContract = Array.isArray(payload?.movement_segments);
+ const range = Object.hasOwn(MOVEMENT_RANGES, payload?.movement_range)
+ ? payload.movement_range
+ : state.movementRange;
+ const config = hasMovementContract
+ ? MOVEMENT_RANGES[range]
+ : { title: 'Weekly movement', granularity: 'weekly' };
+ const granularity = payload?.movement_granularity || (
+ config.granularity === 'daily' ? 'day' : config.granularity === 'weekly' ? 'week' : 'month'
+ );
+ const rows = hasMovementContract
+ ? payload.movement_segments
+ : (Array.isArray(payload?.weekly_segments) ? payload.weekly_segments : []);
+ replaceHiddenMovementRows(rows, granularity);
+ element('adminLifecycleMovementTitle').textContent = config.title;
+ element('adminLifecycleMovementGranularity').textContent = `${config.granularity} snapshots`;
const quality = element('adminLifecycleQuality');
- quality.hidden = !rows.some((week) => week.data_quality === 'partial');
+ quality.hidden = !rows.some((point) => point.data_quality === 'partial');
const canvas = element('adminLifecycleMovementChart');
+ const periodLabel = granularity === 'day' ? 'daily' : granularity === 'month' ? 'monthly' : 'weekly';
canvas.setAttribute(
'aria-label',
- rows.length ? `Lifecycle movement across ${rows.length} weekly snapshots` : 'No lifecycle movement data available'
+ rows.length ? `Lifecycle movement across ${rows.length} ${periodLabel} snapshots` : 'No lifecycle movement data available'
);
if (state.movementChart) {
state.movementChart.destroy();
@@ -354,10 +397,10 @@
state.movementChart = new window.Chart(canvas, {
type: 'line',
data: {
- labels: rows.map((week) => formatDate(week.week_start)),
+ labels: rows.map((point) => formatDate(movementPointDate(point))),
datasets: LIFECYCLE_SEGMENTS.map((segment) => ({
label: LIFECYCLE_LABELS[segment],
- data: rows.map((week) => Number(week.segment_counts?.[segment] || 0)),
+ data: rows.map((point) => Number(point.segment_counts?.[segment] || 0)),
borderColor: CHART_COLORS[segment],
backgroundColor: CHART_COLORS[segment],
borderWidth: 2,
@@ -384,7 +427,7 @@
function renderLifecycle(payload) {
renderHeadline(payload);
renderDistribution(payload);
- renderLifecycleMovement(payload.weekly_segments);
+ renderLifecycleMovement(payload);
const incomplete = availabilityIncomplete(payload.availability);
element('adminValuePrimaryStatus').textContent = incomplete ? 'Incomplete data · available sections remain current.' : '';
}
@@ -443,6 +486,29 @@
openDialog(element('adminAnalyticsEvidenceDialog'), opener);
}
+ function profileHref(userId) {
+ const url = new URL(window.location.href);
+ url.searchParams.set('adminTab', 'analytics');
+ url.searchParams.set('analyticsUser', String(userId));
+ url.searchParams.set('analyticsProfile', String(userId));
+ url.searchParams.set('analyticsSection', 'overview');
+ return `${url.pathname}${url.search}`;
+ }
+
+ function profileLink(user) {
+ const label = user.display_name || user.email || `User #${user.user_id}`;
+ const link = node('a', 'admin-priority-profile-link', label);
+ link.href = profileHref(user.user_id);
+ link.setAttribute('aria-label', `Open analytics profile for ${label}`);
+ link.addEventListener('click', (event) => {
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ if (typeof window.AdminAnalytics?.openProfile !== 'function') return;
+ event.preventDefault();
+ window.AdminAnalytics.openProfile(user.user_id);
+ });
+ return link;
+ }
+
function renderUsers(payload) {
const target = element('adminPriorityUsers');
clear(target);
@@ -450,7 +516,9 @@
items.forEach((user) => {
const row = node('article', 'admin-priority-user');
const identity = node('div', 'admin-priority-identity');
- identity.appendChild(node('strong', '', user.display_name || user.email || `User #${user.user_id}`));
+ const name = node('strong', '', '');
+ name.appendChild(profileLink(user));
+ identity.appendChild(name);
identity.appendChild(node('span', '', user.email || `User #${user.user_id}`));
row.appendChild(identity);
const signals = node('div', 'admin-priority-signals');
@@ -673,7 +741,9 @@
}
function fetchLifecycle() {
- return request(`${API_ENDPOINTS.lifecycle}?${rangeParams()}`);
+ const params = rangeParams();
+ params.set('movement_range', state.movementRange);
+ return request(`${API_ENDPOINTS.lifecycle}?${params}`);
}
function fetchPriorityUsers() {
@@ -742,7 +812,32 @@
if (days > 180) throw new Error('Choose no more than 180 UTC dates.');
}
+ function setMovementRange(value) {
+ if (!Object.hasOwn(MOVEMENT_RANGES, value) || value === state.movementRange) return;
+ state.movementRange = value;
+ setControls();
+ writeUrlState();
+ state.sections.lifecycle.loaded = false;
+ refreshPrimary();
+ }
+
function bindEvents() {
+ const movementRangeKeys = Object.keys(MOVEMENT_RANGES);
+ document.querySelectorAll('[data-movement-range]').forEach((button) => {
+ button.addEventListener('click', () => setMovementRange(button.dataset.movementRange));
+ button.addEventListener('keydown', (event) => {
+ if (!['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(event.key)) return;
+ event.preventDefault();
+ const index = movementRangeKeys.indexOf(button.dataset.movementRange);
+ const next = event.key === 'Home'
+ ? movementRangeKeys[0]
+ : event.key === 'End'
+ ? movementRangeKeys[movementRangeKeys.length - 1]
+ : movementRangeKeys[(index + (event.key === 'ArrowRight' ? 1 : -1) + movementRangeKeys.length) % movementRangeKeys.length];
+ setMovementRange(next);
+ element(`adminLifecycleMovementRange${next}`).focus();
+ });
+ });
element('adminAnalyticsRulesOpen')?.addEventListener('click', (event) => {
renderRulesDialog();
openDialog(element('adminAnalyticsRulesDialog'), event.currentTarget);
@@ -858,10 +953,10 @@
const tab = new URL(window.location.href).searchParams.get('adminTab') || 'analytics';
state.active = tab === 'analytics';
if (!state.active) return;
- if (!state.sections.lifecycle.loaded || !state.sections.users.loaded) refreshPrimary();
- if (/^\d+$/.test(state.userFilters.profile)) {
- window.AdminAnalytics?.openProfile(state.userFilters.profile, { focus: false });
- }
+ // The profile controller owns deep-link restoration. Avoid fetching the
+ // overview behind an already-open profile on a direct URL or browser back.
+ const profileRequested = /^\d+$/.test(state.userFilters.profile);
+ if (!profileRequested && (!state.sections.lifecycle.loaded || !state.sections.users.loaded)) refreshPrimary();
}
function syncAuth(user) {
diff --git a/dashboard/frontend/js/admin-analytics.js b/dashboard/frontend/js/admin-analytics.js
index dc624bc1..e123c6e6 100644
--- a/dashboard/frontend/js/admin-analytics.js
+++ b/dashboard/frontend/js/admin-analytics.js
@@ -66,7 +66,7 @@
status: 'all',
sort: 'recent_failures',
},
- profile: { userId: null, detail: null, section: 'overview', sections: {} },
+ profile: { userId: null, detail: null, section: 'overview', sections: {}, parentUrl: null, parentScrollY: 0 },
trendChart: null,
};
@@ -173,6 +173,12 @@
else url.searchParams.set(key, String(value));
}
+ function analyticsParentUrl() {
+ const url = new URL(window.location.href);
+ ['analyticsUser', 'analyticsProfile', 'analyticsSection'].forEach((key) => url.searchParams.delete(key));
+ return `${url.pathname}${url.search}${url.hash}`;
+ }
+
function replaceAnalyticsUrl({ userId = state.profile.userId, section = state.profile.section } = {}) {
const filters = activeFilters();
if (!filters) return;
@@ -193,6 +199,27 @@
window.history.replaceState(window.history.state, '', url);
}
+ function pushProfileUrl() {
+ const filters = activeFilters();
+ if (!filters) return;
+ const url = new URL(window.location.href);
+ url.searchParams.set('adminTab', 'analytics');
+ url.searchParams.set('analyticsStart', filters.start);
+ url.searchParams.set('analyticsEnd', filters.end);
+ url.searchParams.set('analyticsBilling', filters.billingMode);
+ setOptionalParam(url, 'analyticsProvider', filters.provider);
+ setOptionalParam(url, 'analyticsModel', filters.model);
+ if (filters.includeInternal) url.searchParams.set('analyticsInternal', 'true');
+ else url.searchParams.delete('analyticsInternal');
+ setOptionalParam(url, 'analyticsUser', state.profile.userId);
+ setOptionalParam(url, 'analyticsProfile', state.profile.userId);
+ url.searchParams.set('analyticsSection', state.profile.section || 'overview');
+ window.history.pushState({ ...(window.history.state || {}), analyticsProfileEntry: true }, '', url);
+ const parent = state.profile.parentUrl || analyticsParentUrl();
+ const breadcrumb = element('adminAnalyticsProfileBreadcrumbParent');
+ if (breadcrumb) breadcrumb.href = parent;
+ }
+
async function handleAccessLost(error) {
if (error?.status !== 401 && error?.status !== 403) return false;
if (typeof window.refreshAuthUser === 'function') await window.refreshAuthUser();
@@ -764,6 +791,7 @@
function renderProfile(profile) {
state.profile.detail = profile;
setProfileText('adminAnalyticsProfileTitle', profile.display_name || profile.email || `User #${profile.user_id}`);
+ setProfileText('adminAnalyticsProfileBreadcrumbCurrent', profile.display_name || profile.email || `User #${profile.user_id}`);
setProfileText('adminAnalyticsProfileEmail', profile.email);
setProfileText('adminAnalyticsProfileUserId', profile.user_id);
setProfileTime('adminAnalyticsProfileJoined', profile.joined_at);
@@ -819,8 +847,9 @@
return /^\d+$/.test(String(value || '')) && Number(value) > 0;
}
- function openProfile(userId, { section = 'overview', focus = true } = {}) {
+ function openProfile(userId, { section = 'overview', focus = true, history = true } = {}) {
if (!validUserId(userId)) return;
+ const parentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
state.userRequestSeq += 1;
state.profile.userId = String(userId);
state.profile.detail = null;
@@ -831,21 +860,36 @@
element('adminAnalyticsProfileError').hidden = true;
setProfileText('adminAnalyticsProfileTitle', 'Loading user analytics');
setProfileText('adminAnalyticsProfileEmail', '—');
- selectProfileSection(state.profile.section, { focus: false, load: false });
- replaceAnalyticsUrl();
+ setProfileText('adminAnalyticsProfileBreadcrumbCurrent', 'User analytics profile');
+ selectProfileSection(state.profile.section, { focus: false, load: false, updateUrl: false });
+ if (history) {
+ state.profile.parentScrollY = window.scrollY;
+ state.profile.parentUrl = parentUrl;
+ pushProfileUrl();
+ } else {
+ state.profile.parentUrl = analyticsParentUrl();
+ replaceAnalyticsUrl();
+ }
if (focus) element('adminAnalyticsProfileTitle')?.focus();
loadProfile(state.profile.userId);
}
- function closeProfile({ focus = true } = {}) {
+ function closeProfile({ focus = true, history = true } = {}) {
+ if (history && window.history.state?.analyticsProfileEntry && window.history.length > 1) {
+ window.history.back();
+ return;
+ }
+ const parentScrollY = state.profile.parentScrollY;
state.userRequestSeq += 1;
state.profile.userId = null;
state.profile.detail = null;
state.profile.section = 'overview';
+ state.profile.parentUrl = null;
resetProfileSections();
element('adminAnalyticsProfile').hidden = true;
element('adminAnalyticsOverview').hidden = false;
replaceAnalyticsUrl({ userId: null, section: 'overview' });
+ if (Number.isFinite(parentScrollY)) window.scrollTo({ top: parentScrollY, behavior: 'auto' });
if (focus) element('adminPriorityUsersTitle')?.focus();
}
@@ -853,7 +897,7 @@
return document.querySelector(`[data-analytics-section-panel="${section}"]`);
}
- function selectProfileSection(value, { focus = false, load = true } = {}) {
+ function selectProfileSection(value, { focus = false, load = true, updateUrl = true } = {}) {
const section = PROFILE_SECTIONS.includes(value) ? value : 'overview';
state.profile.section = section;
document.querySelectorAll('[data-analytics-section-tab]').forEach((button) => {
@@ -866,7 +910,7 @@
document.querySelectorAll('[data-analytics-section-panel]').forEach((panel) => {
panel.hidden = panel.dataset.analyticsSectionPanel !== section;
});
- replaceAnalyticsUrl();
+ if (updateUrl) replaceAnalyticsUrl();
if (load && section !== 'overview' && !state.profile.sections[section]?.loaded) {
loadProfileSection(section, { append: false });
}
@@ -1033,8 +1077,8 @@
const params = new URLSearchParams(window.location.search);
const userId = params.get('analyticsUser');
const section = params.get('analyticsSection') || 'overview';
- if (validUserId(userId)) openProfile(userId, { section, focus: false });
- else if (state.profile.userId) closeProfile({ focus: false });
+ if (validUserId(userId)) openProfile(userId, { section, focus: false, history: false });
+ else if (state.profile.userId) closeProfile({ focus: false, history: false });
}
function bindEvents() {
@@ -1072,6 +1116,12 @@
if (button) openProfile(button.dataset.analyticsUserId);
});
element('adminAnalyticsProfileBack')?.addEventListener('click', () => closeProfile());
+ element('adminAnalyticsProfileBreadcrumbParent')?.addEventListener('click', (event) => {
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
+ if (!state.profile.userId) return;
+ event.preventDefault();
+ closeProfile();
+ });
document.querySelectorAll('[data-analytics-section-tab]').forEach((button) => {
button.addEventListener('click', () => selectProfileSection(button.dataset.analyticsSectionTab));
button.addEventListener('keydown', (event) => {
@@ -1100,7 +1150,7 @@
event.preventDefault();
const user = state.profile.detail;
if (!user) return;
- closeProfile({ focus: false });
+ closeProfile({ focus: false, history: false });
window.AdminTabs?.openAccountManagement({ userId: user.user_id, email: user.email });
});
document.addEventListener('admin:tabchange', (event) => {
diff --git a/dashboard/frontend/styles.css b/dashboard/frontend/styles.css
index 5b523b11..8602b835 100644
--- a/dashboard/frontend/styles.css
+++ b/dashboard/frontend/styles.css
@@ -2817,6 +2817,11 @@ a.header-brand:hover .title-section h1 {
gap: 8px;
}
+.admin-help-btn {
+ border-radius: 50%;
+ font: 700 14px var(--font-mono);
+}
+
.admin-value-filters {
display: grid;
grid-template-columns: repeat(2, minmax(145px, 185px)) minmax(220px, 1fr) auto;
@@ -2953,6 +2958,47 @@ a.header-brand:hover .title-section h1 {
letter-spacing: 0;
}
+.admin-value-movement-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.admin-value-range-switch {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 3px;
+ border: 1px solid rgba(148, 163, 184, 0.2);
+ border-radius: 999px;
+ background: rgba(2, 6, 23, 0.34);
+}
+
+.admin-value-range-switch button {
+ min-width: 34px;
+ min-height: 27px;
+ padding: 4px 8px;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--text-muted);
+ font: 700 10px var(--font-mono);
+ letter-spacing: 0.04em;
+ cursor: pointer;
+}
+
+.admin-value-range-switch button:hover,
+.admin-value-range-switch button:focus-visible {
+ color: var(--text-primary);
+}
+
+.admin-value-range-switch button[aria-pressed="true"] {
+ background: rgba(103, 232, 249, 0.16);
+ color: #cffafe;
+}
+
.admin-value-coverage,
.admin-value-quality {
color: var(--text-muted);
@@ -3067,6 +3113,19 @@ a.header-brand:hover .title-section h1 {
font-size: 13px;
}
+.admin-priority-profile-link {
+ color: inherit;
+ text-decoration: none;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 3px;
+}
+
+.admin-priority-profile-link:hover,
+.admin-priority-profile-link:focus-visible {
+ color: #cffafe;
+ text-decoration: underline;
+}
+
.admin-priority-identity span,
.admin-priority-reason {
color: var(--text-muted);
@@ -3293,6 +3352,15 @@ a.header-brand:hover .title-section h1 {
align-items: center;
}
+ .admin-value-section-head {
+ flex-direction: column;
+ }
+
+ .admin-value-movement-actions {
+ justify-content: flex-start;
+ width: 100%;
+ }
+
.admin-value-header h3 {
font-size: 20px;
}
@@ -14631,6 +14699,32 @@ table.sr-only {
padding: 22px 0 50px;
}
+.admin-analytics-breadcrumbs {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 10px;
+ color: var(--text-muted);
+ font-size: 11px;
+}
+
+.admin-analytics-breadcrumbs a {
+ color: #67e8f9;
+ text-decoration: none;
+}
+
+.admin-analytics-breadcrumbs a:hover,
+.admin-analytics-breadcrumbs a:focus-visible {
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
+
+.admin-analytics-breadcrumbs span:last-child {
+ color: var(--text-secondary);
+ font-weight: 700;
+}
+
#adminAnalyticsProfileBack {
margin-bottom: 16px;
}
From 58c54b0a509922ff44ddfa47d7854ef46fd7dfb6 Mon Sep 17 00:00:00 2001
From: Haoxiang Cheng <2739441541@qq.com>
Date: Sat, 5 Sep 2026 15:22:02 +0800
Subject: [PATCH 3/5] fix: place analytics help button beside identity
---
.../backend/tests/test_admin_analytics_frontend.py | 2 +-
.../tests/test_admin_analytics_value_frontend.py | 4 ++++
.../tests/test_backtest_comparison_frontend.py | 2 +-
dashboard/backend/tests/test_frontend_fast_boot.py | 2 +-
dashboard/frontend/app.html | 8 +++++---
dashboard/frontend/styles.css | 12 ++++++++++++
6 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/dashboard/backend/tests/test_admin_analytics_frontend.py b/dashboard/backend/tests/test_admin_analytics_frontend.py
index ac1ac59b..9f77d23b 100644
--- a/dashboard/backend/tests/test_admin_analytics_frontend.py
+++ b/dashboard/backend/tests/test_admin_analytics_frontend.py
@@ -225,7 +225,7 @@ def test_app_lifecycle_and_cache_versions_are_wired():
assert "window.AdminAnalytics.refresh()" in APP_JS
assert "window.AdminAnalyticsValue.syncAuth(user)" in APP_JS
assert "window.AdminAnalyticsValue.onEnter()" in APP_JS
- assert 'styles.css?v=136' in APP_HTML
+ assert 'styles.css?v=137' in APP_HTML
assert 'app.js?v=128' in APP_HTML
assert 'js/admin-analytics.js?v=6' in APP_HTML
assert 'js/admin-analytics-value.js?v=4' in APP_HTML
diff --git a/dashboard/backend/tests/test_admin_analytics_value_frontend.py b/dashboard/backend/tests/test_admin_analytics_value_frontend.py
index 4b2897c4..7675bb54 100644
--- a/dashboard/backend/tests/test_admin_analytics_value_frontend.py
+++ b/dashboard/backend/tests/test_admin_analytics_value_frontend.py
@@ -200,6 +200,10 @@ def test_movement_ranges_and_profile_navigation_are_discoverable():
assert "admin-help-btn" in APP_HTML
assert 'aria-label="How segments work"' in APP_HTML
assert 'id="adminAnalyticsProfileBreadcrumbParent"' in APP_HTML
+ header_start = APP_HTML.index('class="admin-value-header"')
+ identity_start = APP_HTML.index('id="adminLifecycleDistributionTitle"')
+ assert 'id="adminAnalyticsRulesOpen"' not in APP_HTML[header_start:identity_start]
+ assert 'id="adminAnalyticsRulesOpen"' in APP_HTML[identity_start:identity_start + 700]
def test_value_formatting_uses_intl_and_dialogs_bound_scroll():
diff --git a/dashboard/backend/tests/test_backtest_comparison_frontend.py b/dashboard/backend/tests/test_backtest_comparison_frontend.py
index ffff3f77..f87d6938 100644
--- a/dashboard/backend/tests/test_backtest_comparison_frontend.py
+++ b/dashboard/backend/tests/test_backtest_comparison_frontend.py
@@ -192,7 +192,7 @@ def test_exact_raw_ties_mark_every_tied_series_best():
def test_comparison_script_and_semantic_table_ship_before_app():
helper = ''
app = ''
- assert 'href="styles.css?v=136"' in APP_HTML
+ assert 'href="styles.css?v=137"' in APP_HTML
assert APP_HTML.index(helper) < APP_HTML.index(app)
for element_id in (
"performanceLegend",
diff --git a/dashboard/backend/tests/test_frontend_fast_boot.py b/dashboard/backend/tests/test_frontend_fast_boot.py
index d8a4e893..87dea072 100644
--- a/dashboard/backend/tests/test_frontend_fast_boot.py
+++ b/dashboard/backend/tests/test_frontend_fast_boot.py
@@ -193,7 +193,7 @@ def test_cache_busters_bumped():
# round of follow-ups (#347/#348).
assert "app.js?v=128" in APP_HTML
assert "js/agent-editor.js?v=30" in APP_HTML
- assert "styles.css?v=136" in APP_HTML
+ assert "styles.css?v=137" in APP_HTML
assert "js/leaderboard.js?v=32" in APP_HTML
assert "home-page.js?v=50" in APP_HTML
assert "js/credit-format.js?v=1" in APP_HTML
diff --git a/dashboard/frontend/app.html b/dashboard/frontend/app.html
index 5b3a1287..a53ceb0d 100644
--- a/dashboard/frontend/app.html
+++ b/dashboard/frontend/app.html
@@ -13,7 +13,7 @@
because every API call is a CORS request. -->
-
+
@@ -2131,7 +2131,6 @@
Analytics
See who reached value, who returned, and where attention can change an outcome.
@@ -2157,7 +2156,10 @@ Analytics
Current identity
Lifecycle distribution
-
+
+
+ ?
+
diff --git a/dashboard/frontend/styles.css b/dashboard/frontend/styles.css
index 8602b835..767cb9cb 100644
--- a/dashboard/frontend/styles.css
+++ b/dashboard/frontend/styles.css
@@ -2966,6 +2966,13 @@ a.header-brand:hover .title-section h1 {
gap: 8px;
}
+.admin-value-identity-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
.admin-value-range-switch {
display: inline-flex;
align-items: center;
@@ -3356,6 +3363,11 @@ a.header-brand:hover .title-section h1 {
flex-direction: column;
}
+ .admin-value-primary-grid > .admin-value-card:first-child .admin-value-section-head {
+ flex-direction: row;
+ align-items: flex-start;
+ }
+
.admin-value-movement-actions {
justify-content: flex-start;
width: 100%;
From 316f0bc8ba9e1ba0b0b40efbb0ae17a173456438 Mon Sep 17 00:00:00 2001
From: FlyM1ss
Date: Sun, 6 Sep 2026 02:31:26 +0800
Subject: [PATCH 4/5] fix(admin-analytics): correct movement windowing and
profile navigation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review follow-ups on the movement-range/profile work.
Backend (value_queries.py) — widening the daily scan to cover the movement
window took the requested range away from three readers that assumed it:
- transitions summed lifecycle_transition rollups from before `start` while
still stamping the result `period_start=start`; the direct-rows loop and the
weekly loop both filter, so the rollup loop now does too
- availability.history described the movement history rather than the range the
admin asked for, and its new coverage clause pinned status to "partial" for
every 1Y view
- a calendar bucket may open before the chart's own window, so its label is
clamped to the window start instead of naming an excluded date
The 180-day cap was a bare literal here and a named constant in the router;
both now read one exported constant, and the daily scan span is derived from
the movement table so a longer range cannot widen it unnoticed.
Frontend — `analyticsProfile` had two writers. The value module kept a cached
copy that nothing cleared on close, so the overview stayed blank behind a
closed profile and the next filter edit put the closed profile back into the
URL. The profile controller now owns the param outright and the overview guard
reads the live URL; openAccountManagement clears it alongside analyticsUser.
The range switch refreshes the chart without refetching priority users, and its
roving tabindex is backed by radiogroup semantics.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01W2SXWfkLjG1SJBzVv8Embh
---
.../backend/api/routers/admin_analytics.py | 3 +-
.../backend/domain/analytics/value_queries.py | 39 ++++-
dashboard/backend/tests/_frontend_source.py | 12 +-
.../domain/analytics/test_value_queries.py | 144 +++++++++++++++++-
.../tests/test_admin_analytics_frontend.py | 26 +++-
.../test_admin_analytics_value_frontend.py | 56 ++++++-
.../tests/test_admin_credits_frontend.py | 2 +-
.../test_backtest_comparison_frontend.py | 2 +-
.../backend/tests/test_frontend_fast_boot.py | 2 +-
dashboard/frontend/app.html | 16 +-
.../frontend/js/admin-analytics-value.js | 36 +++--
dashboard/frontend/js/admin-tabs.js | 1 +
dashboard/frontend/styles.css | 2 +-
13 files changed, 294 insertions(+), 47 deletions(-)
diff --git a/dashboard/backend/api/routers/admin_analytics.py b/dashboard/backend/api/routers/admin_analytics.py
index 63f6e22b..ea0152f4 100644
--- a/dashboard/backend/api/routers/admin_analytics.py
+++ b/dashboard/backend/api/routers/admin_analytics.py
@@ -24,6 +24,7 @@
get_analytics_service,
)
from dashboard.backend.domain.analytics.value_queries import (
+ MAX_VALUE_RANGE_DAYS,
CommercialAnalyticsResponse,
LifecycleAnalyticsResponse,
OperationalAnalyticsResponse,
@@ -55,7 +56,7 @@
_OPERATIONAL_STATES = {"blocked", "needs_attention", "healthy"}
_COMMERCIAL_TIERS = {"unpaid", "starter", "invested", "high_value"}
_LIFECYCLE_MOVEMENT_RANGES = {"5d", "1w", "1m", "1y"}
-_MAX_VALUE_RANGE_DAYS = 180
+_MAX_VALUE_RANGE_DAYS = MAX_VALUE_RANGE_DAYS
def _invalid_query() -> Never:
diff --git a/dashboard/backend/domain/analytics/value_queries.py b/dashboard/backend/domain/analytics/value_queries.py
index 53481cb0..66b4d0be 100644
--- a/dashboard/backend/domain/analytics/value_queries.py
+++ b/dashboard/backend/domain/analytics/value_queries.py
@@ -60,6 +60,25 @@
"1m": (31, "week"),
"1y": (365, "month"),
}
+MAX_VALUE_RANGE_DAYS = 180
+"""Longest filter range a caller may request, in days.
+
+The router enforces the same bound on the query string; this is the copy that
+holds for every caller, and both now read it from here. It was duplicated as a
+bare literal, which is how the two could drift apart unnoticed.
+"""
+
+_MAX_HISTORY_SCAN_DAYS = max(
+ MAX_VALUE_RANGE_DAYS, *(days for days, _granularity in _MOVEMENT_WINDOWS.values())
+)
+"""Widest span of per-user daily rows one lifecycle request may scan.
+
+`MAX_VALUE_RANGE_DAYS` bounds the *filter* range, but the movement chart reads a
+window of its own, so the scan is the wider of the two -- 365 days today, double
+the filter cap. Derived rather than written down so that adding a longer
+movement range cannot silently widen every user's scan.
+"""
+
_PRIORITY_RANK = {
"blocked": 0,
"needs_attention": 1,
@@ -105,8 +124,10 @@ def _validate_dates(start: date, end: date) -> tuple[date, date]:
raise ValueError("end must be a date")
if end <= start:
raise ValueError("end must be later than start")
- if (end - start).days > 180:
- raise ValueError("date range must contain at most 180 days")
+ if (end - start).days > MAX_VALUE_RANGE_DAYS:
+ raise ValueError(
+ f"date range must contain at most {MAX_VALUE_RANGE_DAYS} days"
+ )
return start, end
@@ -493,7 +514,10 @@ def _history(
selected_movement_start = movement_start or end - timedelta(days=window_days)
if selected_movement_start >= end:
raise ValueError("lifecycle movement range is empty")
- history_start = min(start, selected_movement_start)
+ history_start = max(
+ min(start, selected_movement_start),
+ end - timedelta(days=_MAX_HISTORY_SCAN_DAYS),
+ )
rows = self._daily(
user_ids,
start=history_start - timedelta(days=1),
@@ -564,7 +588,7 @@ def _history(
latest = max(dates)
movement.append(
LifecycleMovementPoint(
- period_start=period,
+ period_start=max(period, selected_movement_start),
segment_counts=daily_counts[latest],
data_quality=daily_quality[latest],
)
@@ -588,6 +612,7 @@ def _history(
for row in rollups:
if (
row.metric_name != "lifecycle_transition"
+ or not start <= row.rollup_date < end
or row.rollup_date in direct_dates
):
continue
@@ -613,7 +638,7 @@ def _history(
key=lambda item: (-item[1], item[0]),
)
]
- coverage = sorted(daily_counts)
+ coverage = sorted(day for day in daily_counts if start <= day < end)
if not coverage:
availability = SectionAvailability(
available=False,
@@ -621,8 +646,8 @@ def _history(
)
else:
partial = (
- any(value == "partial" for value in daily_quality.values())
- or coverage[0] > history_start
+ any(daily_quality[day] == "partial" for day in coverage)
+ or coverage[0] > start
or coverage[-1] < end - timedelta(days=1)
)
availability = SectionAvailability(
diff --git a/dashboard/backend/tests/_frontend_source.py b/dashboard/backend/tests/_frontend_source.py
index 59079737..1f065af8 100644
--- a/dashboard/backend/tests/_frontend_source.py
+++ b/dashboard/backend/tests/_frontend_source.py
@@ -131,9 +131,12 @@ def _match_brace(source: str, index: int) -> int:
index += 1
-def fn_body(signature: str) -> str:
+def fn_body(signature: str, source: str | None = None) -> str:
"""The named function's source, brace-matched to its real closing brace.
+ `source` defaults to app.js; pass one of the `js/*.js` modules to slice a
+ function out of it with the same brace matching.
+
Brace-matching rather than a fixed-width slice: a `[start:start + 900]`
window over-reads into whatever unrelated top-level code happens to follow,
so an assertion can pass on a neighbour's source instead of the function
@@ -145,9 +148,10 @@ def fn_body(signature: str) -> str:
returns that parameter block instead of the body: a short, plausible-looking
string in which every `assert "..." in body` fails, or worse, passes.
"""
- start = APP_JS.index(signature)
- open_brace = APP_JS.index("{", match_paren(APP_JS, APP_JS.index("(", start)))
- return APP_JS[start : _match_brace(APP_JS, open_brace) + 1]
+ text = APP_JS if source is None else source
+ start = text.index(signature)
+ open_brace = text.index("{", match_paren(text, text.index("(", start)))
+ return text[start : _match_brace(text, open_brace) + 1]
def js_const(name: str) -> str:
diff --git a/dashboard/backend/tests/domain/analytics/test_value_queries.py b/dashboard/backend/tests/domain/analytics/test_value_queries.py
index f9c68208..66835036 100644
--- a/dashboard/backend/tests/domain/analytics/test_value_queries.py
+++ b/dashboard/backend/tests/domain/analytics/test_value_queries.py
@@ -13,6 +13,8 @@
AnalyticsUserProfile,
)
from dashboard.backend.domain.analytics.value_queries import (
+ _MAX_HISTORY_SCAN_DAYS,
+ _MOVEMENT_WINDOWS,
UserValueFilters,
ValueAnalyticsQueryService,
)
@@ -129,6 +131,7 @@ def __init__(self, *, snapshots, commercial, daily=(), credit_activity=None):
self.daily = list(daily)
self.credit_activity = dict(credit_activity or {})
self.commercial_windows = []
+ self.daily_windows = []
def list_current_snapshots(self, user_ids):
return {
@@ -149,6 +152,7 @@ def list_commercial_values(self, user_ids, *, start, end):
}
def list_daily_snapshots(self, *, start, end, user_ids=None):
+ self.daily_windows.append((start, end))
selected = None if user_ids is None else set(user_ids)
return [
row
@@ -304,11 +308,11 @@ def test_date_filter_changes_history_not_current_lifecycle_identity():
@pytest.mark.parametrize(
- ("movement_range", "granularity", "expected_max_points"),
- [("5d", "day", 5), ("1w", "day", 7), ("1m", "week", 5), ("1y", "month", 12)],
+ ("movement_range", "granularity"),
+ [("5d", "day"), ("1w", "day"), ("1m", "week"), ("1y", "month")],
)
def test_lifecycle_movement_returns_selected_range_and_granularity(
- movement_range, granularity, expected_max_points
+ movement_range, granularity
):
snapshots = {1: _snapshot(1)}
daily = [
@@ -326,8 +330,19 @@ def test_lifecycle_movement_returns_selected_range_and_granularity(
assert response.movement_range == movement_range
assert response.movement_granularity == granularity
- assert 0 < len(response.movement_segments) <= expected_max_points
- assert all(point.period_start for point in response.movement_segments)
+ starts = [point.period_start for point in response.movement_segments]
+ assert starts
+ assert starts == sorted(set(starts))
+ # Derived from the window rather than written down: a calendar bucket count
+ # depends on where the window falls in the calendar, so a hard-coded ceiling
+ # holds only for the dates this case happens to pick.
+ window_days = _MOVEMENT_WINDOWS[movement_range][0]
+ period_days = {"day": 1, "week": 7, "month": 28}[granularity]
+ assert len(starts) <= window_days // period_days + 2
+ assert all(
+ date(2026, 10, 1) - timedelta(days=window_days) <= day < date(2026, 10, 1)
+ for day in starts
+ )
def test_retention_uses_nulls_for_immature_cells_and_weighted_mature_summary():
@@ -600,3 +615,122 @@ def test_internal_accounts_are_excluded_unless_explicitly_included():
assert [item.user_id for item in external.items] == [1]
assert [item.user_id for item in all_users.items] == [1, 2]
+
+
+def _transition_rollup(day: date, count: int, outcome: str = "complete"):
+ return SimpleNamespace(
+ rollup_date=day,
+ metric_name="lifecycle_transition",
+ event_name="growing",
+ user_state="core",
+ value_count=count,
+ outcome=outcome,
+ )
+
+
+def test_lifecycle_transitions_ignore_rollups_outside_the_requested_window():
+ """A long movement range widens the scan; it must not widen the totals.
+
+ `transitions` is stamped with the requested period, so a rollup from before
+ `start` that is summed in reports itself as having happened inside a window
+ it predates.
+ """
+ start = date(2026, 9, 1)
+ end = date(2026, 10, 1)
+ daily = [
+ _daily(1, start + timedelta(days=offset))
+ for offset in range(30)
+ if start + timedelta(days=offset) != date(2026, 9, 10)
+ ]
+ service, _value_store, _legacy = _service(
+ snapshots={1: _snapshot(1)},
+ daily=daily,
+ rollups=[
+ _transition_rollup(date(2026, 9, 10), 2),
+ _transition_rollup(date(2026, 1, 15), 97),
+ ],
+ )
+
+ response = service.get_lifecycle(
+ start=start,
+ end=end,
+ movement_range="1y",
+ now=datetime(2026, 10, 1, 12, tzinfo=UTC),
+ )
+
+ assert [
+ (row.from_segment, row.to_segment, row.users) for row in response.transitions
+ ] == [("growing", "core", 2)]
+
+
+def test_lifecycle_coverage_reports_the_requested_window_not_the_movement_history():
+ """Coverage describes the window the admin asked for.
+
+ The movement chart needs a wider scan than the filter range, but
+ `availability.history` is what the Lifecycle distribution card renders, so
+ it must keep describing `start..end`.
+ """
+ start = date(2026, 9, 1)
+ end = date(2026, 10, 1)
+ daily = [_daily(1, start + timedelta(days=offset)) for offset in range(30)]
+ daily.append(_daily(1, date(2025, 12, 1), quality="partial"))
+ service, _value_store, _legacy = _service(snapshots={1: _snapshot(1)}, daily=daily)
+
+ response = service.get_lifecycle(
+ start=start,
+ end=end,
+ movement_range="1y",
+ now=datetime(2026, 10, 1, 12, tzinfo=UTC),
+ )
+
+ history = response.availability["history"]
+ assert history.coverage_start == start
+ assert history.coverage_end == end - timedelta(days=1)
+ assert history.status == "ready"
+
+
+def test_lifecycle_movement_buckets_never_start_before_the_selected_window():
+ """Calendar bucketing must not label a point outside the chart's own range."""
+ start = date(2026, 9, 15)
+ end = date(2026, 10, 15)
+ window_start = end - timedelta(days=365)
+ daily = [_daily(1, window_start + timedelta(days=offset)) for offset in range(366)]
+ service, _value_store, _legacy = _service(snapshots={1: _snapshot(1)}, daily=daily)
+
+ response = service.get_lifecycle(
+ start=start,
+ end=end,
+ movement_range="1y",
+ now=datetime(2026, 10, 15, 12, tzinfo=UTC),
+ )
+
+ assert response.movement_segments
+ assert min(point.period_start for point in response.movement_segments) >= window_start
+
+
+@pytest.mark.parametrize("movement_range", sorted(_MOVEMENT_WINDOWS))
+def test_lifecycle_daily_scan_stays_within_the_derived_history_bound(movement_range):
+ """Pins the widest span of per-user rows one request may read.
+
+ Not a regression test -- `_validate_dates` already bounds the filter range,
+ so the scan is bounded today. It is a drift guard: the movement window is
+ read from a table, and a longer entry added to that table would widen this
+ scan for every eligible user with nothing else noticing.
+ """
+ start = date(2026, 9, 1)
+ end = date(2026, 10, 1)
+ service, value_store, _legacy = _service(
+ snapshots={1: _snapshot(1)},
+ daily=[_daily(1, start + timedelta(days=offset)) for offset in range(30)],
+ )
+
+ service.get_lifecycle(
+ start=start,
+ end=end,
+ movement_range=movement_range,
+ now=datetime(2026, 10, 1, 12, tzinfo=UTC),
+ )
+
+ scan_start, scan_end = value_store.daily_windows[-1]
+ assert scan_end == end
+ assert scan_start >= end - timedelta(days=_MAX_HISTORY_SCAN_DAYS + 1)
diff --git a/dashboard/backend/tests/test_admin_analytics_frontend.py b/dashboard/backend/tests/test_admin_analytics_frontend.py
index 9f77d23b..fb7b85a0 100644
--- a/dashboard/backend/tests/test_admin_analytics_frontend.py
+++ b/dashboard/backend/tests/test_admin_analytics_frontend.py
@@ -15,7 +15,12 @@
RetentionAnalyticsResponse,
ValueUserProfile,
)
-from dashboard.backend.tests._frontend_source import APP_HTML, APP_JS, STYLES
+from dashboard.backend.tests._frontend_source import (
+ APP_HTML,
+ APP_JS,
+ STYLES,
+ fn_body,
+)
ROOT = Path(__file__).resolve().parents[2]
@@ -140,6 +145,19 @@ def test_admin_rail_is_accessible_default_and_url_backed():
assert "openAccountManagement" in tabs
+def test_account_management_clears_every_analytics_profile_param():
+ """Leaving analytics for a user's account must not leave a profile behind.
+
+ `analyticsProfile` was added alongside `analyticsUser` but not to this list,
+ so the only thing clearing it was the caller happening to close the profile
+ first. A stale one here lands on a URL the overview guard refuses to load.
+ """
+ tabs = ADMIN_TABS_JS_PATH.read_text(encoding="utf-8")
+ body = fn_body("function openAccountManagement(", tabs)
+ for key in ("analyticsUser", "analyticsProfile", "analyticsSection"):
+ assert f"searchParams.delete('{key}')" in body
+
+
def test_credits_navigation_remains_horizontal():
start = APP_HTML.index('", start)
@@ -225,11 +243,11 @@ def test_app_lifecycle_and_cache_versions_are_wired():
assert "window.AdminAnalytics.refresh()" in APP_JS
assert "window.AdminAnalyticsValue.syncAuth(user)" in APP_JS
assert "window.AdminAnalyticsValue.onEnter()" in APP_JS
- assert 'styles.css?v=137' in APP_HTML
+ assert 'styles.css?v=138' in APP_HTML
assert 'app.js?v=128' in APP_HTML
assert 'js/admin-analytics.js?v=6' in APP_HTML
- assert 'js/admin-analytics-value.js?v=4' in APP_HTML
- assert 'js/admin-tabs.js?v=4' in APP_HTML
+ assert 'js/admin-analytics-value.js?v=5' in APP_HTML
+ assert 'js/admin-tabs.js?v=5' in APP_HTML
def test_credit_costs_use_the_shared_exact_formatter():
diff --git a/dashboard/backend/tests/test_admin_analytics_value_frontend.py b/dashboard/backend/tests/test_admin_analytics_value_frontend.py
index 7675bb54..4967cd7f 100644
--- a/dashboard/backend/tests/test_admin_analytics_value_frontend.py
+++ b/dashboard/backend/tests/test_admin_analytics_value_frontend.py
@@ -2,7 +2,12 @@
from pathlib import Path
-from dashboard.backend.tests._frontend_source import APP_HTML, STYLES
+from dashboard.backend.tests._frontend_source import (
+ APP_HTML,
+ STYLES,
+ fn_body,
+ strip_comments,
+)
ROOT = Path(__file__).resolve().parents[2]
@@ -216,3 +221,52 @@ def test_value_formatting_uses_intl_and_dialogs_bound_scroll():
assert "overscroll-behavior: contain" in STYLES
assert "touch-action: manipulation" in STYLES
assert "table.sr-only" in STYLES
+
+
+def test_profile_url_param_has_a_single_owner():
+ """`analyticsProfile` is written by the profile controller, nowhere else.
+
+ The value module used to keep its own copy in `state.userFilters` and write
+ it back from `writeUrlState`, so the two disagreed the moment a profile was
+ closed: the id survived in memory, and the next filter edit put the closed
+ profile back into the URL.
+ """
+ value = strip_comments(value_source())
+ assert "state.userFilters.profile" not in value
+ assert "URL_KEYS.profile, state.userFilters.profile" not in value
+ assert "URL_KEYS.profile" in value, "the deep-link guard still reads the key"
+ assert "'analyticsProfile'" in strip_comments(profile_source())
+
+
+def test_overview_deep_link_guard_reads_the_live_url():
+ """The guard that suppresses the overview fetch must not read a cached id.
+
+ Anchored on the URL because that is the state `closeProfile` clears; a
+ cached copy left the overview permanently blank behind a closed profile.
+ """
+ body = strip_comments(fn_body("function onEnter(", value_source()))
+ assert "searchParams" in body
+ assert "state.userFilters" not in body
+
+
+def test_movement_range_change_leaves_priority_users_alone():
+ """The range switch drives the movement chart, not the priority table."""
+ body = strip_comments(fn_body("function setMovementRange(", value_source()))
+ assert "refreshLifecycle()" in body
+ assert "refreshPrimary()" not in body
+
+
+def test_movement_range_switch_carries_radio_semantics():
+ """Roving tabindex needs a role that gives arrow keys a meaning.
+
+ `role="group"` does not, so only the selected button was reachable by Tab
+ and nothing announced that arrows moved between them.
+ """
+ start = APP_HTML.index('id="adminLifecycleMovementRanges"')
+ end = APP_HTML.index("", start)
+ switch = APP_HTML[start:end]
+ assert 'role="radiogroup"' in switch
+ assert switch.count('role="radio"') == 4
+ assert switch.count("aria-checked=") == 4
+ assert "aria-pressed" not in switch
+ assert "aria-checked" in strip_comments(value_source())
diff --git a/dashboard/backend/tests/test_admin_credits_frontend.py b/dashboard/backend/tests/test_admin_credits_frontend.py
index 8fb0a64a..bf8634c9 100644
--- a/dashboard/backend/tests/test_admin_credits_frontend.py
+++ b/dashboard/backend/tests/test_admin_credits_frontend.py
@@ -126,4 +126,4 @@ def test_admin_tabs_have_four_tabs_in_usage_order_and_legacy_alias():
def test_admin_visual_assets_use_fresh_cache_versions():
assert 'js/admin-credits.js?v=6' in APP_HTML
- assert 'js/admin-tabs.js?v=4' in APP_HTML
+ assert 'js/admin-tabs.js?v=5' in APP_HTML
diff --git a/dashboard/backend/tests/test_backtest_comparison_frontend.py b/dashboard/backend/tests/test_backtest_comparison_frontend.py
index f87d6938..46579616 100644
--- a/dashboard/backend/tests/test_backtest_comparison_frontend.py
+++ b/dashboard/backend/tests/test_backtest_comparison_frontend.py
@@ -192,7 +192,7 @@ def test_exact_raw_ties_mark_every_tied_series_best():
def test_comparison_script_and_semantic_table_ship_before_app():
helper = ''
app = ''
- assert 'href="styles.css?v=137"' in APP_HTML
+ assert 'href="styles.css?v=138"' in APP_HTML
assert APP_HTML.index(helper) < APP_HTML.index(app)
for element_id in (
"performanceLegend",
diff --git a/dashboard/backend/tests/test_frontend_fast_boot.py b/dashboard/backend/tests/test_frontend_fast_boot.py
index 87dea072..65d7acd4 100644
--- a/dashboard/backend/tests/test_frontend_fast_boot.py
+++ b/dashboard/backend/tests/test_frontend_fast_boot.py
@@ -193,7 +193,7 @@ def test_cache_busters_bumped():
# round of follow-ups (#347/#348).
assert "app.js?v=128" in APP_HTML
assert "js/agent-editor.js?v=30" in APP_HTML
- assert "styles.css?v=137" in APP_HTML
+ assert "styles.css?v=138" in APP_HTML
assert "js/leaderboard.js?v=32" in APP_HTML
assert "home-page.js?v=50" in APP_HTML
assert "js/credit-format.js?v=1" in APP_HTML
diff --git a/dashboard/frontend/app.html b/dashboard/frontend/app.html
index a53ceb0d..e063c9dd 100644
--- a/dashboard/frontend/app.html
+++ b/dashboard/frontend/app.html
@@ -13,7 +13,7 @@
because every API call is a CORS request. -->
-
+
@@ -2168,11 +2168,11 @@ Analytics
Direction of travel
Recent 5-day movement
-
-
5D
-
1W
-
1M
-
1Y
+
+ 5D
+ 1W
+ 1M
+ 1Y
daily snapshots
Incomplete data
@@ -2662,8 +2662,8 @@
Refund Credits purchase
-
-
+
+