diff --git a/.github/workflows/openapi-diff.yml b/.github/workflows/openapi-diff.yml index 61e215269c..cbb7770a22 100644 --- a/.github/workflows/openapi-diff.yml +++ b/.github/workflows/openapi-diff.yml @@ -26,6 +26,15 @@ jobs: 'head/openapi/specs/*.yaml' \ | head -1) echo "summary=$SUMMARY" >> $GITHUB_OUTPUT + { + echo "## OpenAPI Changes" + echo "" + echo "${SUMMARY:-No detectable change.}" + echo "" + echo "[View full changelog](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" + echo "" + echo "Unexpected changes? Ensure your branch is up-to-date with \`main\` (consider rebasing)." + } > comment_body.md - name: Find existing comment id: find_comment uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4 @@ -44,14 +53,7 @@ jobs: repository: ${{ github.repository }} issue-number: ${{ github.event.pull_request.number }} comment-id: ${{ steps.find_comment.outputs.comment-id }} - body: | - ## OpenAPI Changes - - ${{ steps.oasdif_changelog.outputs.summary || 'No detectable change.' }} - - [View full changelog](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) - - Unexpected changes? Ensure your branch is up-to-date with `main` (consider rebasing). + body-path: comment_body.md - name: Check for breaking changes id: oasdif_breaking run: | diff --git a/RELEASE.rst b/RELEASE.rst index de60aa92e5..807a934aa6 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,21 @@ Release Notes ============= +Version 0.76.1 +-------------- + +- test: pin course-run end dates so session options stay unambiguous (#3697) +- Readd dropped GTM calls (#3696) +- feat(b2b): org analytics dashboard for the manager surface (#3679) +- Fix flaky test: deterministically sort choices (#3689) +- Defer AI library imports to reduce process startup cost (#3683) +- add ocw topic to search (#3692) +- Identify posthog persons by keycloak global_id, not django pk (#3693) +- Fix oasdiff comment for large diffs (#3694) +- fix garbled non-Latin characters in certificate PDFs (#3685) +- fix: Show error message when a podcast episode fails to play (#3687) +- Strip trailing slash from channel_url (#3691) + Version 0.76.0 (Released July 29, 2026) -------------- diff --git a/channels/models.py b/channels/models.py index eea800a0b6..7ae3121164 100644 --- a/channels/models.py +++ b/channels/models.py @@ -40,7 +40,6 @@ def annotate_channel_url(self): "channel_type", models.Value("/"), "name", - models.Value("/"), ), ), default=None, @@ -163,7 +162,7 @@ def __str__(self): def channel_url(self) -> str | None: """Return the channel url""" if self.published: - return frontend_absolute_url(f"/c/{self.channel_type}/{self.name}/") + return frontend_absolute_url(f"/c/{self.channel_type}/{self.name}") return None @property diff --git a/channels/models_test.py b/channels/models_test.py index acda047d07..8f5cc57f1b 100644 --- a/channels/models_test.py +++ b/channels/models_test.py @@ -37,7 +37,7 @@ def test_channel_url_for_departments(published, channel_type, detail_factory): if published: assert ( urlparse(channel.channel_url).path - == f"/c/{channel_type.name}/{channel.name}/" + == f"/c/{channel_type.name}/{channel.name}" ) else: assert channel.channel_url is None diff --git a/channels/serializers_test.py b/channels/serializers_test.py index c732473dfd..27e68bd094 100644 --- a/channels/serializers_test.py +++ b/channels/serializers_test.py @@ -105,7 +105,7 @@ def test_serialize_channel( # pylint: disable=too-many-arguments "created_on": mocker.ANY, "id": channel.id, "channel_url": frontend_absolute_url( - f"/c/{channel.channel_type}/{channel.name}/" + f"/c/{channel.channel_type}/{channel.name}" ), "lists": [ LearningPathPreviewSerializer(channel_list.channel_list).data diff --git a/env/frontend.env b/env/frontend.env index aabffb43ff..db8b5f5e30 100644 --- a/env/frontend.env +++ b/env/frontend.env @@ -29,6 +29,13 @@ NEXT_PUBLIC_LEARN_AI_CSRF_COOKIE_NAME=csrftoken NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT=https://api.rc.learn.mit.edu/ai/http/recommendation_agent/ NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT=https://api.rc.learn.mit.edu/ai/http/syllabus_agent/ +# OL Analytics API (ol-analytics-api). Auth is cookie-based via APISIX — the +# frontend attaches no token. Leave unset when there is no analytics API to talk +# to: the org analytics dashboard then reports itself unavailable rather than +# loading data. Access to the route itself is gated by the +# b2b-analytics-dashboard PostHog flag, not by this variable. +NEXT_PUBLIC_ANALYTICS_API_BASE_URL=${ANALYTICS_API_BASE_URL} + NEXT_PUBLIC_MITX_ONLINE_BASE_URL=${MITX_ONLINE_BASE_URL} NEXT_PUBLIC_MITX_ONLINE_LEGACY_BASE_URL=http://mitxonline.odl.local:8065/ NEXT_PUBLIC_VERSION="local-dev" diff --git a/env/frontend.local.example.env b/env/frontend.local.example.env index 7c802900be..763bdbaf3e 100644 --- a/env/frontend.local.example.env +++ b/env/frontend.local.example.env @@ -11,3 +11,12 @@ NEXT_PUBLIC_STAY_UPDATED_HUBSPOT_FORM_ID="" # in the README. Requires learn-ai running locally. # NEXT_PUBLIC_LEARN_AI_RECOMMENDATION_ENDPOINT=http://open.odl.local:8065/ai/http/recommendation_agent/ # NEXT_PUBLIC_LEARN_AI_SYLLABUS_ENDPOINT=http://open.odl.local:8065/ai/http/syllabus_agent/ + +# ol-analytics-api integration: supplies the data for the B2B org analytics +# dashboard at /dashboard/organization//analytics. Whether that route is +# reachable at all is controlled by the b2b-analytics-dashboard PostHog flag, +# not by this variable: with the flag on and this unset the page still renders, +# reporting "Analytics is not available in this environment". +# Point at the APISIX route rather than the API directly — the API expects the +# JWT that APISIX mints from the session cookie. +# NEXT_PUBLIC_ANALYTICS_API_BASE_URL=http://open.odl.local:8065/analytics/ diff --git a/frontends/api/package.json b/frontends/api/package.json index 27ff2b75ad..7598ba0093 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -16,7 +16,10 @@ "./test-utils/mockAxios": "./src/test-utils/mockAxios.ts", "./test-utils": "./src/test-utils/index.ts", "./mitxonline-hooks/*": "./src/mitxonline/hooks/*/index.ts", - "./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts" + "./mitxonline-test-utils": "./src/mitxonline/test-utils/index.ts", + "./analytics-hooks/*": "./src/analytics/hooks/*/index.ts", + "./analytics-types": "./src/analytics/types.ts", + "./analytics-test-utils": "./src/analytics/test-utils/index.ts" }, "peerDependencies": { "react": "^19.2.1" diff --git a/frontends/api/src/analytics/axios.ts b/frontends/api/src/analytics/axios.ts new file mode 100644 index 0000000000..a44d1d4972 --- /dev/null +++ b/frontends/api/src/analytics/axios.ts @@ -0,0 +1,14 @@ +import { createConfigurableAxios } from "../configurableAxios" + +/** + * Axios instance for the OL Analytics API (`ol-analytics-api`). + * + * Auth is entirely cookie-based: the analytics API sits behind APISIX, which + * resolves the session cookie into the JWT the API expects. The browser sends + * the cookie because of `withCredentials`, so nothing here attaches a token. + */ +export const analyticsAxiosClient = createConfigurableAxios( + "mit-learn.api.axios.analytics", +) + +export default analyticsAxiosClient.instance diff --git a/frontends/api/src/analytics/clients.ts b/frontends/api/src/analytics/clients.ts new file mode 100644 index 0000000000..2d67bc87fe --- /dev/null +++ b/frontends/api/src/analytics/clients.ts @@ -0,0 +1,107 @@ +import type { AxiosResponse } from "axios" +import axiosInstance from "./axios" +import type { + AnalyticsPageParams, + ContentEngagementDepth, + ContractUtilization, + EnrollmentCompletionFunnel, + MonthlyEngagementTrend, + OrgAnalyticsResponse, + ProgramFunnel, +} from "./types" + +/** + * The b2b_dashboard tenant is mounted at this path by `ol-analytics-api`'s + * root ASGI app (`main.py`'s tenant registry). The configured axios `baseURL` + * is the API host, so every path here is relative to it. + */ +const B2B_DASHBOARD_ROOT = "/api/v1/analytics" + +/** + * `organizationId` is the **Keycloak organization UUID** (`sso_organization_id`), + * not the org slug and not MITx Online's numeric org id. It is the only + * identifier that is stable across the JWT, MITx Online and StarRocks, so the + * analytics API keys every org endpoint on it — see mitodl/ol-analytics-api#13. + */ +const orgRoot = (organizationId: string) => + `${B2B_DASHBOARD_ROOT}/organizations/${encodeURIComponent(organizationId)}` + +const getOrgResource = ( + organizationId: string, + resource: string, + page: AnalyticsPageParams | undefined, + signal: AbortSignal | undefined, +): Promise>> => + axiosInstance.get>( + `${orgRoot(organizationId)}/${resource}`, + { params: page, signal }, + ) + +/** + * One method per org-scoped endpoint of the analytics API's b2b_dashboard + * tenant. Thin by design — paging and the response envelope are the only + * shared concerns, and both live in `getOrgResource`. + */ +const analyticsOrganizationsApi = { + contractUtilization: ( + organizationId: string, + page?: AnalyticsPageParams, + signal?: AbortSignal, + ) => + getOrgResource( + organizationId, + "contract-utilization", + page, + signal, + ), + + enrollmentFunnel: ( + organizationId: string, + page?: AnalyticsPageParams, + signal?: AbortSignal, + ) => + getOrgResource( + organizationId, + "enrollment-funnel", + page, + signal, + ), + + engagementTrend: ( + organizationId: string, + page?: AnalyticsPageParams, + signal?: AbortSignal, + ) => + getOrgResource( + organizationId, + "engagement-trend", + page, + signal, + ), + + programFunnel: ( + organizationId: string, + page?: AnalyticsPageParams, + signal?: AbortSignal, + ) => + getOrgResource( + organizationId, + "program-funnel", + page, + signal, + ), + + contentEngagement: ( + organizationId: string, + page?: AnalyticsPageParams, + signal?: AbortSignal, + ) => + getOrgResource( + organizationId, + "content-engagement", + page, + signal, + ), +} + +export { analyticsOrganizationsApi, B2B_DASHBOARD_ROOT } diff --git a/frontends/api/src/analytics/hooks/organizations/index.ts b/frontends/api/src/analytics/hooks/organizations/index.ts new file mode 100644 index 0000000000..5c172ba9a8 --- /dev/null +++ b/frontends/api/src/analytics/hooks/organizations/index.ts @@ -0,0 +1,14 @@ +export { + analyticsOrganizationQueries, + analyticsOrganizationKeys, +} from "./queries" + +export type { + AnalyticsPageParams, + ContentEngagementDepth, + ContractUtilization, + EnrollmentCompletionFunnel, + MonthlyEngagementTrend, + OrgAnalyticsResponse, + ProgramFunnel, +} from "../../types" diff --git a/frontends/api/src/analytics/hooks/organizations/queries.test.ts b/frontends/api/src/analytics/hooks/organizations/queries.test.ts new file mode 100644 index 0000000000..46839f969a --- /dev/null +++ b/frontends/api/src/analytics/hooks/organizations/queries.test.ts @@ -0,0 +1,117 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { useQuery, type UseQueryOptions } from "@tanstack/react-query" +import { setupReactQueryTest } from "../../../hooks/test-utils" +import { setMockResponse, makeRequest } from "../../../test-utils" +import { factories, urls } from "../../test-utils" +import { analyticsOrganizationQueries } from "./queries" + +const ORG_UUID = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + +/** + * Each factory returns options for its own row type, so a `test.each` table of + * them is a union that `useQuery` cannot resolve to a single overload. The + * table only needs "does this options object request that URL", so erase the + * row type here rather than splitting the table into five near-identical tests. + */ +type AnyQueryOptions = UseQueryOptions< + unknown, + Error, + unknown, + readonly unknown[] +> +const erase = (options: unknown) => options as AnyQueryOptions + +describe("analyticsOrganizationQueries", () => { + test.each([ + { + name: "contractUtilization", + query: () => + erase(analyticsOrganizationQueries.contractUtilization(ORG_UUID)), + url: urls.organizations.contractUtilization(ORG_UUID), + response: factories.envelope([factories.contractUtilization()]), + }, + { + name: "enrollmentFunnel", + query: () => + erase(analyticsOrganizationQueries.enrollmentFunnel(ORG_UUID)), + url: urls.organizations.enrollmentFunnel(ORG_UUID), + response: factories.envelope([factories.enrollmentCompletionFunnel()]), + }, + { + name: "engagementTrend", + query: () => + erase(analyticsOrganizationQueries.engagementTrend(ORG_UUID)), + url: urls.organizations.engagementTrend(ORG_UUID), + response: factories.envelope([factories.monthlyEngagementTrend()]), + }, + { + name: "programFunnel", + query: () => erase(analyticsOrganizationQueries.programFunnel(ORG_UUID)), + url: urls.organizations.programFunnel(ORG_UUID), + response: factories.envelope([factories.programFunnel()]), + }, + { + name: "contentEngagement", + query: () => + erase(analyticsOrganizationQueries.contentEngagement(ORG_UUID)), + url: urls.organizations.contentEngagement(ORG_UUID), + response: factories.envelope([factories.contentEngagementDepth()]), + }, + ])("$name requests its endpoint and returns the envelope", async (spec) => { + setMockResponse.get(spec.url, spec.response) + const { wrapper } = setupReactQueryTest() + + const { result } = renderHook(() => useQuery(spec.query()), { wrapper }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data).toEqual(spec.response) + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "get", url: spec.url }), + ) + }) + + test("paging params reach the request and change the query key", async () => { + const page = { limit: 200, offset: 0 } + const url = urls.organizations.contractUtilization(ORG_UUID, page) + const response = factories.envelope([factories.contractUtilization()]) + setMockResponse.get(url, response) + const { wrapper } = setupReactQueryTest() + + const { result } = renderHook( + () => + useQuery( + analyticsOrganizationQueries.contractUtilization(ORG_UUID, page), + ), + { wrapper }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(makeRequest).toHaveBeenCalledWith( + expect.objectContaining({ method: "get", url }), + ) + expect( + analyticsOrganizationQueries.contractUtilization(ORG_UUID, page).queryKey, + ).not.toEqual( + analyticsOrganizationQueries.contractUtilization(ORG_UUID).queryKey, + ) + }) + + test("query keys are namespaced per org so one org cannot read another's cache", () => { + const [namespace, resource, orgId] = + analyticsOrganizationQueries.contractUtilization(ORG_UUID).queryKey + expect([namespace, resource, orgId]).toEqual([ + "analytics", + "organizations", + ORG_UUID, + ]) + expect( + analyticsOrganizationQueries.contractUtilization("other").queryKey, + ).not.toEqual( + analyticsOrganizationQueries.contractUtilization(ORG_UUID).queryKey, + ) + }) + + test("the org id is url-encoded rather than spliced into the path raw", () => { + expect(urls.organizations.contractUtilization("a/b")).toContain("a%2Fb") + }) +}) diff --git a/frontends/api/src/analytics/hooks/organizations/queries.ts b/frontends/api/src/analytics/hooks/organizations/queries.ts new file mode 100644 index 0000000000..0679c78880 --- /dev/null +++ b/frontends/api/src/analytics/hooks/organizations/queries.ts @@ -0,0 +1,99 @@ +import { queryOptions } from "@tanstack/react-query" +import { analyticsOrganizationsApi } from "../../clients" +import type { AnalyticsPageParams } from "../../types" + +/** + * `orgId` in every key is the Keycloak organization UUID — see + * `analyticsOrganizationsApi`. Keys are namespaced under "analytics" so the + * whole dashboard can be invalidated without touching mitxonline or learn + * queries. + */ +const analyticsOrganizationKeys = { + root: ["analytics", "organizations"] as const, + organization: (orgId: string) => + [...analyticsOrganizationKeys.root, orgId] as const, + resource: (orgId: string, resource: string, page?: AnalyticsPageParams) => + [...analyticsOrganizationKeys.organization(orgId), resource, page] as const, +} + +/** + * The materialized views behind these endpoints refresh on a schedule measured + * in hours, so refetching on every window focus only costs StarRocks queries + * without ever changing what the manager sees. Freshness is communicated by the + * `as_of` in each response instead. + */ +const ANALYTICS_STALE_TIME = 5 * 60 * 1000 + +const analyticsOrganizationQueries = { + contractUtilization: (orgId: string, page?: AnalyticsPageParams) => + queryOptions({ + queryKey: analyticsOrganizationKeys.resource( + orgId, + "contract-utilization", + page, + ), + staleTime: ANALYTICS_STALE_TIME, + queryFn: async ({ signal }) => + analyticsOrganizationsApi + .contractUtilization(orgId, page, signal) + .then((res) => res.data), + }), + + enrollmentFunnel: (orgId: string, page?: AnalyticsPageParams) => + queryOptions({ + queryKey: analyticsOrganizationKeys.resource( + orgId, + "enrollment-funnel", + page, + ), + staleTime: ANALYTICS_STALE_TIME, + queryFn: async ({ signal }) => + analyticsOrganizationsApi + .enrollmentFunnel(orgId, page, signal) + .then((res) => res.data), + }), + + engagementTrend: (orgId: string, page?: AnalyticsPageParams) => + queryOptions({ + queryKey: analyticsOrganizationKeys.resource( + orgId, + "engagement-trend", + page, + ), + staleTime: ANALYTICS_STALE_TIME, + queryFn: async ({ signal }) => + analyticsOrganizationsApi + .engagementTrend(orgId, page, signal) + .then((res) => res.data), + }), + + programFunnel: (orgId: string, page?: AnalyticsPageParams) => + queryOptions({ + queryKey: analyticsOrganizationKeys.resource( + orgId, + "program-funnel", + page, + ), + staleTime: ANALYTICS_STALE_TIME, + queryFn: async ({ signal }) => + analyticsOrganizationsApi + .programFunnel(orgId, page, signal) + .then((res) => res.data), + }), + + contentEngagement: (orgId: string, page?: AnalyticsPageParams) => + queryOptions({ + queryKey: analyticsOrganizationKeys.resource( + orgId, + "content-engagement", + page, + ), + staleTime: ANALYTICS_STALE_TIME, + queryFn: async ({ signal }) => + analyticsOrganizationsApi + .contentEngagement(orgId, page, signal) + .then((res) => res.data), + }), +} + +export { analyticsOrganizationQueries, analyticsOrganizationKeys } diff --git a/frontends/api/src/analytics/test-utils/factories.ts b/frontends/api/src/analytics/test-utils/factories.ts new file mode 100644 index 0000000000..fa05457199 --- /dev/null +++ b/frontends/api/src/analytics/test-utils/factories.ts @@ -0,0 +1,133 @@ +import { faker } from "@faker-js/faker/locale/en" +import type { + ContentEngagementDepth, + ContractUtilization, + EnrollmentCompletionFunnel, + MonthlyEngagementTrend, + OrgAnalyticsResponse, + ProgramFunnel, +} from "../types" + +/** + * Factories for the analytics API's org-scoped responses. + * + * Every nullable count here defaults to a real number: suppression is the + * exception, so a test that cares about it opts in by passing `null` rather + * than every other test having to opt out. + */ + +const organizationId = () => faker.string.uuid() + +const envelope = ( + data: RowT[], + overrides: Partial> = {}, +): OrgAnalyticsResponse => ({ + organization_id: organizationId(), + as_of: "2026-07-01T04:00:00Z", + // Defaults to a complete result set; a test exercising truncation passes a + // larger total explicitly. + total_count: data.length, + data, + ...overrides, +}) + +const contractUtilization = ( + overrides: Partial = {}, +): ContractUtilization => ({ + organization_key: faker.string.alphanumeric(6).toUpperCase(), + organization_name: faker.company.name(), + contract_pk: faker.number.int({ min: 1, max: 10000 }), + b2b_contract_name: `${faker.company.name()} Contract`, + b2b_contract_is_active: true, + b2b_contract_start_date: "2026-01-01", + b2b_contract_end_date: "2026-12-31", + seat_limit: 100, + b2b_contract_membership_type: "fixed", + seats_consumed: 62, + active_learners: 48, + learners_certified: 20, + seat_utilization_pct: 62, + completion_rate_pct: 32.3, + ...overrides, +}) + +const enrollmentCompletionFunnel = ( + overrides: Partial = {}, +): EnrollmentCompletionFunnel => ({ + organization_key: faker.string.alphanumeric(6).toUpperCase(), + organization_name: faker.company.name(), + contract_pk: faker.number.int({ min: 1, max: 10000 }), + b2b_contract_name: `${faker.company.name()} Contract`, + courserun_pk: faker.number.int({ min: 1, max: 10000 }), + courserun_readable_id: `course-v1:MITx+${faker.string.alphanumeric(5)}+2026`, + courserun_title: faker.commerce.productName(), + enrolled_learners: 40, + active_learners: 31, + passing_learners: 18, + certified_learners: 16, + active_rate_pct: 77.5, + completion_rate_pct: 40, + ...overrides, +}) + +const monthlyEngagementTrend = ( + overrides: Partial = {}, +): MonthlyEngagementTrend => ({ + organization_key: faker.string.alphanumeric(6).toUpperCase(), + organization_name: faker.company.name(), + activity_year_and_month: "2026-01", + monthly_active_learners: 30, + new_enrollments: 12, + certificates_earned: 5, + total_videos_watched: 900, + total_problems_attempted: 1200, + total_chatbot_interactions: 80, + ...overrides, +}) + +const programFunnel = ( + overrides: Partial = {}, +): ProgramFunnel => ({ + organization_key: faker.string.alphanumeric(6).toUpperCase(), + organization_name: faker.company.name(), + contract_pk: faker.number.int({ min: 1, max: 10000 }), + b2b_contract_name: `${faker.company.name()} Contract`, + program_pk: faker.number.int({ min: 1, max: 10000 }), + program_title: `${faker.commerce.department()} Program`, + total_courses: 6, + enrolled_in_contract_courses: 50, + enrolled_via_program: 30, + program_course_completers: 12, + ...overrides, +}) + +const contentEngagementDepth = ( + overrides: Partial = {}, +): ContentEngagementDepth => ({ + organization_key: faker.string.alphanumeric(6).toUpperCase(), + organization_name: faker.company.name(), + courserun_readable_id: `course-v1:MITx+${faker.string.alphanumeric(5)}+2026`, + courserun_title: faker.commerce.productName(), + total_enrolled_learners: 40, + engaged_learners: 28, + engagement_rate_pct: 70, + total_videos_watched: 800, + avg_videos_per_engaged_learner: 28.6, + total_problems_attempted: 1000, + avg_problems_per_engaged_learner: 35.7, + total_chatbot_interactions: 60, + chatbot_users: 14, + chatbot_adoption_pct: 35, + certificates_earned: 16, + ...overrides, +}) + +export { + contentEngagementDepth, + contractUtilization, + enrollmentCompletionFunnel, + envelope, + monthlyEngagementTrend, + organizationId, + programFunnel, +} diff --git a/frontends/api/src/analytics/test-utils/index.ts b/frontends/api/src/analytics/test-utils/index.ts new file mode 100644 index 0000000000..5edf680a68 --- /dev/null +++ b/frontends/api/src/analytics/test-utils/index.ts @@ -0,0 +1,2 @@ +export * as factories from "./factories" +export * as urls from "./urls" diff --git a/frontends/api/src/analytics/test-utils/urls.ts b/frontends/api/src/analytics/test-utils/urls.ts new file mode 100644 index 0000000000..f87534403e --- /dev/null +++ b/frontends/api/src/analytics/test-utils/urls.ts @@ -0,0 +1,34 @@ +import { queryify } from "ol-test-utilities" +import analyticsAxios from "../axios" +import { B2B_DASHBOARD_ROOT } from "../clients" +import type { AnalyticsPageParams } from "../types" + +// Absolute, and read back from the configured axios instance, so the shared +// request mock can tell analytics requests apart from Learn and MITx ones by +// origin — same reasoning as the mitxonline URL builders. +const getApiBaseUrl = () => analyticsAxios.defaults.baseURL + +const orgResource = ( + organizationId: string, + resource: string, + params?: AnalyticsPageParams, +) => + // queryify supplies its own leading "?" (and "" when there are no params). + `${getApiBaseUrl()}${B2B_DASHBOARD_ROOT}/organizations/${encodeURIComponent( + organizationId, + )}/${resource}${queryify(params)}` + +const organizations = { + contractUtilization: (organizationId: string, params?: AnalyticsPageParams) => + orgResource(organizationId, "contract-utilization", params), + enrollmentFunnel: (organizationId: string, params?: AnalyticsPageParams) => + orgResource(organizationId, "enrollment-funnel", params), + engagementTrend: (organizationId: string, params?: AnalyticsPageParams) => + orgResource(organizationId, "engagement-trend", params), + programFunnel: (organizationId: string, params?: AnalyticsPageParams) => + orgResource(organizationId, "program-funnel", params), + contentEngagement: (organizationId: string, params?: AnalyticsPageParams) => + orgResource(organizationId, "content-engagement", params), +} + +export { organizations } diff --git a/frontends/api/src/analytics/types.ts b/frontends/api/src/analytics/types.ts new file mode 100644 index 0000000000..dcb8d2e99d --- /dev/null +++ b/frontends/api/src/analytics/types.ts @@ -0,0 +1,131 @@ +/** + * Response types for the B2B dashboard tenant of the OL Analytics API. + * + * These are hand-written rather than generated: `ol-analytics-api` does not + * publish a TypeScript client the way MITx Online does (`@mitodl/mitxonline-api-axios`). + * They mirror `tenants/b2b_dashboard/models.py` — which in turn mirrors the + * StarRocks materialized views owned by dbt in `ol-data-platform` — column for + * column. When a view gains a column, update the matching type here. + * + * # Why so many nullable numbers + * + * The API applies a k-anonymity floor: any distinct-learner count below the + * floor, and every rate/average derived from it, comes back `null`. A `null` + * therefore means "suppressed to protect learner privacy", NOT zero and NOT + * missing — render it as such (see `SuppressibleValue` in the dashboard) and + * never coerce it to 0 in a chart or an average. + */ + +/** + * Envelope shared by every org-scoped endpoint. + * + * `as_of` is the last refresh time of the single materialized view backing this + * endpoint, so it is per-section rather than per-page: one lagging view cannot + * make another section look fresher than it is. It is `null` until that view + * has refreshed for the first time. + * + * `total_count` is how many rows the org has in that view across every page, + * already net of the anonymity floor. `data.length` alone cannot distinguish a + * complete result from one truncated at the page cap, so this is what lets the + * dashboard admit to showing a subset instead of quietly dropping the rest. + */ +export type OrgAnalyticsResponse = { + organization_id: string + as_of: string | null + total_count: number + data: RowT[] +} + +/** `mv_b2b_contract_utilization` — grain: org x contract. */ +export type ContractUtilization = { + organization_key: string + organization_name: string + contract_pk: number + b2b_contract_name: string + b2b_contract_is_active: boolean + b2b_contract_start_date: string | null + b2b_contract_end_date: string | null + seat_limit: number | null + b2b_contract_membership_type: string | null + seats_consumed: number + active_learners: number | null + learners_certified: number | null + seat_utilization_pct: number | null + completion_rate_pct: number | null +} + +/** `mv_b2b_enrollment_completion_funnel` — grain: org x contract x course run. */ +export type EnrollmentCompletionFunnel = { + organization_key: string + organization_name: string + contract_pk: number + b2b_contract_name: string + courserun_pk: number + courserun_readable_id: string + courserun_title: string + enrolled_learners: number + active_learners: number | null + passing_learners: number | null + certified_learners: number | null + active_rate_pct: number | null + completion_rate_pct: number | null +} + +/** + * `mv_b2b_monthly_engagement_trend` — grain: org x year_month. + * + * `activity_year_and_month` is a `YYYY-MM` string, not a date. + */ +export type MonthlyEngagementTrend = { + organization_key: string + organization_name: string + activity_year_and_month: string + monthly_active_learners: number + new_enrollments: number | null + certificates_earned: number | null + total_videos_watched: number + total_problems_attempted: number + total_chatbot_interactions: number +} + +/** `mv_b2b_program_funnel` — grain: org x contract x program. */ +export type ProgramFunnel = { + organization_key: string + organization_name: string + contract_pk: number + b2b_contract_name: string + program_pk: number + program_title: string + total_courses: number + enrolled_in_contract_courses: number + enrolled_via_program: number | null + program_course_completers: number | null +} + +/** `mv_b2b_content_engagement_depth` — grain: org x course run, all-time. */ +export type ContentEngagementDepth = { + organization_key: string + organization_name: string + courserun_readable_id: string + courserun_title: string + total_enrolled_learners: number + engaged_learners: number | null + engagement_rate_pct: number | null + total_videos_watched: number | null + avg_videos_per_engaged_learner: number | null + total_problems_attempted: number | null + avg_problems_per_engaged_learner: number | null + total_chatbot_interactions: number | null + chatbot_users: number | null + chatbot_adoption_pct: number | null + certificates_earned: number | null +} + +/** + * LIMIT/OFFSET paging, shared by every multi-row endpoint. The API caps `limit` + * at its own `max_page_size` and rejects anything larger with a 422. + */ +export type AnalyticsPageParams = { + limit?: number + offset?: number +} diff --git a/frontends/api/src/runtime/index.ts b/frontends/api/src/runtime/index.ts index 5de93d861c..5c61f0217d 100644 --- a/frontends/api/src/runtime/index.ts +++ b/frontends/api/src/runtime/index.ts @@ -1,10 +1,19 @@ import { learnAxiosClient } from "../axios" import { mitxAxiosClient } from "../mitxonline/axios" +import { analyticsAxiosClient } from "../analytics/axios" import type { ConfigurableAxiosConfig } from "../configurableAxios" export type ApiClientsConfig = { learn: ConfigurableAxiosConfig mitxonline: ConfigurableAxiosConfig + /** + * The OL Analytics API. Optional: it is a newer service that is not deployed + * to every environment yet, so an environment that does not set + * `NEXT_PUBLIC_ANALYTICS_API_BASE_URL` still boots — only the analytics + * dashboard is unavailable there. Callers gate on `isAnalyticsConfigured()` + * rather than letting the unconfigured instance throw from its interceptor. + */ + analytics?: ConfigurableAxiosConfig } const normalizeBaseUrl = (label: string, value: string) => { @@ -33,6 +42,12 @@ const normalizeConfig = (config: ApiClientsConfig): ApiClientsConfig => ({ ...config.mitxonline, baseUrl: normalizeBaseUrl("mitxonline", config.mitxonline.baseUrl), }, + ...(config.analytics && { + analytics: { + ...config.analytics, + baseUrl: normalizeBaseUrl("analytics", config.analytics.baseUrl), + }, + }), }) export const configureApiClients = (config: ApiClientsConfig): void => { @@ -48,12 +63,25 @@ export const configureApiClients = (config: ApiClientsConfig): void => { learnAxiosClient.applyConfig(normalized.learn) mitxAxiosClient.applyConfig(normalized.mitxonline) + if (normalized.analytics) { + analyticsAxiosClient.applyConfig(normalized.analytics) + } } +/** + * Whether the always-required clients are configured. Deliberately excludes the + * analytics client so that an environment without an analytics API still + * reports "configured" and `bootstrapApiClients()` stays first-wins. + */ export const isApiClientsConfigured = (): boolean => learnAxiosClient.isConfigured() && mitxAxiosClient.isConfigured() +/** Whether this environment has an analytics API to talk to. */ +export const isAnalyticsConfigured = (): boolean => + analyticsAxiosClient.isConfigured() + export const resetApiClientsForTests = () => { learnAxiosClient.resetForTests() mitxAxiosClient.resetForTests() + analyticsAxiosClient.resetForTests() } diff --git a/frontends/api/src/test-utils/setupJest.ts b/frontends/api/src/test-utils/setupJest.ts index 26b0d1536d..20ca372905 100644 --- a/frontends/api/src/test-utils/setupJest.ts +++ b/frontends/api/src/test-utils/setupJest.ts @@ -26,4 +26,9 @@ configureApiClients({ csrfCookieName: "mitxcsrftoken", withCredentials: false, }, + analytics: { + baseUrl: "http://api.test.learn.odl.local:8065/analytics", + csrfCookieName: "csrftoken", + withCredentials: false, + }, }) diff --git a/frontends/jest-shared-setup.ts b/frontends/jest-shared-setup.ts index ba3d96a412..f13619adf2 100644 --- a/frontends/jest-shared-setup.ts +++ b/frontends/jest-shared-setup.ts @@ -1,3 +1,4 @@ +import v8 from "node:v8" import { faker } from "@faker-js/faker/locale/en" import failOnConsole from "jest-fail-on-console" @@ -24,6 +25,20 @@ setDefaultTimezone("UTC") // configures its clients with hardcoded test URLs in its own setup, and leaf // packages (ol-*) don't read NEXT_PUBLIC_* at all. +/** + * jsdom does not expose `structuredClone`, which real browsers and Node both + * have. `@mui/x-charts` calls it while building series state, so any test that + * renders a chart dies with a ReferenceError without this. + * + * Implemented with v8's serializer rather than a JSON round-trip so it behaves + * like the real thing for Dates, Maps and Sets instead of flattening them to + * strings and plain objects. + */ +if (typeof globalThis.structuredClone === "undefined") { + globalThis.structuredClone = (value: T): T => + v8.deserialize(v8.serialize(value as never)) as T +} + // Pulled from the docs - see https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom Object.defineProperty(window, "matchMedia", { diff --git a/frontends/main/package.json b/frontends/main/package.json index 066801811b..25042ae7c4 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -23,6 +23,7 @@ "@mui/base": "5.0.0-beta.70", "@mui/material": "^6.4.5", "@mui/material-nextjs": "^6.4.3", + "@mui/x-charts": "^8.29.2", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", "@opentelemetry/resources": "^2.6.1", diff --git a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx index 2548013f2f..46d3ebb98e 100644 --- a/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx +++ b/frontends/main/src/app-pages/ContractAdminPage/ContractAdminPage.tsx @@ -12,6 +12,7 @@ import { alpha, Chip, Container, + Link, Pagination, SearchInput, Skeleton, @@ -29,6 +30,18 @@ import { } from "@mitodl/smoot-design" import { AssignSeatsSection } from "./AssignSeatsSection" import { RowActionMenu } from "./RowActionMenu" +import { + EmptyTableMessage, + MobileLabel, + STUB, + TableCard, + TableCell, + TableFooter, + TableFootnote, + TableHeaderCell, + TableHeaderRow, + TableRow, +} from "@/components/B2BTable/B2BTable" import { managerOrganizationQueries, type ManagerEnrollmentCode, @@ -38,6 +51,7 @@ import { matchOrganizationBySlug } from "@/common/utils" import { ForbiddenError } from "@/common/errors" import { FeatureFlags } from "@/common/feature_flags" import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import { organizationAnalyticsView } from "@/common/urls" import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" import graduateLogo from "@/public/images/dashboard/graduate.png" @@ -94,6 +108,12 @@ const ContractSubtitle = styled(Typography)(({ theme }) => ({ color: theme.custom.colors.silverGrayDark, })) as typeof Typography +const AnalyticsLink = styled(Link)(({ theme }) => ({ + ...theme.typography.body2, + display: "inline-block", + paddingTop: "4px", +})) + const StatsSide = styled.div(({ theme }) => ({ display: "flex", gap: "64px", @@ -185,89 +205,6 @@ const ControlsLeft = styled.div(({ theme }) => ({ }, })) -const TableCard = styled.div(({ theme }) => ({ - backgroundColor: theme.custom.colors.white, - border: `1px solid ${theme.custom.colors.lightGray2}`, - borderRadius: "8px", - padding: "24px", - [theme.breakpoints.down("md")]: { - padding: "16px", - }, -})) - -const TableHeaderRow = styled.div(({ theme }) => ({ - display: "flex", - gap: "16px", - alignItems: "center", - paddingBottom: "16px", - borderBottom: `1px solid ${theme.custom.colors.silverGrayDark}`, - [theme.breakpoints.down("md")]: { - display: "none", - }, -})) - -const TableHeaderCell = styled("div", { - shouldForwardProp: (prop) => prop !== "$flex", -})<{ $flex: number }>(({ $flex, theme }) => ({ - flex: $flex, - minWidth: 0, - ...theme.typography.subtitle2, - color: theme.custom.colors.black, -})) - -const TableRow = styled.div(({ theme }) => ({ - display: "flex", - gap: "16px", - alignItems: "center", - padding: "14px 0", - borderBottom: `1px solid ${theme.custom.colors.silverGrayLight}`, - "&:last-child": { - borderBottom: "none", - }, - [theme.breakpoints.down("md")]: { - position: "relative", - flexWrap: "wrap", - gap: "6px 0", - padding: "16px 40px 16px 0", - }, -})) - -const MobileLabel = styled.span(({ theme }) => ({ - display: "none", - [theme.breakpoints.down("md")]: { - display: "inline", - ...theme.typography.subtitle2, - color: theme.custom.colors.darkGray2, - minWidth: "120px", - flexShrink: 0, - }, -})) - -const TableCell = styled("div", { - shouldForwardProp: (prop) => prop !== "$flex" && prop !== "$primary", -})<{ $flex: number; $primary?: boolean }>(({ $flex, $primary, theme }) => ({ - flex: $flex, - minWidth: 0, - ...theme.typography.body2, - color: theme.custom.colors.black, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - [theme.breakpoints.down("md")]: { - flex: "none", - width: "100%", - display: "flex", - alignItems: "center", - gap: "8px", - overflow: "visible", - whiteSpace: "normal", - ...($primary && { - ...theme.typography.subtitle2, - marginBottom: "4px", - }), - }, -})) - const StatusBadge = styled(Chip, { shouldForwardProp: (prop) => prop !== "$status", })<{ $status: "assigned" | "redeemed" }>(({ $status, theme }) => ({ @@ -300,27 +237,6 @@ const ActionCell = styled.div(({ theme }) => ({ }, })) -const TableFooter = styled.div({ - display: "flex", - justifyContent: "space-between", - alignItems: "center", - paddingTop: "16px", -}) - -const TableFootnote = styled(Typography)(({ theme }) => ({ - ...theme.typography.body2, - color: theme.custom.colors.silverGrayDark, -})) as typeof Typography - -const EmptyTableMessage = styled(Typography)(({ theme }) => ({ - ...theme.typography.body2, - color: theme.custom.colors.silverGrayDark, - padding: "32px 0", - textAlign: "center", -})) as typeof Typography - -const STUB = "—" - type StatusFilter = "all" | "pending" | "redeemed" const COLUMN_FLEX = { @@ -365,6 +281,9 @@ const ContractAdminPageInternal: React.FC = ({ contractSlug, }) => { const queryClient = useQueryClient() + const analyticsEnabled = useFeatureFlagEnabled( + FeatureFlags.B2BAnalyticsDashboard, + ) const [statusFilter, setStatusFilter] = useState("all") const [searchQuery, setSearchQuery] = useState("") const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("") @@ -633,6 +552,19 @@ const ContractAdminPageInternal: React.FC = ({ ) : null} + {/* Analytics is the reporting half of this dashboard and is + org-scoped, so it lives at its own URL rather than under this + contract. Behind its own flag: the analytics API is not + deployed everywhere this page is. */} + {analyticsEnabled ? ( + + View analytics + + ) : null} diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/ContractKpiCards.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/ContractKpiCards.tsx new file mode 100644 index 0000000000..f51a626eed --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/ContractKpiCards.tsx @@ -0,0 +1,203 @@ +"use client" + +import React from "react" +import { Skeleton, styled, Typography } from "ol-components" +import type { ContractUtilization } from "api/analytics-hooks/organizations" +import { + formatCount, + formatDate, + formatPercent, + SuppressibleValue, +} from "./format" +import SectionError from "./SectionError" + +/** + * Headline numbers from `mv_b2b_contract_utilization`. + * + * # Why one card group per contract rather than one org-wide row + * + * The view's grain is org x contract, and the three headline figures cannot be + * honestly rolled up across contracts here: + * + * - `active_learners` counts *distinct* learners per contract, so summing it + * double-counts anyone on two contracts. + * - `seat_utilization_pct` and `completion_rate_pct` are rates; averaging + * rates across contracts of different sizes is simply the wrong number, and + * recomputing them from the raw counts is impossible whenever a count has + * been suppressed. + * + * So each contract gets its own group. Most orgs have exactly one, in which + * case this reads as a single KPI row. + */ + +const Root = styled.div({ + display: "flex", + flexDirection: "column", + gap: "16px", +}) + +const ContractCard = styled.div(({ theme }) => ({ + backgroundColor: theme.custom.colors.white, + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "8px", + padding: "24px", + [theme.breakpoints.down("md")]: { + padding: "16px", + }, +})) + +const ContractName = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle1, + color: theme.custom.colors.darkGray2, +})) as typeof Typography + +const ContractMeta = styled(Typography)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const StatRow = styled.div(({ theme }) => ({ + display: "flex", + gap: "64px", + flexWrap: "wrap", + paddingTop: "20px", + [theme.breakpoints.down("md")]: { + gap: "24px", + }, +})) + +const StatBlock = styled.div({ + display: "flex", + flexDirection: "column", + gap: "4px", +}) + +const StatValue = styled(Typography)(({ theme }) => ({ + ...theme.typography.h3, + color: theme.custom.colors.darkGray2, + fontVariantNumeric: "tabular-nums", +})) as typeof Typography + +const StatLabel = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle1, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const StatSubLabel = styled(Typography)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const Stat: React.FC<{ + label: string + subLabel?: string | null + value: number | null + format?: (value: number) => string +}> = ({ label, subLabel, value, format }) => ( + + + + + {label} + {subLabel ? {subLabel} : null} + +) + +const contractDates = (row: ContractUtilization): string | null => { + const start = formatDate(row.b2b_contract_start_date) + const end = formatDate(row.b2b_contract_end_date) + if (start && end) return `${start} – ${end}` + return start ?? end +} + +const ContractKpiCards: React.FC<{ + rows: ContractUtilization[] | undefined + isLoading: boolean + isError?: boolean +}> = ({ rows, isLoading, isError }) => { + if (isError) { + return ( + + + + + + ) + } + + if (isLoading) { + return ( + + + + + {Array.from({ length: 3 }).map((_, index) => ( + + + + + ))} + + + + ) + } + + if (!rows?.length) { + return null + } + + return ( + + {rows.map((row) => { + const dates = contractDates(row) + const seatLimit = row.seat_limit + return ( + + {row.b2b_contract_name} + + {[ + row.b2b_contract_is_active ? "Active" : "Inactive", + row.b2b_contract_membership_type, + dates, + ] + .filter(Boolean) + .join(" · ")} + + + + + + + + ) + })} + + ) +} + +export default ContractKpiCards diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/CoursePerformanceTable.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/CoursePerformanceTable.tsx new file mode 100644 index 0000000000..e284a13fdd --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/CoursePerformanceTable.tsx @@ -0,0 +1,232 @@ +"use client" + +import React from "react" +import { Skeleton, styled, Typography } from "ol-components" +import type { EnrollmentCompletionFunnel } from "api/analytics-hooks/organizations" +import { + EmptyTableMessage, + MobileLabel, + TableCard, + TableCell, + TableFooter, + TableFootnote, + TableHeaderCell, + TableHeaderRow, + TableRow, +} from "@/components/B2BTable/B2BTable" +import { + formatCount, + formatPercent, + SUPPRESSED_EXPLANATION, + SuppressibleValue, +} from "./format" +import SectionError from "./SectionError" + +/** + * Per-course-run performance from `mv_b2b_enrollment_completion_funnel`. + * + * Uses the same table primitives as the contract admin page so the two B2B + * manager surfaces read as one product, including the below-`md` reflow where + * each row becomes a stack of label/value pairs. + */ + +const CourseTitle = styled.span(({ theme }) => ({ + ...theme.typography.subtitle2, + color: theme.custom.colors.darkGray2, + display: "block", +})) + +const CourseId = styled.span(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, + display: "block", +})) + +const ContractLabel = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle2, + color: theme.custom.colors.darkGray2, + paddingTop: "8px", +})) as typeof Typography + +const COLUMN_FLEX = { + course: 3, + enrolled: 1, + active: 1, + certified: 1, + activeRate: 1.2, + completionRate: 1.4, +} + +const CoursePerformanceTable: React.FC<{ + rows: EnrollmentCompletionFunnel[] | undefined + isLoading: boolean + isError?: boolean +}> = ({ rows, isLoading, isError }) => { + if (isError) { + return ( + + + + ) + } + + if (isLoading) { + return ( + + {Array.from({ length: 4 }).map((_, index) => ( + + ))} + + ) + } + + if (!rows?.length) { + return ( + + + No course enrollments recorded yet. + + + ) + } + + // The view's grain includes the contract, so a course run appears once per + // contract it is offered under. Grouping by contract keeps those rows from + // reading as duplicates; the label is dropped when there is only one. + const contracts = new Map() + rows.forEach((row) => { + const existing = contracts.get(row.contract_pk) + if (existing) { + existing.push(row) + } else { + contracts.set(row.contract_pk, [row]) + } + }) + const showContractLabels = contracts.size > 1 + const hasSuppressed = rows.some( + (row) => + row.active_learners === null || + row.certified_learners === null || + row.passing_learners === null, + ) + + return ( + +
+
+ + + Course + + + Enrolled + + + Active + + + Certified + + + Active rate + + + Completion rate + + +
+
+ {Array.from(contracts.values()).map((contractRows) => ( + + {showContractLabels ? ( + + {contractRows[0].b2b_contract_name} + + ) : null} + {contractRows.map((row) => ( + + + + {row.courserun_title} + {row.courserun_readable_id} + + + + Enrolled + {formatCount(row.enrolled_learners)} + + + Active + + + + Certified + + + + Active rate + + + + Completion rate + + + + ))} + + ))} +
+
+ {hasSuppressed ? ( + + {SUPPRESSED_EXPLANATION} + + ) : null} +
+ ) +} + +export default CoursePerformanceTable +export { COLUMN_FLEX } diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx new file mode 100644 index 0000000000..432c3e49be --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/EngagementTrendChart.tsx @@ -0,0 +1,259 @@ +"use client" + +import React from "react" +import { LineChart } from "@mui/x-charts/LineChart" +import { Skeleton, styled, useTheme } from "ol-components" +import type { MonthlyEngagementTrend } from "api/analytics-hooks/organizations" +import { + EmptyTableMessage, + MobileLabel, + TableCell, + TableFooter, + TableFootnote, + TableHeaderCell, + TableHeaderRow, + TableRow, +} from "@/components/B2BTable/B2BTable" +import { CATEGORICAL, chartInk } from "./chartPalette" +import { + formatCount, + formatYearMonth, + formatYearMonthShort, + SUPPRESSED_EXPLANATION, + SuppressibleValue, +} from "./format" +import SectionError from "./SectionError" + +/** + * Monthly enrollment/engagement trend from `mv_b2b_monthly_engagement_trend`. + * + * # What is and isn't on this axis + * + * All three series are counts of *people*, so they share one linear axis. The + * view also carries `total_videos_watched`, `total_problems_attempted` and + * `total_chatbot_interactions` — those are counts of *events*, run three or + * four orders of magnitude larger, and putting them here would need a second + * y-scale. A dual-axis chart invites the reader to compare two things that were + * never on the same scale, so they are deliberately left out; they belong in a + * separate content-engagement panel. + * + * # Suppressed months + * + * `new_enrollments` and `certificates_earned` are nullable under the + * k-anonymity floor. They are passed through to the chart as `null`, which the + * line series renders as a genuine gap. Coercing them to 0 would draw a dip + * that did not happen. + * + * # Why the chart is paired with a table + * + * The `` is an SVG of plotted geometry: a screen reader gets nothing + * readable out of it, and a suppressed month is drawn as a gap that reads + * identically to "no data". The table below carries the same monthly numbers as + * text, where a suppressed value can say so in words. Same pairing, and same + * reasoning, as `ProgramFunnelChart`. + */ + +const ChartCard = styled.div(({ theme }) => ({ + backgroundColor: theme.custom.colors.white, + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "8px", + padding: "24px", + [theme.breakpoints.down("md")]: { + padding: "16px", + }, +})) + +const TableWrapper = styled.div(({ theme }) => ({ + paddingTop: "24px", + marginTop: "8px", + borderTop: `1px solid ${theme.custom.colors.lightGray2}`, +})) + +const CHART_HEIGHT = 320 + +const COLUMN_FLEX = { + month: 1.4, + active: 1.4, + enrollments: 1.4, + certificates: 1.6, +} + +/** Drives both the chart series and the columns of the table beside it. */ +const SERIES = [ + { + key: "monthly_active_learners", + column: "active", + label: "Active learners", + color: CATEGORICAL[0], + }, + { + key: "new_enrollments", + column: "enrollments", + label: "New enrollments", + color: CATEGORICAL[1], + }, + { + key: "certificates_earned", + column: "certificates", + label: "Certificates earned", + color: CATEGORICAL[2], + }, +] as const satisfies ReadonlyArray<{ + key: keyof MonthlyEngagementTrend + column: keyof typeof COLUMN_FLEX + label: string + color: string +}> + +const EngagementTrendChart: React.FC<{ + rows: MonthlyEngagementTrend[] | undefined + isLoading: boolean + isError?: boolean +}> = ({ rows, isLoading, isError }) => { + // Above the early returns: hooks cannot be called conditionally. + const ink = chartInk(useTheme()) + + if (isError) { + return ( + + + + ) + } + + if (isLoading) { + return ( + + + + ) + } + + if (!rows?.length) { + return ( + + No monthly activity recorded yet. + + ) + } + + // The API orders by activity_year_and_month, but sorting here keeps the chart + // correct even if paging ever returns rows out of order. + const months = [...rows].sort((a, b) => + a.activity_year_and_month.localeCompare(b.activity_year_and_month), + ) + const labels = months.map((row) => row.activity_year_and_month) + const hasSuppressed = months.some( + (row) => row.new_enrollments === null || row.certificates_earned === null, + ) + + return ( + + {/* Hidden from assistive tech: the table below carries the same numbers + as text, so leaving the SVG exposed only makes a screen reader walk + hundreds of series and axis nodes to reach data it is about to be + given properly. Safe to hide because the chart renders nothing + focusable — asserted in the test, since burying a focusable node + inside aria-hidden would be its own violation. */} +
+ + // The tooltip has room for the year; a dense monthly axis does not. + context.location === "tick" + ? formatYearMonthShort(value) + : formatYearMonth(value), + tickLabelStyle: { fill: ink.label, fontSize: 12 }, + }, + ]} + yAxis={[ + { + min: 0, + valueFormatter: (value: number) => formatCount(value), + tickLabelStyle: { fill: ink.label, fontSize: 12 }, + width: 56, + }, + ]} + series={SERIES.map((series) => ({ + // `null` is meaningful here: a month suppressed by the anonymity + // floor, drawn as a gap rather than a zero. + data: months.map((row) => row[series.key] ?? null), + label: series.label, + color: series.color, + curve: "linear", + // A mark per month keeps single-point series visible and gives the + // hover layer something bigger than a 2px line to aim at. + showMark: true, + valueFormatter: (value: number | null) => + value === null + ? "Withheld (too few learners)" + : formatCount(value), + }))} + sx={{ + "& .MuiChartsAxis-line, & .MuiChartsAxis-tick": { + stroke: ink.axis, + }, + "& .MuiChartsGrid-line": { stroke: ink.grid }, + "& .MuiLineElement-root": { strokeWidth: 2 }, + }} + /> +
+ +
+
+ + + Month + + {SERIES.map((series) => ( + + {series.label} + + ))} + +
+
+ {months.map((row) => ( + + + {formatYearMonth(row.activity_year_and_month)} + + {SERIES.map((series) => ( + + {series.label} + + + ))} + + ))} +
+
+ {hasSuppressed ? ( + + {SUPPRESSED_EXPLANATION} + + ) : null} +
+
+ ) +} + +export default EngagementTrendChart diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/ProgramFunnelChart.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/ProgramFunnelChart.tsx new file mode 100644 index 0000000000..20c3e754d0 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/ProgramFunnelChart.tsx @@ -0,0 +1,279 @@ +"use client" + +import React from "react" +import { BarChart } from "@mui/x-charts/BarChart" +import { Skeleton, styled, Typography, useTheme } from "ol-components" +import type { ProgramFunnel } from "api/analytics-hooks/organizations" +import { + EmptyTableMessage, + MobileLabel, + TableCell, + TableFooter, + TableFootnote, + TableHeaderCell, + TableHeaderRow, + TableRow, +} from "@/components/B2BTable/B2BTable" +import { chartInk, FUNNEL_STAGES } from "./chartPalette" +import { + formatCount, + SUPPRESSED_EXPLANATION, + SuppressibleValue, +} from "./format" +import SectionError from "./SectionError" + +/** + * Program funnel from `mv_b2b_program_funnel`. + * + * Stage order carries the meaning here — each stage is a subset of the one + * before it — so the bars take a single-hue light-to-dark ramp rather than + * three unrelated hues: the reader sees the progression in the color itself. + * + * The chart is paired with a table of the same numbers rather than treated as + * the whole story. That table is what makes the exact values available to + * screen readers, and it is where a value suppressed by the anonymity floor can + * say so — a bar chart can only omit the bar, which reads as zero. + */ + +const Card = styled.div(({ theme }) => ({ + backgroundColor: theme.custom.colors.white, + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "8px", + padding: "24px", + [theme.breakpoints.down("md")]: { + padding: "16px", + }, +})) + +const ChartWrapper = styled.div(({ theme }) => ({ + // A long program title needs room; below md the chart scrolls sideways rather + // than squeezing the labels into unreadable truncation. + overflowX: "auto", + [theme.breakpoints.down("md")]: { + "> *": { minWidth: "560px" }, + }, +})) + +const TableWrapper = styled.div(({ theme }) => ({ + paddingTop: "24px", + marginTop: "8px", + borderTop: `1px solid ${theme.custom.colors.lightGray2}`, +})) + +const ContractLabel = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle2, + color: theme.custom.colors.darkGray2, + paddingTop: "8px", +})) as typeof Typography + +const STAGES = [ + { + key: "enrolled_in_contract_courses", + label: "Enrolled in contract courses", + color: FUNNEL_STAGES[0], + }, + { + key: "enrolled_via_program", + label: "Enrolled via program", + color: FUNNEL_STAGES[1], + }, + { + key: "program_course_completers", + label: "Completed program courses", + color: FUNNEL_STAGES[2], + }, +] as const + +const COLUMN_FLEX = { + program: 3, + courses: 1, + enrolled: 1.4, + viaProgram: 1.4, + completers: 1.6, +} + +/** Bar thickness plus its gap, times three stages, plus room for the legend. */ +const rowHeight = (programCount: number) => + Math.max(220, programCount * 104 + 72) + +const ProgramFunnelChart: React.FC<{ + rows: ProgramFunnel[] | undefined + isLoading: boolean + isError?: boolean +}> = ({ rows, isLoading, isError }) => { + // Above the early returns: hooks cannot be called conditionally. + const ink = chartInk(useTheme()) + + if (isError) { + return ( + + + + ) + } + + if (isLoading) { + return ( + + + + ) + } + + if (!rows?.length) { + return ( + + + No program enrollments recorded yet. + + + ) + } + + const showContract = new Set(rows.map((row) => row.contract_pk)).size > 1 + // A program can appear under more than one contract, so the axis label has to + // disambiguate or two distinct rows collapse into one visual band. + const labels = rows.map((row) => + showContract + ? `${row.program_title} (${row.b2b_contract_name})` + : row.program_title, + ) + const hasSuppressed = rows.some( + (row) => + row.enrolled_via_program === null || + row.program_course_completers === null, + ) + + return ( + + {/* Hidden from assistive tech for the same reason as the trend chart: + the table below is the accessible copy of these numbers, so exposing + the SVG only adds a long walk through series and axis nodes. Nothing + inside is focusable, which the test asserts. */} + + formatCount(value), + tickLabelStyle: { fill: ink.label, fontSize: 12 }, + }, + ]} + series={STAGES.map((stage) => ({ + data: rows.map((row) => row[stage.key] ?? null), + label: stage.label, + color: stage.color, + valueFormatter: (value: number | null) => + value === null + ? "Withheld (too few learners)" + : formatCount(value), + }))} + sx={{ + "& .MuiChartsAxis-line, & .MuiChartsAxis-tick": { + stroke: ink.axis, + }, + "& .MuiChartsGrid-line": { stroke: ink.grid }, + }} + /> + + +
+
+ + + Program + + + Courses + + + Enrolled in contract courses + + + Enrolled via program + + + Completed program courses + + +
+
+ {rows.map((row) => ( + + + {row.program_title} + {showContract ? ( + + {" "} + · {row.b2b_contract_name} + + ) : null} + + + Courses + {formatCount(row.total_courses)} + + + Enrolled in contract courses + {formatCount(row.enrolled_in_contract_courses)} + + + Enrolled via program + + + + Completed program courses + + + + ))} +
+
+ {hasSuppressed ? ( + + {SUPPRESSED_EXPLANATION} + + ) : null} +
+
+ ) +} + +export default ProgramFunnelChart diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/SectionError.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/SectionError.tsx new file mode 100644 index 0000000000..61174b2a37 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/SectionError.tsx @@ -0,0 +1,25 @@ +"use client" + +import React from "react" +import { EmptyTableMessage } from "@/components/B2BTable/B2BTable" + +/** + * What a section renders in place of its content when its own query failed. + * + * Kept distinct from the empty state on purpose. Each section receives only + * `rows`, which is `undefined` on both a successful empty response and a failed + * one, so without this a failed section would say "No … recorded yet" — a + * manager would read that as "my org has no activity" rather than "we could not + * reach the analytics API". Same reasoning as the suppression marker: never let + * an absence of data render as a factual zero. + */ + +const SECTION_ERROR_MESSAGE = + "This data could not be loaded. Please try again later." + +const SectionError: React.FC = () => ( + {SECTION_ERROR_MESSAGE} +) + +export default SectionError +export { SECTION_ERROR_MESSAGE } diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/SectionHeader.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/SectionHeader.tsx new file mode 100644 index 0000000000..52916974c2 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/SectionHeader.tsx @@ -0,0 +1,112 @@ +"use client" + +import React from "react" +import { Skeleton, styled, Typography } from "ol-components" + +/** + * Each analytics section is backed by its own materialized view with its own + * refresh cycle, so freshness is a per-section fact, not a per-page one. The + * `as_of` therefore lives in the section header rather than once at the top — + * one lagging view must never be able to make another section look fresher + * than it is. + */ + +const Root = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "baseline", + justifyContent: "space-between", + gap: "16px", + flexWrap: "wrap", + [theme.breakpoints.down("md")]: { + gap: "4px", + }, +})) + +const TitleGroup = styled.div({ + display: "flex", + flexDirection: "column", + gap: "4px", +}) + +const Title = styled(Typography)(({ theme }) => ({ + ...theme.typography.h5, + color: theme.custom.colors.black, +})) as typeof Typography + +const Description = styled(Typography)(({ theme }) => ({ + ...theme.typography.body2, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const AsOf = styled(Typography)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, + whiteSpace: "nowrap", +})) as typeof Typography + +const formatAsOf = (iso: string): string | null => { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return null + return date.toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }) +} + +type SectionHeaderProps = { + title: string + description?: string + /** ISO timestamp of the backing view's last refresh; `null` before its first. */ + asOf?: string | null + isLoading?: boolean + /** The section's query failed, so nothing is known about its freshness. */ + isError?: boolean + /** Heading level, so section headings nest correctly under the page `h1`. */ + component?: React.ElementType +} + +const SectionHeader: React.FC = ({ + title, + description, + asOf, + isLoading, + isError, + component = "h2", +}) => { + const formatted = asOf ? formatAsOf(asOf) : null + + /** + * A failed request tells us nothing about when the view last refreshed, so + * this slot claims nothing at all — "Data not yet refreshed" would be a + * statement about the view that we are in no position to make. The section + * body says it could not load. + */ + const freshness = isError ? null : isLoading ? ( + + ) : formatted && asOf ? ( + + Data as of + + ) : ( + // Distinguish "the view has never refreshed" from "we are still + // loading" — a manager reading a zero needs to know which. + Data not yet refreshed + ) + + return ( + + + {title} + {description ? {description} : null} + + {freshness} + + ) +} + +export default SectionHeader +export { formatAsOf } +export type { SectionHeaderProps } diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/SectionTruncation.test.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/SectionTruncation.test.tsx new file mode 100644 index 0000000000..6451f5ed6f --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/SectionTruncation.test.tsx @@ -0,0 +1,118 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { ThemeProvider } from "ol-components" +import SectionTruncation from "./SectionTruncation" + +/** + * Branch-level cover for the truncation footer. The page test covers the wiring + * (that the real query's placeholder state reaches this component); driving the + * props directly here is what makes the completion branch cheap to test — via + * the page it would mean rendering a full result set into jsdom. + */ + +const renderTruncation = ( + props: Partial> = {}, +) => + render( + + + , + ) + +test("says nothing at all when the section holds every row", () => { + renderTruncation({ shown: 340, total: 340 }) + + expect(screen.queryByText(/Showing/)).not.toBeInTheDocument() + expect(screen.queryByRole("button")).not.toBeInTheDocument() +}) + +test("marks the control busy while the expanded page is in flight", () => { + renderTruncation({ isExpanding: true }) + + const button = screen.getByRole("button", { name: "Loading…" }) + expect(button).toHaveAttribute("aria-busy", "true") + // aria-disabled rather than disabled, so a keyboard user keeps focus. + expect(button).toHaveAttribute("aria-disabled", "true") + expect(button).not.toBeDisabled() +}) + +test("ignores a second click while already expanding", async () => { + const onShowAll = jest.fn() + renderTruncation({ isExpanding: true, onShowAll }) + + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Loading…" })) + + expect(onShowAll).not.toHaveBeenCalled() +}) + +test("announces nothing before the reader has asked for anything", () => { + renderTruncation() + + expect(screen.getByRole("status")).toHaveTextContent("") +}) + +/** + * The completion case is the one that needs the live region to outlive the + * control: once every row is shown, the message and button both unmount, so a + * region rendered inside that branch would announce into a node that no longer + * exists. + */ +test("announces completion even though the control has gone", async () => { + const onShowAll = jest.fn() + const { rerender } = renderTruncation({ onShowAll }) + + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Show all 340" })) + expect(onShowAll).toHaveBeenCalled() + + rerender( + + + , + ) + + expect(screen.getByRole("status")).toHaveTextContent( + "Now showing all 340 rows.", + ) + expect(screen.queryByRole("button")).not.toBeInTheDocument() +}) + +test("announces the new count when the section is still partial", async () => { + const onShowAll = jest.fn() + const { rerender } = renderTruncation({ onShowAll }) + + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Show all 340" })) + + rerender( + + + , + ) + + expect(screen.getByRole("status")).toHaveTextContent("Showing 3 of 340 rows.") +}) diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/SectionTruncation.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/SectionTruncation.tsx new file mode 100644 index 0000000000..a0f55fecf7 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/SectionTruncation.tsx @@ -0,0 +1,123 @@ +"use client" + +import React from "react" +import { styled, Typography } from "ol-components" +import { Button, VisuallyHidden } from "@mitodl/smoot-design" +import { formatCount } from "./format" + +/** + * Every analytics endpoint is paged, and a page cap is invisible in the rows + * themselves: 200 rows out of 340 look exactly like 340 out of 340. A manager + * reading a truncated table has no way to know they are missing courses unless + * the page says so — so it says so, and offers the rest. + * + * `total_count` is net of the anonymity floor (the API applies the same + * primary-cohort gate to the count as to the rows), so it can be compared + * against the rows on screen without ever implying there are hidden rows the + * caller could reach by paging. + * + * # Why this owns an announcement + * + * Expanding a section keeps the old rows on screen while the larger page loads + * (`keepPreviousData`), which is deliberate — blanking a table back to a + * skeleton on an explicit user action is worse. But it means a click otherwise + * produces no perceivable change at all until the rows quietly grow. The button + * therefore reports its own in-flight state, and the result is announced once + * it lands, so the click is observable without watching row counts. + */ + +const Root = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + flexWrap: "wrap", + gap: "8px", + [theme.breakpoints.down("md")]: { + alignItems: "flex-start", + flexDirection: "column", + }, +})) + +const Message = styled(Typography)(({ theme }) => ({ + ...theme.typography.body3, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +type SectionTruncationProps = { + /** Rows currently rendered. */ + shown: number + /** Rows this org has in the backing view, across every page. */ + total: number + onShowAll: () => void + /** + * False once the section already holds as many rows as the API will return + * in one response — there is nothing further to ask for, so offering a button + * that cannot deliver the rest would be a lie. + */ + canShowAll: boolean + /** The expanded page is in flight; the rows on screen are the previous ones. */ + isExpanding: boolean +} + +const SectionTruncation: React.FC = ({ + shown, + total, + onShowAll, + canShowAll, + isExpanding, +}) => { + const [hasExpanded, setHasExpanded] = React.useState(false) + + /** + * Derived from render state rather than detected as a rising/falling edge in + * an effect. An edge detector has to observe the in-flight render to arm + * itself, and React is free to batch a fast resolution into a single commit — + * so on a warm cache the announcement would silently never fire. This says + * "the user asked to expand, and we are no longer loading", which is true in + * either case. + */ + const announcement = + !hasExpanded || isExpanding + ? "" + : shown >= total + ? `Now showing all ${formatCount(total)} rows.` + : `Showing ${formatCount(shown)} of ${formatCount(total)} rows.` + + return ( + <> + {/* Rendered even once the section is complete, so the announcement of + that completion has somewhere to land — a live region that unmounts + in the same commit as the change it describes announces nothing. */} + + {announcement} + + {shown < total ? ( + + + Showing {formatCount(shown)} of {formatCount(total)}. + + {canShowAll ? ( + + ) : null} + + ) : null} + + ) +} + +export default SectionTruncation +export type { SectionTruncationProps } diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/chartPalette.ts b/frontends/main/src/app-pages/DashboardPage/Analytics/chartPalette.ts new file mode 100644 index 0000000000..b120ae3b03 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/chartPalette.ts @@ -0,0 +1,71 @@ +import type { Theme } from "ol-components" + +/** + * Chart colors for the B2B analytics dashboard. + * + * These are not eyeballed. Every value below was checked with the data-viz + * validator against the *light* surface the dashboard renders on (the app has + * no dark theme), on the adjacent pairlist that applies to lines and bars: + * lightness band, chroma floor, CVD separation under simulated protanopia and + * deuteranopia, a normal-vision separation floor, and >= 3:1 contrast vs the + * surface. Changing a value means re-running that check, not swapping a hex. + * + * Hues come from the smoot-design token set (`theme.custom.colors`), so the + * charts stay recognisably MIT Learn. Two of them are stepped: + * + * - AMBER is the `orange` token's hue (#FAB005), darkened to enter the + * lightness band and clear 3:1 on white. The token itself is far too light + * to carry a 2px line. + * - The lightest funnel step is the `blue` token's hue lightened; the + * `lightBlue` token is too pale to be distinguishable from the card. + * + * Green and red are deliberately absent: they are reserved for status, so that + * a red mark on this page always means "bad" and never "series 3". + */ + +/** + * Categorical hues — series *identity*. Assigned in this fixed order and never + * cycled; the order is what makes the palette colorblind-safe, so a chart with + * three series takes slots 1-3 in sequence rather than picking favourites. + * + * The list stops at three because these are the three MIT Learn hues that clear + * the separation floor together. A fourth series is not a fourth color: fold it + * into "Other" or split the chart. + */ +const CATEGORICAL = [ + "#1966FF", // blue token + "#B17F21", // orange token hue, stepped into the lightness band + "#FF14F0", // pink token +] as const + +/** + * Ordinal ramp for funnel stages — one hue, light to dark, so the reader sees + * the progression in the color itself. Stage order is the meaning here, which + * is why this is a ramp and not three categorical hues. + */ +const FUNNEL_STAGES = [ + "#7FA4EA", // lightest — widest stage + "#1966FF", + "#002896", // darkest — narrowest stage +] as const + +/** + * Non-data ink. Grid and axis lines stay recessive so the marks carry the + * chart; labels wear text tokens rather than the series color. + * + * Read from the theme rather than pinned as hexes, unlike the two palettes + * above. Nothing here is a validated data color — these are the same greys the + * rest of the dashboard's chrome already uses, so if smoot-design retunes a + * token the chart chrome has to move with it or the charts drift out of step + * with the tables beside them. The series colors are the opposite case: they + * are pinned precisely *because* they must not move without re-running the + * contrast and CVD checks. + */ +const chartInk = (theme: Theme) => ({ + grid: theme.custom.colors.lightGray2, + axis: theme.custom.colors.silverGrayLight, + label: theme.custom.colors.silverGrayDark, + surface: theme.custom.colors.white, +}) + +export { CATEGORICAL, chartInk, FUNNEL_STAGES } diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx new file mode 100644 index 0000000000..bf495aea62 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/charts.test.tsx @@ -0,0 +1,326 @@ +import React from "react" +import { render, screen, within } from "@testing-library/react" +import { ThemeProvider, useTheme } from "ol-components" +import type { Theme } from "ol-components" +import { factories } from "api/analytics-test-utils" +import EngagementTrendChart from "./EngagementTrendChart" +import ProgramFunnelChart from "./ProgramFunnelChart" +import { CATEGORICAL, chartInk, FUNNEL_STAGES } from "./chartPalette" + +/** + * Captures the live theme so the ink assertions below compare against the + * token, not a hex copied into this file — copying one here would reintroduce + * exactly the drift the theme lookup exists to prevent. + */ +let capturedTheme: Theme +const ThemeProbe = () => { + capturedTheme = useTheme() as Theme + return null +} + +/** + * Smoke coverage for the real charts (they are stubbed out in the page test). + * jsdom does no layout, so there is no point asserting on geometry; what these + * check is that the chart code runs, draws one mark per series, and paints + * those marks with the validated palette rather than the library's defaults. + */ + +const renderWithTheme = (ui: React.ReactElement) => + render( + + + {ui} + , + ) + +/** + * The two palettes above stay pinned as hexes on purpose — they are validated + * data colors and must not move without re-running the contrast/CVD checks. + * The non-data ink is the opposite case: it is the same grey chrome the tables + * beside these charts use, so it reads from the theme and moves when a + * smoot-design token is retuned. Asserted against the live token rather than a + * literal, since a literal here would just relocate the duplication and would + * still pass if the module went back to hardcoding. + */ +describe("chartInk", () => { + test("resolves non-data ink from theme tokens, not pinned hexes", () => { + renderWithTheme(
) + const colors = capturedTheme.custom.colors + + expect(chartInk(capturedTheme)).toEqual({ + grid: colors.lightGray2, + axis: colors.silverGrayLight, + label: colors.silverGrayDark, + surface: colors.white, + }) + }) +}) + +/** + * Both charts are paired with a table carrying the same numbers, which makes + * the SVG redundant for a screen reader — and worse than redundant, since + * traversing hundreds of series and axis nodes to reach data that is about to + * be presented properly is pure noise. So the chart is hidden and the table is + * the accessible copy. + * + * The second assertion in each case is the one that keeps this honest: + * `aria-hidden` on a subtree containing focusable content is itself a + * violation, because keyboard focus can land somewhere screen readers have been + * told does not exist. Neither chart renders anything focusable today; this + * fails if a future library version starts to. + */ +describe.each([ + [ + "EngagementTrendChart", + () => ( + + ), + "Monthly engagement", + ], + [ + "ProgramFunnelChart", + () => ( + + ), + "Program funnel", + ], +])("%s accessibility", (_name, renderChart, tableLabel) => { + test("hides the chart from assistive tech but keeps its table", () => { + const { container } = renderWithTheme(renderChart()) + + // eslint-disable-next-line testing-library/no-container + const svg = container.querySelector("svg") + expect(svg).toBeInTheDocument() + expect(svg?.closest("[aria-hidden='true']")).not.toBeNull() + + // The table is the accessible equivalent, so it must stay exposed. + const table = screen.getByRole("table", { name: tableLabel }) + expect(table.closest("[aria-hidden='true']")).toBeNull() + }) + + test("puts nothing focusable inside the hidden subtree", () => { + const { container } = renderWithTheme(renderChart()) + + // eslint-disable-next-line testing-library/no-container + const hidden = container.querySelector("[aria-hidden='true']") + const focusable = hidden?.querySelectorAll( + "a[href], button, input, select, textarea, [tabindex]", + ) + expect(focusable).toHaveLength(0) + }) +}) + +describe("EngagementTrendChart", () => { + const months = [ + factories.monthlyEngagementTrend({ activity_year_and_month: "2026-01" }), + factories.monthlyEngagementTrend({ activity_year_and_month: "2026-02" }), + ] + + test("renders a line per series in the categorical palette", () => { + const { container } = renderWithTheme( + , + ) + + // Testing Library has no query for "which paint attribute did this SVG + // element get", which is exactly what this asserts, so the container query + // is the only way to check the palette actually reached the marks. + // eslint-disable-next-line testing-library/no-container + const lines = container.querySelectorAll(".MuiLineElement-root") + expect(lines).toHaveLength(CATEGORICAL.length) + const strokes = Array.from(lines).map((line) => line.getAttribute("stroke")) + expect(strokes).toEqual([...CATEGORICAL]) + }) + + // Scoped to the legend: the paired table repeats every series name as a + // column header, which is the point of it, so an unscoped query matches twice. + test("labels every series so identity is never carried by color alone", () => { + renderWithTheme() + + expect( + screen.getByText("Active learners", { selector: ".MuiChartsLabel-root" }), + ).toBeInTheDocument() + expect( + screen.getByText("New enrollments", { selector: ".MuiChartsLabel-root" }), + ).toBeInTheDocument() + expect( + screen.getByText("Certificates earned", { + selector: ".MuiChartsLabel-root", + }), + ).toBeInTheDocument() + }) + + test("shows an empty state rather than an empty chart", () => { + renderWithTheme() + expect( + screen.getByText("No monthly activity recorded yet."), + ).toBeInTheDocument() + }) + + /** + * A month suppressed by the anonymity floor must be a gap in the line, not a + * dip to zero — so the null has to survive all the way into the series data. + */ + test("renders a suppressed month without inventing a zero", () => { + const rows = [ + factories.monthlyEngagementTrend({ + activity_year_and_month: "2026-01", + new_enrollments: null, + certificates_earned: null, + }), + factories.monthlyEngagementTrend({ activity_year_and_month: "2026-02" }), + ] + + expect(() => + renderWithTheme(), + ).not.toThrow() + }) + + /** + * The SVG is unreadable to a screen reader, so the monthly numbers have to + * exist as text too — the same pairing `ProgramFunnelChart` uses. + */ + test("pairs the chart with a table of the same numbers", () => { + renderWithTheme( + , + ) + + const table = screen.getByRole("table", { name: "Monthly engagement" }) + expect(within(table).getByText("Jan 2026")).toBeInTheDocument() + expect(within(table).getByText("84")).toBeInTheDocument() + expect(within(table).getByText("21")).toBeInTheDocument() + expect(within(table).getByText("7")).toBeInTheDocument() + }) + + /** + * A gap in a line reads as "no data" — only the table can say a month was + * withheld and why. + */ + test("explains suppressed months in the table instead of leaving a bare gap", () => { + renderWithTheme( + , + ) + + expect( + screen.getAllByLabelText(/Withheld: too few learners/).length, + ).toBeGreaterThan(0) + expect(screen.getByText(/Withheld: too few learners/)).toBeInTheDocument() + }) + + test("says the data could not be loaded rather than showing an empty state", () => { + renderWithTheme( + , + ) + + expect( + screen.getByText( + "This data could not be loaded. Please try again later.", + ), + ).toBeInTheDocument() + expect( + screen.queryByText("No monthly activity recorded yet."), + ).not.toBeInTheDocument() + }) +}) + +describe("ProgramFunnelChart", () => { + const programs = [ + factories.programFunnel({ + program_title: "Widget Engineering", + enrolled_in_contract_courses: 50, + enrolled_via_program: 30, + program_course_completers: 12, + }), + ] + + /** + * Asserted via the legend swatches rather than the bars themselves: bar + * geometry is derived from measured width, and jsdom reports zero, so no + * `.MuiBarElement-root` is ever emitted here. The legend marks carry the same + * per-series color, which is what this is actually checking. + */ + test("assigns the ordinal ramp to the funnel stages in order", () => { + const { container } = renderWithTheme( + , + ) + + // See the note on the line-chart palette test: paint attributes are not + // reachable through a Testing Library query. + // eslint-disable-next-line testing-library/no-container + const swatches = container.querySelectorAll(".MuiChartsLabelMark-fill") + expect(swatches).toHaveLength(FUNNEL_STAGES.length) + expect( + Array.from(swatches).map((swatch) => swatch.getAttribute("fill")), + ).toEqual([...FUNNEL_STAGES]) + }) + + test("labels every funnel stage so identity is never carried by color alone", () => { + renderWithTheme() + + expect( + screen.getByText("Enrolled in contract courses", { + selector: ".MuiChartsLabel-root", + }), + ).toBeInTheDocument() + expect( + screen.getByText("Enrolled via program", { + selector: ".MuiChartsLabel-root", + }), + ).toBeInTheDocument() + expect( + screen.getByText("Completed program courses", { + selector: ".MuiChartsLabel-root", + }), + ).toBeInTheDocument() + }) + + test("pairs the chart with a table of the same numbers", () => { + renderWithTheme() + + const table = screen.getByRole("table", { name: "Program funnel" }) + expect(table).toBeInTheDocument() + expect(screen.getByText("50")).toBeInTheDocument() + expect(screen.getByText("30")).toBeInTheDocument() + expect(screen.getByText("12")).toBeInTheDocument() + }) + + test("explains suppressed stages in the table instead of omitting them silently", () => { + renderWithTheme( + , + ) + + expect( + screen.getAllByLabelText(/Withheld: too few learners/).length, + ).toBeGreaterThan(0) + }) +}) diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/format.test.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/format.test.tsx new file mode 100644 index 0000000000..aefb4a1bb9 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/format.test.tsx @@ -0,0 +1,86 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import { ThemeProvider } from "ol-components" +import { + formatCount, + formatDate, + formatPercent, + formatYearMonth, + formatYearMonthShort, + SUPPRESSED_EXPLANATION, + SuppressibleValue, +} from "./format" + +describe("formatYearMonth", () => { + test.each([ + { input: "2026-01", long: "Jan 2026", short: "Jan" }, + { input: "2026-12", long: "Dec 2026", short: "Dec" }, + ])("formats $input as $long", ({ input, long, short }) => { + expect(formatYearMonth(input)).toBe(long) + expect(formatYearMonthShort(input)).toBe(short) + }) + + /** + * `new Date("2026-03")` is parsed as UTC midnight, which renders as February + * for anyone west of Greenwich. The formatter builds a local date instead; + * this is the regression guard for that. + */ + test("does not shift the month across timezones", () => { + expect(formatYearMonth("2026-03")).toBe("Mar 2026") + }) + + test("passes an unparseable value through rather than rendering garbage", () => { + expect(formatYearMonth("not-a-month")).toBe("not-a-month") + expect(formatYearMonthShort("")).toBe("") + }) +}) + +describe("number formatting", () => { + test("counts are thousands-separated", () => { + expect(formatCount(1234567)).toBe("1,234,567") + }) + + test("percentages keep at most one decimal", () => { + expect(formatPercent(62)).toBe("62%") + expect(formatPercent(32.35)).toBe("32.4%") + }) + + test("formatDate returns null for missing or invalid dates", () => { + expect(formatDate(null)).toBeNull() + expect(formatDate(undefined)).toBeNull() + expect(formatDate("nope")).toBeNull() + expect(formatDate("2026-01-15")).toBe("Jan 15, 2026") + }) +}) + +describe("SuppressibleValue", () => { + const renderValue = (value: number | null) => + render( + + + , + ) + + test("renders the formatted number when the API returned one", () => { + renderValue(42) + expect(screen.getByText("42")).toBeInTheDocument() + }) + + /** + * The whole point of the component: a suppressed value must never read as + * zero, and its reason must be available without hovering. + */ + test("explains a suppressed value instead of showing zero", () => { + renderValue(null) + expect(screen.queryByText("0")).not.toBeInTheDocument() + expect(screen.getByLabelText(SUPPRESSED_EXPLANATION)).toBeInTheDocument() + }) + + test("zero is a real value and is rendered as such", () => { + renderValue(0) + expect(screen.getByText("0")).toBeInTheDocument() + expect( + screen.queryByLabelText(SUPPRESSED_EXPLANATION), + ).not.toBeInTheDocument() + }) +}) diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/format.tsx b/frontends/main/src/app-pages/DashboardPage/Analytics/format.tsx new file mode 100644 index 0000000000..741ab281cf --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/format.tsx @@ -0,0 +1,128 @@ +"use client" + +import React from "react" +import { styled, Tooltip } from "ol-components" + +/** + * The analytics API nulls out any learner count below its k-anonymity floor, + * along with every rate and average derived from it. A `null` from these + * endpoints therefore means "withheld to protect learner privacy" — it is not + * zero, and it is not missing data. + * + * Everything in this module exists to keep that distinction visible: a + * suppressed value renders as a marked placeholder that says why, and no + * formatter silently turns `null` into `0`. + */ + +/** Copy used by both the tooltip and the per-section footnote, so they agree. */ +const SUPPRESSED_EXPLANATION = + "Withheld: too few learners in this group to report without identifying them." + +/** Thousands-separated integer. */ +const formatCount = (value: number): string => value.toLocaleString("en-US") + +/** One decimal place, because the API's rates are already rounded percentages. */ +const formatPercent = (value: number): string => + `${value.toLocaleString("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + })}%` + +/** One decimal place, for per-learner averages. */ +const formatAverage = (value: number): string => + value.toLocaleString("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }) + +/** + * `activity_year_and_month` arrives as a `YYYY-MM` string. Parsed by hand + * rather than with `new Date("2026-03")` — that parses as UTC midnight and then + * renders as the *previous* month for anyone west of Greenwich. + */ +const parseYearMonth = (yearMonth: string): Date | null => { + const match = /^(\d{4})-(\d{2})$/.exec(yearMonth) + if (!match) return null + return new Date(Number(match[1]), Number(match[2]) - 1, 1) +} + +/** Month and year, e.g. "Mar 2026". */ +const formatYearMonth = (yearMonth: string): string => + parseYearMonth(yearMonth)?.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }) ?? yearMonth + +/** Short month for a dense axis, e.g. "Mar". */ +const formatYearMonthShort = (yearMonth: string): string => + parseYearMonth(yearMonth)?.toLocaleDateString("en-US", { month: "short" }) ?? + yearMonth + +/** + * Contract start/end dates, which arrive as date-only `YYYY-MM-DD` strings. + * + * Built as a local date for the same reason as `parseYearMonth`: `new + * Date("2026-01-15")` is parsed as UTC midnight, so `toLocaleDateString` + * renders it as the 14th for any reader west of Greenwich. A contract that + * starts on the 15th must not display as starting on the 14th. + */ +const formatDate = (iso: string | null | undefined): string | null => { + if (!iso) return null + const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso) + const date = dateOnly + ? new Date( + Number(dateOnly[1]), + Number(dateOnly[2]) - 1, + Number(dateOnly[3]), + ) + : new Date(iso) + if (Number.isNaN(date.getTime())) return null + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) +} + +const SuppressedMark = styled.span(({ theme }) => ({ + color: theme.custom.colors.silverGrayDark, + // Dotted underline marks it as explained-on-hover rather than an em dash the + // reader is meant to interpret as "zero". + borderBottom: `1px dotted ${theme.custom.colors.silverGray}`, + cursor: "help", +})) + +/** + * Placeholder for a value the API suppressed. Carries its explanation to + * pointer users via the tooltip and to assistive tech via the accessible label, + * so the meaning never depends on hover alone. + */ +const Suppressed: React.FC = () => ( + + + — + + +) + +/** + * Render a possibly-suppressed value: the formatted number when present, the + * suppression marker when not. + */ +const SuppressibleValue: React.FC<{ + value: number | null | undefined + format?: (value: number) => string +}> = ({ value, format = formatCount }) => + value === null || value === undefined ? : <>{format(value)} + +export { + formatAverage, + formatCount, + formatDate, + formatPercent, + formatYearMonth, + formatYearMonthShort, + Suppressed, + SUPPRESSED_EXPLANATION, + SuppressibleValue, +} diff --git a/frontends/main/src/app-pages/DashboardPage/Analytics/orgUuid.ts b/frontends/main/src/app-pages/DashboardPage/Analytics/orgUuid.ts new file mode 100644 index 0000000000..5c90cf37ef --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/Analytics/orgUuid.ts @@ -0,0 +1,28 @@ +import type { OrganizationPage } from "@mitodl/mitxonline-api-axios/v2" + +/** + * The Keycloak organization UUID, which is what the analytics API keys every + * org endpoint on — it is the only identifier stable across the JWT, MITx + * Online and StarRocks (see mitodl/ol-analytics-api#13). + * + * MITx Online exposes it on `OrganizationPageSerializer` as + * `sso_organization_id` (mitodl/mitxonline#3789). That change has not been cut + * into a release of `@mitodl/mitxonline-api-axios` yet, so the generated + * `OrganizationPage` type does not declare the field and we have to read it + * off the wire ourselves. + * + * Returning `null` when it is absent is the load-bearing part: it is exactly + * what happens when mit-learn is pointed at a MITx Online deploy that predates + * that PR, and it must surface as "analytics unavailable for this org" rather + * than as a request to the analytics API with `undefined` in the path. + * + * TODO: delete this and read `org.sso_organization_id` directly once the + * regenerated client is picked up in `frontends/api/package.json`. + */ +const getOrgUuid = (org: OrganizationPage | undefined): string | null => { + const value = (org as { sso_organization_id?: unknown } | undefined) + ?.sso_organization_id + return typeof value === "string" && value.length > 0 ? value : null +} + +export { getOrgUuid } diff --git a/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx b/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx new file mode 100644 index 0000000000..9a7566a523 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.test.tsx @@ -0,0 +1,619 @@ +import React from "react" +import { renderWithProviders, screen, TestingErrorBoundary } from "@/test-utils" +import { setMockResponse } from "api/test-utils" +import { factories, urls } from "api/mitxonline-test-utils" +import { + factories as analyticsFactories, + urls as analyticsUrls, +} from "api/analytics-test-utils" +import { waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import type { AxiosError } from "axios" +import type { OrganizationPage } from "@mitodl/mitxonline-api-axios/v2" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { allowConsoleErrors } from "ol-test-utilities" +import { ForbiddenError } from "@/common/errors" +import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import AnalyticsContent from "./AnalyticsContent" + +jest.mock("next/image", () => ({ + __esModule: true, + default: (props: React.ImgHTMLAttributes) => { + // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text + return + }, +})) + +/** + * The charts are stubbed out. They render SVG whose geometry depends on + * measured layout, which jsdom does not do — exercising them here would assert + * on nothing useful. What this file covers is the page's own behaviour: access, + * availability, freshness and suppression. The numbers those charts draw are + * also rendered as text (KPI cards, the course table, the funnel's table view), + * so they are still asserted on below. + */ +jest.mock("@mui/x-charts/LineChart", () => ({ + __esModule: true, + LineChart: () =>
, +})) +jest.mock("@mui/x-charts/BarChart", () => ({ + __esModule: true, + BarChart: () =>
, +})) + +jest.mock("posthog-js/react", () => ({ + ...jest.requireActual("posthog-js/react"), + useFeatureFlagEnabled: jest.fn(), +})) +jest.mock("@/common/useFeatureFlagsLoaded") +const mockedUseFeatureFlagsLoaded = jest.mocked(useFeatureFlagsLoaded) +const mockedUseFeatureFlagEnabled = jest.mocked(useFeatureFlagEnabled) + +const managerOrgsUrl = urls.organization.managerOrganizationsList() + +const ORG_UUID = "3fa85f64-5717-4562-b3fc-2c963f66afa6" + +/** + * `sso_organization_id` is not on the generated `OrganizationPage` type yet + * (mitodl/mitxonline#3789 has not been released into the client), so it is + * spliced on here the same way it arrives on the wire. + */ +const orgWithUuid = ( + overrides: Partial = {}, + ssoOrganizationId: string | null = ORG_UUID, +) => { + const org = factories.organizations.organization({ + contracts: [factories.contracts.contract()], + ...overrides, + }) + return ssoOrganizationId + ? { ...org, sso_organization_id: ssoOrganizationId } + : org +} + +const setManagerOrgs = (orgs: unknown[]) => { + setMockResponse.get(managerOrgsUrl, { + count: orgs.length, + next: null, + previous: null, + results: orgs, + }) +} + +const AS_OF = "2026-07-01T04:00:00Z" + +const setAnalyticsResponses = ({ + utilization = [analyticsFactories.contractUtilization()], + trend = [analyticsFactories.monthlyEngagementTrend()], + courses = [analyticsFactories.enrollmentCompletionFunnel()], + programs = [analyticsFactories.programFunnel()], + page = { limit: 200 }, +}: { + utilization?: ReturnType[] + trend?: ReturnType[] + courses?: ReturnType[] + programs?: ReturnType[] + page?: { limit: number } +} = {}) => { + setMockResponse.get( + analyticsUrls.organizations.contractUtilization(ORG_UUID, page), + analyticsFactories.envelope(utilization, { as_of: AS_OF }), + ) + setMockResponse.get( + analyticsUrls.organizations.engagementTrend(ORG_UUID, page), + analyticsFactories.envelope(trend, { as_of: AS_OF }), + ) + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, page), + analyticsFactories.envelope(courses, { as_of: AS_OF }), + ) + setMockResponse.get( + analyticsUrls.organizations.programFunnel(ORG_UUID, page), + analyticsFactories.envelope(programs, { as_of: AS_OF }), + ) +} + +describe("AnalyticsContent", () => { + beforeEach(() => { + mockedUseFeatureFlagsLoaded.mockReturnValue(true) + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get( + urls.userMe.get(), + factories.user.user({ email: "manager@test.com" }), + ) + }) + + describe("access", () => { + test("throws ForbiddenError when the feature flag is off", () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + allowConsoleErrors() + + expect(() => + renderWithProviders(), + ).toThrow(ForbiddenError) + }) + + test("waits for PostHog rather than 403-ing on a bootstrapped false", () => { + mockedUseFeatureFlagsLoaded.mockReturnValue(false) + mockedUseFeatureFlagEnabled.mockReturnValue(undefined) + + expect(() => + renderWithProviders(), + ).not.toThrow() + }) + + test("denies access when the caller does not manage the requested org", async () => { + setManagerOrgs([orgWithUuid()]) + + renderWithProviders() + + await screen.findByRole("heading", { name: "Access denied" }) + }) + + /** + * A 403 from the analytics API is not handled inside the page: the browser + * query client throws on 401/403 so the route's error boundary handles it, + * the same as every other page. This asserts the error actually escapes + * rather than being swallowed into a half-rendered dashboard. + */ + test("lets an analytics 403 reach the error boundary", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + allowConsoleErrors() + const page = { limit: 200 } + const forbidden = ["Forbidden", { code: 403 }] as const + setMockResponse.get( + analyticsUrls.organizations.contractUtilization(ORG_UUID, page), + ...forbidden, + ) + setMockResponse.get( + analyticsUrls.organizations.engagementTrend(ORG_UUID, page), + ...forbidden, + ) + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, page), + ...forbidden, + ) + setMockResponse.get( + analyticsUrls.organizations.programFunnel(ORG_UUID, page), + ...forbidden, + ) + + const onError = jest.fn() + renderWithProviders( + + + , + ) + + await waitFor(() => expect(onError).toHaveBeenCalled()) + expect((onError.mock.calls[0][0] as AxiosError).response?.status).toBe( + 403, + ) + }) + + test("shows an error page when the manager org lookup fails", async () => { + allowConsoleErrors() + setMockResponse.get(managerOrgsUrl, "Internal Server Error", { + code: 500, + }) + + renderWithProviders() + + await screen.findByRole("heading", { name: "Something went wrong" }) + }) + }) + + describe("availability", () => { + /** + * An org whose MITx Online record predates the `sso_organization_id` + * field has no key the analytics API can be called with. That must read as + * "unavailable", never as a request with `undefined` in the path. + */ + test("reports unavailable, and issues no request, when the org has no UUID", async () => { + const org = orgWithUuid({}, null) + setManagerOrgs([org]) + + renderWithProviders( + , + ) + + await screen.findByText( + /Analytics is not available for this organization/, + ) + // No analytics response was ever registered; reaching the API would have + // failed the test via the mock adapter's console.error. + expect( + screen.queryByRole("heading", { name: "Contract utilization" }), + ).not.toBeInTheDocument() + }) + }) + + describe("content", () => { + test("renders every section with its own as-of date", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses() + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: "Contract utilization" }) + expect( + screen.getByRole("heading", { name: "Monthly engagement" }), + ).toBeInTheDocument() + expect( + screen.getByRole("heading", { name: "Course performance" }), + ).toBeInTheDocument() + expect( + screen.getByRole("heading", { name: "Program funnel" }), + ).toBeInTheDocument() + + // One "Data as of" per section — freshness is per materialized view. + const asOfLabels = await screen.findAllByText(/Data as of/) + expect(asOfLabels).toHaveLength(4) + }) + + test("renders the KPI figures from contract utilization", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses({ + utilization: [ + analyticsFactories.contractUtilization({ + b2b_contract_name: "Acme Site License", + seats_consumed: 62, + seat_limit: 100, + active_learners: 48, + seat_utilization_pct: 62, + completion_rate_pct: 32.3, + }), + ], + }) + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: "Acme Site License" }) + expect(screen.getByText("62%")).toBeInTheDocument() + expect(screen.getByText("48")).toBeInTheDocument() + expect(screen.getByText("32.3%")).toBeInTheDocument() + expect(screen.getByText("62 of 100 seats")).toBeInTheDocument() + }) + + test("renders course rows and marks suppressed values as withheld", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses({ + courses: [ + analyticsFactories.enrollmentCompletionFunnel({ + courserun_title: "Intro to Widgets", + enrolled_learners: 40, + // Below the anonymity floor: must not render as 0. + certified_learners: null, + completion_rate_pct: null, + }), + ], + }) + + renderWithProviders( + , + ) + + await screen.findByText("Intro to Widgets") + expect(screen.getByText("40")).toBeInTheDocument() + expect( + screen.getAllByLabelText(/Withheld: too few learners/).length, + ).toBeGreaterThan(0) + expect( + screen.getAllByText(/Withheld: too few learners/).length, + ).toBeGreaterThan(0) + }) + + test("renders the program funnel's table view alongside the chart", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses({ + programs: [ + analyticsFactories.programFunnel({ + program_title: "Widget Engineering", + total_courses: 6, + enrolled_in_contract_courses: 50, + enrolled_via_program: 30, + program_course_completers: 12, + }), + ], + }) + + renderWithProviders( + , + ) + + await screen.findByText("Widget Engineering") + // Scoped to this table: the monthly engagement section renders its own + // table of counts, which can legitimately carry the same numbers. + const table = screen.getByRole("table", { name: "Program funnel" }) + expect(within(table).getByText("50")).toBeInTheDocument() + expect(within(table).getByText("30")).toBeInTheDocument() + expect(within(table).getByText("12")).toBeInTheDocument() + }) + + test("says so when a view has never refreshed rather than implying freshness", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + const page = { limit: 200 } + setMockResponse.get( + analyticsUrls.organizations.contractUtilization(ORG_UUID, page), + analyticsFactories.envelope( + [analyticsFactories.contractUtilization()], + { + as_of: null, + }, + ), + ) + setMockResponse.get( + analyticsUrls.organizations.engagementTrend(ORG_UUID, page), + analyticsFactories.envelope([], { as_of: null }), + ) + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, page), + analyticsFactories.envelope([], { as_of: null }), + ) + setMockResponse.get( + analyticsUrls.organizations.programFunnel(ORG_UUID, page), + analyticsFactories.envelope([], { as_of: null }), + ) + + renderWithProviders( + , + ) + + expect( + (await screen.findAllByText("Data not yet refreshed")).length, + ).toBe(4) + expect(screen.queryByText(/Data as of/)).not.toBeInTheDocument() + }) + + test("renders empty states rather than blank sections", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses({ courses: [], programs: [], trend: [] }) + + renderWithProviders( + , + ) + + await screen.findByText("No course enrollments recorded yet.") + expect( + screen.getByText("No program enrollments recorded yet."), + ).toBeInTheDocument() + }) + + /** + * A section whose query failed has no rows and no `as_of`, which is exactly + * what a successful-but-empty section looks like. It must not borrow that + * section's copy: "No course enrollments recorded yet" is a claim about the + * org, and "Data not yet refreshed" is a claim about the view, and neither + * is known to be true when the request never came back. + */ + test("distinguishes a failed section from an empty one", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + allowConsoleErrors() + setAnalyticsResponses() + // Overrides the successful response registered just above. + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, { limit: 200 }), + "Internal Server Error", + { code: 500 }, + ) + + renderWithProviders( + , + ) + + await screen.findByText( + "This data could not be loaded. Please try again later.", + ) + expect( + screen.queryByText("No course enrollments recorded yet."), + ).not.toBeInTheDocument() + expect( + screen.queryByText("Data not yet refreshed"), + ).not.toBeInTheDocument() + // The three sections that did load keep their own freshness stamp. + expect(screen.getAllByText(/Data as of/)).toHaveLength(3) + expect( + screen.getByText(/Some analytics could not be loaded/), + ).toBeInTheDocument() + }) + }) + + /** + * Every endpoint is paged, and a truncated page is invisible in the rows: 2 + * of 340 looks exactly like 2 of 2. The envelope's `total_count` is the only + * thing that distinguishes them. + */ + describe("truncation", () => { + const courseRows = (count: number) => + Array.from({ length: count }, (_, index) => + analyticsFactories.enrollmentCompletionFunnel({ + courserun_pk: index + 1, + courserun_title: `Course ${index + 1}`, + }), + ) + + test("says nothing when a section holds every row", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses() + + renderWithProviders( + , + ) + + await screen.findByRole("heading", { name: "Course performance" }) + expect(screen.queryByText(/Showing \d+ of/)).not.toBeInTheDocument() + expect( + screen.queryByRole("button", { name: /Show all/ }), + ).not.toBeInTheDocument() + }) + + test("admits to showing a subset, and loads the rest on request", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses() + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, { limit: 200 }), + analyticsFactories.envelope(courseRows(2), { + as_of: AS_OF, + total_count: 340, + }), + ) + // "Show all" asks for the whole result set in one page. + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, { limit: 340 }), + analyticsFactories.envelope(courseRows(3), { + as_of: AS_OF, + total_count: 340, + }), + ) + + const user = userEvent.setup() + renderWithProviders( + , + ) + + await screen.findByText("Showing 2 of 340.") + await user.click(screen.getByRole("button", { name: "Show all 340" })) + + await screen.findByText("Course 3") + // Still short of the total, so the count updates rather than disappearing. + expect(screen.getByText("Showing 3 of 340.")).toBeInTheDocument() + }) + + /** + * The expanded page keeps the old rows on screen while it loads, so nothing + * visibly changes on click. Without the busy state and the live region a + * screen reader gets no feedback at all that the button did anything. + */ + test("marks the button busy while expanding and announces the result", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses() + // Row counts stay tiny deliberately: rendering hundreds of rows in jsdom + // is slow enough to make this flaky under parallel load, and the wiring + // under test doesn't depend on the magnitudes. `total_count` still has to + // exceed the page size, or there would be nothing to expand. + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, { limit: 200 }), + analyticsFactories.envelope(courseRows(2), { + as_of: AS_OF, + total_count: 340, + }), + ) + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, { limit: 340 }), + analyticsFactories.envelope(courseRows(3), { + as_of: AS_OF, + total_count: 340, + }), + ) + + const user = userEvent.setup() + renderWithProviders( + , + ) + + const button = await screen.findByRole("button", { name: "Show all 340" }) + expect(button).toHaveAttribute("aria-busy", "false") + + await user.click(button) + + await screen.findByText("Showing 3 of 340 rows.") + }) + + /** + * The API applies its LIMIT in SQL and drops sub-floor rows afterwards, so + * a page of `total_count` rows can still come back short — permanently. + * Asking again would resend the same limit and change nothing, so the + * button has to go rather than sit there doing nothing. + */ + test("stops offering more once a section has already asked for the total", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses() + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, { limit: 200 }), + analyticsFactories.envelope(courseRows(2), { + as_of: AS_OF, + total_count: 340, + }), + ) + // Asked for all 340 and got 3 back: the rest were dropped by the floor + // after the LIMIT had already been applied. (Tiny counts on purpose — see + // the note in the busy/announce test.) + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, { limit: 340 }), + analyticsFactories.envelope(courseRows(3), { + as_of: AS_OF, + total_count: 340, + }), + ) + + const user = userEvent.setup() + renderWithProviders( + , + ) + + await user.click( + await screen.findByRole("button", { name: "Show all 340" }), + ) + + await screen.findByText("Showing 3 of 340.") + expect( + screen.queryByRole("button", { name: /Show all/ }), + ).not.toBeInTheDocument() + }) + + /** + * The API answers 422 above its max_page_size, so past that point there is + * no request left to make — the message has to stand on its own rather than + * offering a button that cannot deliver. + */ + test("drops the button once a section is already at the API's page cap", async () => { + const org = orgWithUuid() + setManagerOrgs([org]) + setAnalyticsResponses() + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, { limit: 200 }), + analyticsFactories.envelope(courseRows(2), { + as_of: AS_OF, + total_count: 5000, + }), + ) + setMockResponse.get( + analyticsUrls.organizations.enrollmentFunnel(ORG_UUID, { limit: 1000 }), + analyticsFactories.envelope(courseRows(4), { + as_of: AS_OF, + total_count: 5000, + }), + ) + + const user = userEvent.setup() + renderWithProviders( + , + ) + + // Capped at max_page_size rather than asking for all 5000. + await screen.findByRole("button", { name: "Show all 5,000" }) + await user.click(screen.getByRole("button", { name: "Show all 5,000" })) + + await screen.findByText("Showing 4 of 5,000.") + expect( + screen.queryByRole("button", { name: /Show all/ }), + ).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx b/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx new file mode 100644 index 0000000000..62caacb278 --- /dev/null +++ b/frontends/main/src/app-pages/DashboardPage/AnalyticsContent.tsx @@ -0,0 +1,409 @@ +"use client" + +import React from "react" +import Image from "next/image" +import { keepPreviousData, useQuery } from "@tanstack/react-query" +import type { AxiosError } from "axios" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { Skeleton, Stack, styled, Typography } from "ol-components" +import { ButtonLink } from "@mitodl/smoot-design" +import { managerOrganizationQueries } from "api/mitxonline-hooks/organizations" +import { analyticsOrganizationQueries } from "api/analytics-hooks/organizations" +import { isAnalyticsConfigured } from "api/runtime" +import { matchOrganizationBySlug } from "@/common/utils" +import { ForbiddenError } from "@/common/errors" +import { FeatureFlags } from "@/common/feature_flags" +import { useFeatureFlagsLoaded } from "@/common/useFeatureFlagsLoaded" +import { contractAdminView } from "@/common/urls" +import { ErrorContent } from "../ErrorPage/ErrorPageTemplate" +import graduateLogo from "@/public/images/dashboard/graduate.png" +import ContractKpiCards from "./Analytics/ContractKpiCards" +import CoursePerformanceTable from "./Analytics/CoursePerformanceTable" +import EngagementTrendChart from "./Analytics/EngagementTrendChart" +import ProgramFunnelChart from "./Analytics/ProgramFunnelChart" +import SectionHeader from "./Analytics/SectionHeader" +import SectionTruncation from "./Analytics/SectionTruncation" +import { getOrgUuid } from "./Analytics/orgUuid" + +/** + * Org-scoped B2B analytics, the reporting half of the org-manager dashboard + * whose other half is `ContractAdminPage` (seat administration). It reuses that + * page's layout, header and table primitives so the two read as one product. + * + * # Access + * + * Authorization is the analytics API's job, not this component's: it checks + * membership and org-manager status from the JWT that APISIX mints from the + * session cookie, and answers 403 otherwise. What this component does is + * resolve which org the caller means. It does so through MITx Online's + * *manager* org list — the same source `ContractAdminPage` uses — so a learner + * who is not a manager never gets as far as issuing an analytics request. + */ + +/** + * No page container here: the `/dashboard` layout already renders children + * inside its own `Container` and sidebar grid column, so adding another would + * double the padding and fight the grid. `ContractContent` does the same. + */ +const HeaderSection = styled.div(({ theme }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: "24px", + [theme.breakpoints.down("md")]: { + flexDirection: "column", + alignItems: "flex-start", + }, +})) + +const OrgDetailsContainer = styled.div({ + display: "flex", + alignItems: "center", + gap: "24px", +}) + +const ImageContainer = styled.div(({ theme }) => ({ + display: "flex", + width: "60px", + height: "60px", + padding: "8px", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + borderRadius: "8px", + backgroundColor: theme.custom.colors.white, + border: `1px solid ${theme.custom.colors.lightGray2}`, + overflow: "hidden", + "> img": { + width: "100%", + height: "auto", + }, +})) + +const OrgName = styled(Typography)(({ theme }) => ({ + ...theme.typography.h3, + color: theme.custom.colors.darkGray2, +})) as typeof Typography + +const PageSubtitle = styled(Typography)(({ theme }) => ({ + ...theme.typography.subtitle1, + color: theme.custom.colors.silverGrayDark, +})) as typeof Typography + +const Section = styled.section({ + display: "flex", + flexDirection: "column", + gap: "16px", +}) + +const Notice = styled(Typography)(({ theme }) => ({ + ...theme.typography.body2, + color: theme.custom.colors.silverGrayDark, + backgroundColor: theme.custom.colors.lightGray1, + border: `1px solid ${theme.custom.colors.lightGray2}`, + borderRadius: "8px", + padding: "24px", +})) as typeof Typography + +/** + * These views are small per org (contracts, programs, a couple of years of + * months), but the API caps every list endpoint, so ask for a page big enough + * that no org is truncated at the default. + * + * "Big enough" is not "always enough", though, which is why every section + * compares what it rendered against the envelope's `total_count` and says so + * when it is showing a subset — see `SectionTruncation`. + */ +const PAGE_SIZE = 200 + +/** + * The analytics API's own `max_page_size`; it answers 422 above this. Caps what + * "Show all" is allowed to ask for, so the button never issues a request the + * API will reject. + */ +const MAX_PAGE_SIZE = 1000 + +type SectionKey = "utilization" | "trend" | "courses" | "programs" + +/** + * Note there is no 401/403 branch for the analytics queries themselves. The + * browser query client (`makeBrowserQueryClient`) sets `throwOnError` for 400, + * 401 and 403, so a caller the analytics API rejects never reaches an + * `isError` branch here at all — the error is thrown to the route's error + * boundary, which is where every other page's access denial is handled too. + * Only the remaining statuses (5xx, network) surface as `isError` below, and + * those mean "could not load", not "not allowed". + */ +const isForbidden = (error: unknown) => { + const status = (error as AxiosError | null)?.response?.status + return status === 401 || status === 403 +} + +type AnalyticsContentInternalProps = { + orgSlug: string +} + +const AnalyticsContentInternal: React.FC = ({ + orgSlug, +}) => { + const { + data: managerOrgs, + isLoading: isLoadingOrgs, + isError: isOrgsError, + error: orgsError, + } = useQuery(managerOrganizationQueries.managerOrganizationsList()) + + const org = managerOrgs?.find(matchOrganizationBySlug(orgSlug)) + const orgUuid = getOrgUuid(org) + const analyticsAvailable = isAnalyticsConfigured() && !!orgUuid + + // Per-section page size. Raised only by that section's "Show all", so + // expanding a truncated course table never refetches the other three. + const [limits, setLimits] = React.useState>({ + utilization: PAGE_SIZE, + trend: PAGE_SIZE, + courses: PAGE_SIZE, + programs: PAGE_SIZE, + }) + + // One query per endpoint, each backed by its own materialized view with its + // own refresh time, so sections load and report freshness independently. + // + // Four `useQuery` calls rather than one `useQueries`, which is what this was + // originally. `placeholderData: keepPreviousData` is silently inert under + // `useQueries` here — on a limit change the result went straight to + // `data: undefined`, so the section a manager had just asked to expand blanked + // back to its skeleton, which is exactly what the option was there to prevent. + // It behaves as documented on `useQuery` (as it does on ContractAdminPage). + // The count is fixed and the order never changes, so this is hook-safe. + const utilization = useQuery({ + ...analyticsOrganizationQueries.contractUtilization(orgUuid ?? "", { + limit: limits.utilization, + }), + enabled: analyticsAvailable, + placeholderData: keepPreviousData, + }) + const trend = useQuery({ + ...analyticsOrganizationQueries.engagementTrend(orgUuid ?? "", { + limit: limits.trend, + }), + enabled: analyticsAvailable, + placeholderData: keepPreviousData, + }) + const courses = useQuery({ + ...analyticsOrganizationQueries.enrollmentFunnel(orgUuid ?? "", { + limit: limits.courses, + }), + enabled: analyticsAvailable, + placeholderData: keepPreviousData, + }) + const programs = useQuery({ + ...analyticsOrganizationQueries.programFunnel(orgUuid ?? "", { + limit: limits.programs, + }), + enabled: analyticsAvailable, + placeholderData: keepPreviousData, + }) + + /** + * The truncation footer for one section, or null when it is showing + * everything. "Show all" asks for the whole result set in a single page, + * bounded by what the API will serve — beyond that the message stands alone, + * since a button that cannot deliver the rest would be worse than none. + */ + const truncation = ( + query: { + data?: { total_count: number; data: unknown[] } + isPlaceholderData: boolean + }, + key: SectionKey, + ) => { + if (!query.data) return null + const { total_count: total } = query.data + const shown = query.data.data.length + const nextLimit = Math.min(total, MAX_PAGE_SIZE) + return ( + limits[key]} + onShowAll={() => + setLimits((current) => ({ ...current, [key]: nextLimit })) + } + /> + ) + } + + if (isLoadingOrgs) { + return + } + + if (isOrgsError) { + return isForbidden(orgsError) ? ( + + ) : ( + + ) + } + + // Not in the manager org list means not an org manager for this org — the + // same 403 the contract admin page gives, decided before any analytics call. + if (!org) { + return + } + + const firstContractSlug = org.contracts[0]?.slug + + const header = ( + + + + + +
+ {org.name} + Analytics +
+
+ {firstContractSlug ? ( + + Manage seats + + ) : null} +
+ ) + + if (!analyticsAvailable) { + return ( + + {header} + + {isAnalyticsConfigured() + ? // The org record has no Keycloak organization UUID, which is + // what the analytics API keys on. Nothing the manager can fix. + "Analytics is not available for this organization yet. Please contact support if you expect to see data here." + : "Analytics is not available in this environment."} + + + ) + } + + const failed = [utilization, trend, courses, programs].some( + (query) => query.isError, + ) + + return ( + + {header} + + {failed ? ( + + Some analytics could not be loaded. The figures below may be + incomplete. + + ) : null} + +
+ + + {truncation(utilization, "utilization")} +
+ +
+ + + {truncation(trend, "trend")} +
+ +
+ + + {truncation(courses, "courses")} +
+ +
+ + + {truncation(programs, "programs")} +
+
+ ) +} + +type AnalyticsContentProps = { + orgSlug: string +} + +const AnalyticsContent: React.FC = ({ orgSlug }) => { + const flagEnabled = useFeatureFlagEnabled(FeatureFlags.B2BAnalyticsDashboard) + const flagsLoaded = useFeatureFlagsLoaded() + + // The whole page is behind the flag, so wait for the real value rather than + // 403-ing on a bootstrapped `false` — same reasoning as ContractAdminPage. + if (!flagsLoaded) { + return + } + + if (!flagEnabled) { + throw new ForbiddenError("Not enabled.") + } + + return +} + +export default AnalyticsContent +export type { AnalyticsContentProps } diff --git a/frontends/main/src/app-pages/PodcastPage/AudioPlayer.styled.ts b/frontends/main/src/app-pages/PodcastPage/AudioPlayer.styled.ts index ee464ef660..0104f609fd 100644 --- a/frontends/main/src/app-pages/PodcastPage/AudioPlayer.styled.ts +++ b/frontends/main/src/app-pages/PodcastPage/AudioPlayer.styled.ts @@ -159,3 +159,37 @@ export const SpeedButton = styled.button(({ theme }) => ({ color: theme.custom.colors.red, }, })) + +/** + * Playback-failure message. Takes the progress row's grid area, replacing the + * seek slider — which has nothing to scrub — so the players report the failure + * without growing taller and pushing the fixed bar over the page content. + */ +export const PlaybackError = styled.div(({ theme }) => ({ + gridArea: "progress", + display: "flex", + alignItems: "center", + gap: "8px", + minWidth: 0, + color: theme.custom.colors.red, + "& > svg": { + flexShrink: 0, + width: "20px", + height: "20px", + }, +})) + +export const PlaybackErrorText = styled(Typography)({ + minWidth: 0, + // Bound the message at two lines: one fits the desktop bar, two fit the + // taller mobile layout, and neither can push the transport controls around. + display: "-webkit-box", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + overflow: "hidden", +}) + +export const RetryButton = styled(SpeedButton)(({ theme }) => ({ + borderColor: theme.custom.colors.red, + color: theme.custom.colors.red, +})) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.test.tsx index 9de32b0ac2..ee2ac06ea3 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.test.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.test.tsx @@ -308,6 +308,69 @@ describe("PodcastEmbedPlayer", () => { }) }) + describe("playback errors", () => { + const simulateMediaError = (audio: HTMLAudioElement, code: number) => { + Object.defineProperty(audio, "error", { + value: { code }, + configurable: true, + }) + fireEvent.error(audio) + } + + test("shows a message when the source is rejected (e.g. HTTP 451)", async () => { + const { audio } = renderPlayer() + simulateMediaError(audio, 4) // MEDIA_ERR_SRC_NOT_SUPPORTED + + expect(await screen.findByRole("alert")).toHaveTextContent( + /unavailable in your region/i, + ) + }) + + test("replaces the seek slider with the message", async () => { + const { audio } = renderPlayer() + expect(screen.getByRole("slider", { name: /seek/i })).toBeInTheDocument() + + simulateMediaError(audio, 2) + + await screen.findByRole("alert") + expect( + screen.queryByRole("slider", { name: /seek/i }), + ).not.toBeInTheDocument() + }) + + test("reports missing audio for an episode with no audio_url", () => { + const resource = makeEpisode({ + podcast_episode: { + ...makeEpisode().podcast_episode!, + audio_url: null as unknown as string, + episode_link: null, + }, + }) + renderPlayer(resource) + + expect(screen.getByRole("alert")).toHaveTextContent( + /audio isn't available for this episode/i, + ) + expect( + screen.queryByRole("button", { name: /try again/i }), + ).not.toBeInTheDocument() + }) + + test("Try again reloads the source and clears the message", async () => { + const { audio } = renderPlayer() + simulateMediaError(audio, 2) + await screen.findByRole("alert") + + jest.clearAllMocks() + fireEvent.click(screen.getByRole("button", { name: /try again/i })) + + expect(window.HTMLMediaElement.prototype.load).toHaveBeenCalled() + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ) + }) + }) + describe("no close button", () => { test("does not render a close button", () => { renderPlayer() diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.tsx index c0d6097cfe..763dc413d2 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.tsx @@ -2,15 +2,18 @@ import React from "react" import { styled } from "ol-components" +import { VisuallyHidden } from "@mitodl/smoot-design" import { RiPlayCircleLine, RiPauseCircleLine, RiReplay10Line, RiForward30Line, + RiErrorWarningLine, } from "@remixicon/react" import type { LearningResource } from "api/v1" import { getEpisodeAudioUrl } from "./PodcastsListingPage/helpers" import { useAudioPlayer, formatClockTime } from "./useAudioPlayer" +import { usePlaybackRecovery, RETRYING_STATUS } from "./usePlaybackRecovery" import { TrackInfo as TrackInfoBase, TrackTitle, @@ -23,6 +26,9 @@ import { ProgressRange, TimeLabel, SpeedButton as SpeedButtonBase, + PlaybackError, + PlaybackErrorText, + RetryButton, } from "./AudioPlayer.styled" // ─── Styled components (card layout) ──────────────────────────────────────────── @@ -129,12 +135,23 @@ const PodcastEmbedPlayer: React.FC = ({ duration, percent, speed, + error, togglePlay, skip, cycleSpeed, seek, + retry, } = useAudioPlayer(audioUrl) + const isPlayDisabled = isBuffering || isPlayPending || !hasAudioSource + const { + playButtonRef, + retryButtonRef, + onProgressFocus, + requestRetry, + isRetrying, + } = usePlaybackRecovery(error, retry, isPlayDisabled) + const Wrapper = inline ? InlineWrapper : Shell return ( @@ -142,7 +159,19 @@ const PodcastEmbedPlayer: React.FC = ({ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}