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

- +

Direction of travel

Recent 5-day movement

+
+
+ + + + +
+ daily snapshots + +
-
-
Lifecycle segment counts by week
WeekNewOnboardingGrowingCoreAt riskDormant
+
+
Lifecycle segment counts by period
PeriodNewOnboardingGrowingCoreAt riskDormant
@@ -2308,6 +2317,11 @@
Activation funnel