Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions frontends/api/src/analytics/test-utils/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ import type {

const organizationId = () => faker.string.uuid()

/**
* `contract_pk`, `courserun_pk` and `program_pk` are dbt surrogate keys —
* `dbt_utils.generate_surrogate_key` MD5-hashes its inputs, so they are
* 32-character lowercase hex strings, never integers. Fixtures that generated
* numbers here were passing while production sent strings (ol-analytics-api#29,
* which 500ed on exactly that mismatch server-side).
*/
const surrogateKey = () =>
faker.string.hexadecimal({ length: 32, casing: "lower", prefix: "" })

const envelope = <RowT>(
data: RowT[],
overrides: Partial<OrgAnalyticsResponse<RowT>> = {},
Expand All @@ -36,7 +46,7 @@ const contractUtilization = (
): ContractUtilization => ({
organization_key: faker.string.alphanumeric(6).toUpperCase(),
organization_name: faker.company.name(),
contract_pk: faker.number.int({ min: 1, max: 10000 }),
contract_pk: surrogateKey(),
b2b_contract_name: `${faker.company.name()} Contract`,
b2b_contract_is_active: true,
b2b_contract_start_date: "2026-01-01",
Expand All @@ -56,9 +66,9 @@ const enrollmentCompletionFunnel = (
): EnrollmentCompletionFunnel => ({
organization_key: faker.string.alphanumeric(6).toUpperCase(),
organization_name: faker.company.name(),
contract_pk: faker.number.int({ min: 1, max: 10000 }),
contract_pk: surrogateKey(),
b2b_contract_name: `${faker.company.name()} Contract`,
courserun_pk: faker.number.int({ min: 1, max: 10000 }),
courserun_pk: surrogateKey(),
courserun_readable_id: `course-v1:MITx+${faker.string.alphanumeric(5)}+2026`,
courserun_title: faker.commerce.productName(),
enrolled_learners: 40,
Expand All @@ -78,10 +88,15 @@ const monthlyEngagementTrend = (
activity_year_and_month: "2026-01",
monthly_active_learners: 30,
new_enrollments: 12,
enrolling_learners: 10,
certificates_earned: 5,
certified_learners: 5,
total_videos_watched: 900,
video_watchers: 22,
total_problems_attempted: 1200,
problem_attempters: 25,
total_chatbot_interactions: 80,
chatbot_users: 14,
...overrides,
})

Expand All @@ -90,9 +105,9 @@ const programFunnel = (
): ProgramFunnel => ({
organization_key: faker.string.alphanumeric(6).toUpperCase(),
organization_name: faker.company.name(),
contract_pk: faker.number.int({ min: 1, max: 10000 }),
contract_pk: surrogateKey(),
b2b_contract_name: `${faker.company.name()} Contract`,
program_pk: faker.number.int({ min: 1, max: 10000 }),
program_pk: surrogateKey(),
program_title: `${faker.commerce.department()} Program`,
total_courses: 6,
enrolled_in_contract_courses: 50,
Expand All @@ -112,8 +127,10 @@ const contentEngagementDepth = (
engaged_learners: 28,
engagement_rate_pct: 70,
total_videos_watched: 800,
video_watchers: 22,
avg_videos_per_engaged_learner: 28.6,
total_problems_attempted: 1000,
problem_attempters: 24,
avg_problems_per_engaged_learner: 35.7,
total_chatbot_interactions: 60,
chatbot_users: 14,
Expand Down
30 changes: 22 additions & 8 deletions frontends/api/src/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
* 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.
*
* The activity totals (`total_videos_watched` and friends) are nullable for a
* less obvious reason: they count events, not learners, so the floor is applied
* through the cohort that produced them rather than to the total itself. A
* large total suppresses when few enough learners are behind it — 500 videos
* watched by 2 learners comes back `null`. Do not infer from a big number that
* it is safe to display, and do not treat a `null` total as "no activity".
*/

/**
Expand All @@ -40,7 +47,7 @@ export type OrgAnalyticsResponse<RowT> = {
export type ContractUtilization = {
organization_key: string
organization_name: string
contract_pk: number
contract_pk: string
b2b_contract_name: string
b2b_contract_is_active: boolean
b2b_contract_start_date: string | null
Expand All @@ -58,9 +65,9 @@ export type ContractUtilization = {
export type EnrollmentCompletionFunnel = {
organization_key: string
organization_name: string
contract_pk: number
contract_pk: string
b2b_contract_name: string
courserun_pk: number
courserun_pk: string
courserun_readable_id: string
courserun_title: string
enrolled_learners: number
Expand All @@ -82,19 +89,24 @@ export type MonthlyEngagementTrend = {
activity_year_and_month: string
monthly_active_learners: number
new_enrollments: number | null
enrolling_learners: number | null
certificates_earned: number | null
total_videos_watched: number
total_problems_attempted: number
total_chatbot_interactions: number
certified_learners: number | null
total_videos_watched: number | null
video_watchers: number | null
total_problems_attempted: number | null
problem_attempters: number | null
total_chatbot_interactions: number | null
chatbot_users: number | null
}

/** `mv_b2b_program_funnel` — grain: org x contract x program. */
export type ProgramFunnel = {
organization_key: string
organization_name: string
contract_pk: number
contract_pk: string
b2b_contract_name: string
program_pk: number
program_pk: string
program_title: string
total_courses: number
enrolled_in_contract_courses: number
Expand All @@ -112,8 +124,10 @@ export type ContentEngagementDepth = {
engaged_learners: number | null
engagement_rate_pct: number | null
total_videos_watched: number | null
video_watchers: number | null
avg_videos_per_engaged_learner: number | null
total_problems_attempted: number | null
problem_attempters: number | null
avg_problems_per_engaged_learner: number | null
total_chatbot_interactions: number | null
chatbot_users: number | null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const CoursePerformanceTable: React.FC<{
// 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<number, EnrollmentCompletionFunnel[]>()
const contracts = new Map<string, EnrollmentCompletionFunnel[]>()
rows.forEach((row) => {
const existing = contracts.get(row.contract_pk)
if (existing) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ describe("AnalyticsContent", () => {
const courseRows = (count: number) =>
Array.from({ length: count }, (_, index) =>
analyticsFactories.enrollmentCompletionFunnel({
courserun_pk: index + 1,
courserun_pk: String(index + 1),
courserun_title: `Course ${index + 1}`,
}),
)
Expand Down
Loading