feat(b2b): org analytics dashboard for the manager surface - #3679
Open
blarghmatey wants to merge 2 commits into
Open
feat(b2b): org analytics dashboard for the manager surface#3679blarghmatey wants to merge 2 commits into
blarghmatey wants to merge 2 commits into
Conversation
Adds the reporting half of the B2B org-manager dashboard, alongside the existing seat-administration half (ContractAdminPage): contract KPI cards, a monthly engagement trend, per-course-run performance, and a program funnel, each backed by one of ol-analytics-api's org-scoped materialized views. Keyed on the Keycloak org UUID (sso_organization_id), not the org slug or MITx Online's numeric id — that is the only identifier stable across the JWT, MITx Online and StarRocks, and the one the analytics API filters on (see mitodl/ol-analytics-api#13, mitodl/mitxonline#3789). The published mitxonline client does not declare the field yet, so it is read off the wire through a shim that returns null when absent; the dashboard reports itself unavailable rather than issuing a request with `undefined` in the path. Charts use @mui/x-charts rather than recharts. x-charts reads the existing MUI v6 theme directly, so axis, tooltip and legend styling comes from the same tokens as the rest of the app instead of being threaded through props by hand; it also avoids pulling Redux, immer and a second lodash-alike into the bundle, which recharts 3 ships as runtime dependencies. Chart colors are MIT Learn tokens checked against the data-viz palette rules (lightness band, chroma floor, CVD separation, contrast) rather than picked by eye — two slots are stepped versions of brand hues that fall outside the readable band. Numbers the API withholds under its k-anonymity floor arrive as null. They are rendered as an explained placeholder, never coerced to zero: a suppressed month is a gap in the trend line, and the program funnel is paired with a table so a withheld stage can say so instead of just missing a bar. Auth stays cookie-based; the analytics API sits behind APISIX, which mints the JWT from the session. NEXT_PUBLIC_ANALYTICS_API_BASE_URL is optional so environments without the service still boot. Also extracts the contract admin page's table primitives into components/B2BTable so both surfaces share them, and polyfills structuredClone in the jest setup — jsdom lacks it and x-charts requires it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjK3vzqB6nvLHKUF7686Pj
OpenAPI ChangesNo changes detected Unexpected changes? Ensure your branch is up-to-date with |
Contributor
There was a problem hiding this comment.
Pull request overview
Adds an org-scoped B2B analytics dashboard (reporting half of the org-manager surface) to the Next.js frontend, backed by new ol-analytics-api query factories and an optional analytics API client configuration so environments without the service can still boot.
Changes:
- Introduces
/dashboard/organization/[orgSlug]/analyticswith four analytics sections, behind a new PostHog feature flag. - Adds a new
analyticsAPI client + React Query query factories/types/test-utils forol-analytics-api, plus optional runtime configuration (NEXT_PUBLIC_ANALYTICS_API_BASE_URL). - Extracts shared B2B table primitives to
components/B2BTableand links to the analytics dashboard from the contract admin page.
Reviewed changes
Copilot reviewed 36 out of 37 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Adds @mui/x-charts and related transitive dependencies. |
| frontends/main/validateEnv.js | Adds optional NEXT_PUBLIC_ANALYTICS_API_BASE_URL to env schema. |
| frontends/main/src/test-utils/setupJest.tsx | Configures analytics API client + env var in main workspace Jest setup. |
| frontends/main/src/components/B2BTable/B2BTable.tsx | New shared table primitives for B2B manager surfaces. |
| frontends/main/src/common/urls.ts | Adds organizationAnalyticsView route helper. |
| frontends/main/src/common/feature_flags.ts | Adds B2BAnalyticsDashboard feature flag. |
| frontends/main/src/bootstrap/api.ts | Optionally configures analytics API client based on NEXT_PUBLIC_ANALYTICS_API_BASE_URL. |
| frontends/main/src/app/(site)/dashboard/organization/[orgSlug]/analytics/page.tsx | New App Router page wiring for analytics route. |
| frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx | Main analytics dashboard content: org resolution, feature-flag gating, and section composition. |
| frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx | Page-level tests covering access, availability, freshness, and suppression behavior. |
| frontends/main/src/app-pages/DashboardPage/Analytics/SectionHeader.tsx | New per-section header with as_of freshness handling. |
| frontends/main/src/app-pages/DashboardPage/Analytics/ProgramFunnelChart.tsx | New program funnel chart + accompanying accessible table. |
| frontends/main/src/app-pages/DashboardPage/Analytics/orgUuid.ts | Shim to read sso_organization_id off the wire until mitxonline client is regenerated. |
| frontends/main/src/app-pages/DashboardPage/Analytics/format.tsx | New formatting utilities + suppression marker component (privacy floor). |
| frontends/main/src/app-pages/DashboardPage/Analytics/format.test.tsx | Tests for date/month parsing and suppression rendering. |
| frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx | New engagement trend line chart using @mui/x-charts. |
| frontends/main/src/app-pages/DashboardPage/Analytics/CoursePerformanceTable.tsx | New course performance table using shared B2B table primitives. |
| frontends/main/src/app-pages/DashboardPage/Analytics/ContractKpiCards.tsx | New KPI card rendering for contract utilization. |
| frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx | Smoke tests ensuring charts render and use the intended palette. |
| frontends/main/src/app-pages/DashboardPage/Analytics/chartPalette.ts | Defines validated chart palette and non-data “ink” colors. |
| frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx | Reuses extracted table primitives and adds “View analytics” link behind the new flag. |
| frontends/main/package.json | Adds @mui/x-charts dependency. |
| frontends/jest-shared-setup.ts | Adds structuredClone polyfill needed by @mui/x-charts under jsdom. |
| frontends/api/src/test-utils/setupJest.ts | Configures analytics API client in api workspace Jest setup. |
| frontends/api/src/runtime/index.ts | Adds optional analytics client support and isAnalyticsConfigured(). |
| frontends/api/src/analytics/types.ts | New hand-written response types/envelopes for analytics endpoints. |
| frontends/api/src/analytics/test-utils/urls.ts | New analytics URL builders for tests (absolute URLs). |
| frontends/api/src/analytics/test-utils/index.ts | Exports analytics test factories/urls. |
| frontends/api/src/analytics/test-utils/factories.ts | Factories for analytics response shapes used in tests. |
| frontends/api/src/analytics/hooks/organizations/queries.ts | New React Query query factories/keys for org analytics endpoints. |
| frontends/api/src/analytics/hooks/organizations/queries.test.ts | Tests for URL correctness, key namespacing, paging, and encoding. |
| frontends/api/src/analytics/hooks/organizations/index.ts | Public exports for analytics org hooks and types. |
| frontends/api/src/analytics/clients.ts | New axios client wrapper for org-scoped analytics endpoints. |
| frontends/api/src/analytics/axios.ts | Creates configurable axios instance for analytics API. |
| frontends/api/package.json | Exposes analytics hooks/types/test-utils via package exports. |
| env/frontend.local.example.env | Documents NEXT_PUBLIC_ANALYTICS_API_BASE_URL for local dev. |
| env/frontend.env | Adds NEXT_PUBLIC_ANALYTICS_API_BASE_URL wiring for local env template. |
…ates Both env comments implied the variable controls whether the analytics dashboard is visible. It does not: reachability is gated by the b2b-analytics-dashboard PostHog flag, and with the flag on and this unset the route still renders, reporting itself unavailable. The variable only decides whether there is an API to load data from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SjK3vzqB6nvLHKUF7686Pj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What are the relevant tickets?
N/A — no GitHub issue. This is the frontend piece of the B2B self-serve analytics work.
Related PRs, and the required deploy order:
sso_organization_idonOrganizationPageSerializerand adds the?sso_organization_id=filter to the manager org endpoint. Must deploy first.Identifier analysis: mitodl/hq#11845.
Description (What does it do?)
Adds the reporting half of the B2B org-manager dashboard, alongside the seat-administration half that already exists in
ContractAdminPage. Four sections at/dashboard/organization/[orgSlug]/analytics, each backed by one ofol-analytics-api's org-scoped materialized views:/contract-utilizationmv_b2b_contract_utilization/engagement-trendmv_b2b_monthly_engagement_trend/enrollment-funnelmv_b2b_enrollment_completion_funnel/program-funnelmv_b2b_program_funnelA query factory for the fifth endpoint (
/content-engagement) is included; its panel is deliberately out of scope for this PR.Reachable from a "View analytics" link added to the contract admin page header, behind the same flag.
Charting:
@mui/x-charts, notrechartsThe original scope called for
recharts. After evaluating both against this codebase I went with@mui/x-charts@^8:theme.custom.colorsviaol-components. x-charts reads that theme directly, so axis, tooltip and legend styling comes from the same tokens as everything else. Recharts styles per-component through props, so every token would have to be threaded by hand.@reduxjs/toolkit,react-redux,immerandes-toolkitas runtime dependencies — a second state-management stack inside a chart library, in an app that uses TanStack Query. Roughly 18MB installed vs ~6.6MB for x-charts.@mui/x-charts@8peer-accepts@mui/material ^6.0.0(we're on 6.4.5) and React 19. The charts needed here (Line, Bar) are MIT/community; only the dedicated Funnel widget is Pro, and this uses ordinary bars.Trade-off worth knowing: x-charts pulls
@mui/utils@^7as a direct dependency alongside MUI v6, so that small utils package is duplicated in the tree.Organizations are keyed on the Keycloak UUID, not the slug
Per ol-analytics-api#13, every org endpoint filters on
sso_organization_id— the only identifier stable across the JWT, MITx Online and StarRocks. The route keeps[orgSlug](human-facing, consistent with its sibling routes) andAnalyticsContentresolves slug → manager org → UUID.Because mitxonline#3789 has not been cut into a release of
@mitodl/mitxonline-api-axios(we're pinned to 2026.7.7), the generatedOrganizationPagetype does not declare the field yet.Analytics/orgUuid.tsreads it off the wire and returnsnullwhen absent — so pointed at an older MITx Online, the page shows "Analytics is not available for this organization yet" rather than issuing a request withundefinedin the path. That shim is marked for deletion once the client is regenerated.Suppressed values are never rendered as zero
The API nulls out any learner count below its k-anonymity floor, plus every rate and average derived from it. Those nulls are carried through as nulls:
aria-label, not hover alone.Auth
Unchanged from the rest of the app: cookie-based,
withCredentials+ CSRF, no bearer token. The analytics API sits behind APISIX, which mints the JWT from the session cookie. Authorization is the API's job; this component only resolves which org is meant, via MITx Online's manager org list — the same sourceContractAdminPageuses — so a non-manager never issues an analytics request.Chart colors were validated, not chosen by eye
Hues come from the smoot-design token set, then checked against lightness band, chroma floor, CVD separation under simulated protanopia/deuteranopia, a normal-vision separation floor, and ≥3:1 contrast on the surface. Two slots are stepped versions of brand hues because the tokens themselves fall outside the readable band (
orange#FAB005is far too light to carry a 2px line;lightBlueis too pale to distinguish from the card). Green and red are deliberately excluded from the series palette so they stay reserved for status. Rationale is documented inAnalytics/chartPalette.ts— changing a value there means re-running the check, not swapping a hex.Funnel stages take a single-hue light→dark ramp rather than three unrelated hues, because stage order carries meaning. The trend chart deliberately omits the view's
total_videos_watched/total_problems_attempted/total_chatbot_interactionscolumns: those are counts of events, orders of magnitude above the learner counts, and putting them on the same plot would need a second y-axis.Incidental changes
components/B2BTable— the contract admin page's table primitives are extracted so both manager surfaces share them (including the sub-mdreflow where rows become label/value stacks).ContractAdminPage's 122 tests still pass unchanged.structuredClonepolyfill infrontends/jest-shared-setup.ts— jsdom does not provide it and@mui/x-chartscalls it, so without this any test rendering a chart dies with aReferenceError. Implemented with the v8 serializer rather than a JSON round-trip so Dates/Maps/Sets survive.NEXT_PUBLIC_ANALYTICS_API_BASE_URLis optional in the yup schema. Environments without an analytics deployment still boot;bootstrapApiClients()simply skips configuring that client and the page reports itself unavailable. This avoids breaking startup for every environment that doesn't have the service yet.How can this be tested?
Automated — all green:
30 new tests covering: the five query factories (correct URL per endpoint, paging params, per-org key namespacing, org-id URL encoding); formatters; both charts rendering with the validated palette; and the page's access / availability / freshness / suppression behaviour.
Note on the full suite:
AssignSeatsSection,CoursePage,NewsEditorandSearchPagefail intermittently under parallel load. All pass in isolation, andAssignSeatsSectionandNewsEditorfail identically on an untouchedmaincheckout — pre-existing flakes, not regressions from this branch.Manual, once the dependencies are in place:
/ai/*learn-ai route), and setNEXT_PUBLIC_ANALYTICS_API_BASE_URLto that route — not to the API directly; the API expects the JWT APISIX mints from the session cookie.b2b-analytics-dashboardPostHog flag and enable it for your user./dashboard/organization/<org>/analytics.Worth exercising specifically:
makeBrowserQueryClientsetsthrowOnErrorfor 400/401/403, so those never reach anisErrorbranch. I wrote an in-component 403 branch first and removed it once a test proved it was unreachable.NEXT_PUBLIC_ANALYTICS_API_BASE_URLunset) → "not available" notice, and no request is issued.0.as_of: null) → "Data not yet refreshed" rather than an implied freshness.Locally without the API, set nothing and confirm the page renders the unavailable notice rather than erroring.
Additional Context
KPI cards are per-contract, not rolled up org-wide — deliberately.
mv_b2b_contract_utilization's grain is org × contract, and none of the three headline figures can be honestly aggregated here:active_learnerscounts distinct learners per contract, so summing double-counts anyone on two contracts; andseat_utilization_pct/completion_rate_pctare rates, where averaging across contracts of different sizes is the wrong number and recomputing from raw counts is impossible whenever a count has been suppressed. Most orgs have one contract, so this reads as a single KPI row.A bug this caught:
formatDate("2026-01-15")was rendering as Jan 14 —new Date()parses a date-only ISO string as UTC midnight, whichtoLocaleDateStringthen renders as the previous day for anyone west of Greenwich. A contract starting on the 15th must not display as starting on the 14th. Fixed for both contract dates and theYYYY-MMtrend labels; regression tests cover both.Follow-up work already identified (not in this PR):
ol-analytics-api, replacing the hand-written types infrontends/api/src/analytics/types.ts. Worth flagging for whoever picks it up: the b2b_dashboard tenant is a mounted sub-app, so it owns its own/openapi.jsonand the root schema does not include it; and its endpoints are registered in a loop with a runtime-parametrized generic, so the generated schema needs checking that each row model is named distinctly rather than collapsed into oneOrgAnalyticsResponse.sso_organization_idshim once the regenerated mitxonline client lands.Screenshots are omitted because no environment can render the dashboard until the analytics API is deployed. I'll attach desktop and mobile captures once it's up in CI/RC.