@playcademy/sandbox 0.4.0 → 0.4.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -243,11 +243,12 @@ var init_platform = __esm(() => {
243
243
  var PLATFORM_TIMEZONE = "America/New_York";
244
244
 
245
245
  // ../constants/src/timeback.ts
246
- var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS;
246
+ var TIMEBACK_ROUTES, TIMEBACK_ORG_SOURCED_ID = "PLAYCADEMY", TIMEBACK_ORG_NAME = "Playcademy Studios", TIMEBACK_ORG_TYPE = "department", TIMEBACK_COURSE_DEFAULTS, TIMEBACK_RESOURCE_DEFAULTS, TIMEBACK_COMPONENT_DEFAULTS, TIMEBACK_COMPONENT_RESOURCE_DEFAULTS, TIMEBACK_GAME_METRIC_DECIMAL_PLACES, TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE;
247
247
  var init_timeback2 = __esm(() => {
248
248
  TIMEBACK_ROUTES = {
249
249
  END_ACTIVITY: "/integrations/timeback/end-activity",
250
250
  GET_XP: "/integrations/timeback/xp",
251
+ GET_MASTERY: "/integrations/timeback/mastery",
251
252
  HEARTBEAT: "/integrations/timeback/heartbeat",
252
253
  ADVANCE_COURSE: "/integrations/timeback/advance-course"
253
254
  };
@@ -287,6 +288,17 @@ var init_timeback2 = __esm(() => {
287
288
  sortOrder: 1,
288
289
  lessonType: "quiz"
289
290
  };
291
+ TIMEBACK_GAME_METRIC_DECIMAL_PLACES = {
292
+ xp: 1,
293
+ mastery: 0,
294
+ score: 2
295
+ };
296
+ TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE = {
297
+ xp: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.xp,
298
+ mastery: 0,
299
+ time: 60,
300
+ score: 0.5 / 10 ** TIMEBACK_GAME_METRIC_DECIMAL_PLACES.score
301
+ };
290
302
  });
291
303
 
292
304
  // ../constants/src/cloudflare.ts
@@ -1064,7 +1076,7 @@ var package_default;
1064
1076
  var init_package = __esm(() => {
1065
1077
  package_default = {
1066
1078
  name: "@playcademy/sandbox",
1067
- version: "0.4.0",
1079
+ version: "0.4.1-beta.2",
1068
1080
  description: "Local development server for Playcademy game development",
1069
1081
  type: "module",
1070
1082
  exports: {
@@ -28234,6 +28246,7 @@ var init_constants3 = __esm(() => {
28234
28246
  TIMEBACK: {
28235
28247
  END_ACTIVITY: `/api${TIMEBACK_ROUTES.END_ACTIVITY}`,
28236
28248
  GET_XP: `/api${TIMEBACK_ROUTES.GET_XP}`,
28249
+ GET_MASTERY: `/api${TIMEBACK_ROUTES.GET_MASTERY}`,
28237
28250
  HEARTBEAT: `/api${TIMEBACK_ROUTES.HEARTBEAT}`,
28238
28251
  ADVANCE_COURSE: `/api${TIMEBACK_ROUTES.ADVANCE_COURSE}`
28239
28252
  }
@@ -29889,7 +29902,7 @@ function isValidAdminAttributionDate(value) {
29889
29902
  const date3 = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
29890
29903
  return date3.getUTCFullYear() === year && date3.getUTCMonth() + 1 === month && date3.getUTCDate() === day;
29891
29904
  }
29892
- var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameActivityMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29905
+ var TIMEBACK_GRADES, TIMEBACK_SUBJECTS4, TimebackGradeSchema, TimebackSubjectSchema, CourseGoalsSchema, UpdateGameTimebackIntegrationRequestSchema, TimebackActivityDataSchema, EndActivityRequestSchema, GameRunMetricsSchema, GameCourseMetricsSchema, GameMetricsResponseSchema, AdvanceCourseRequestSchema, HeartbeatRequestSchema, PopulateStudentRequestSchema, DerivedPlatformCourseConfigSchema, TimebackBaseConfigSchema, PlatformTimebackSetupRequestSchema, AdminTimebackMutationBaseSchema, AdminAttributionDateSchema, ADMIN_GRANT_XP_MIN = -1e5, ADMIN_GRANT_XP_MAX = 1e5, ADMIN_GRANT_XP_AMOUNT_RANGE_MESSAGE, GrantTimebackXpRequestSchema, AdjustTimebackTimeRequestSchema, AdjustTimebackMasteryRequestSchema, ReconcileMasteryForConfigChangeSchema, EnrollStudentRequestSchema, UnenrollStudentRequestSchema, ReactivateEnrollmentRequestSchema, InsertAssessmentTestSchema, CreateAssessmentRequestSchema, ReorderAssessmentsRequestSchema, ReorderQuestionsRequestSchema;
29893
29906
  var init_schemas4 = __esm(() => {
29894
29907
  init_drizzle_zod();
29895
29908
  init_esm();
@@ -29958,24 +29971,28 @@ var init_schemas4 = __esm(() => {
29958
29971
  }).optional(),
29959
29972
  xpEarned: exports_external.number().optional(),
29960
29973
  masteredUnits: exports_external.number().optional(),
29974
+ masteredUnitsAbsolute: exports_external.number().int().nonnegative().optional(),
29961
29975
  extensions: exports_external.record(exports_external.string(), exports_external.unknown()).optional()
29976
+ }).refine((data) => !(data.masteredUnits !== undefined && data.masteredUnitsAbsolute !== undefined), {
29977
+ message: "Cannot provide both masteredUnits and masteredUnitsAbsolute",
29978
+ path: ["masteredUnitsAbsolute"]
29962
29979
  });
29963
- GameActivityMetricsSchema = exports_external.object({
29980
+ GameRunMetricsSchema = exports_external.object({
29981
+ runId: exports_external.string().uuid(),
29964
29982
  activityId: exports_external.string().min(1),
29965
29983
  activityName: exports_external.string().optional(),
29966
- totalXp: exports_external.number().nonnegative(),
29967
- masteredUnits: exports_external.number().int().nonnegative(),
29968
- activeTimeSeconds: exports_external.number().nonnegative(),
29969
- completionCount: exports_external.number().int().nonnegative(),
29970
- lastCompletedAt: exports_external.string().datetime().optional()
29984
+ totalXp: exports_external.number().nonnegative().optional(),
29985
+ masteredUnits: exports_external.number().int().nonnegative().optional(),
29986
+ activeTimeSeconds: exports_external.number().nonnegative().optional(),
29987
+ score: exports_external.number().min(0).max(100).optional()
29971
29988
  });
29972
29989
  GameCourseMetricsSchema = exports_external.object({
29973
29990
  grade: TimebackGradeSchema,
29974
29991
  subject: TimebackSubjectSchema,
29975
- totalXp: exports_external.number().nonnegative(),
29976
- masteredUnits: exports_external.number().int().nonnegative(),
29977
- activeTimeSeconds: exports_external.number().nonnegative(),
29978
- activities: exports_external.array(GameActivityMetricsSchema).optional()
29992
+ totalXp: exports_external.number().nonnegative().optional(),
29993
+ masteredUnits: exports_external.number().int().nonnegative().optional(),
29994
+ activeTimeSeconds: exports_external.number().nonnegative().optional(),
29995
+ activities: exports_external.array(GameRunMetricsSchema).optional()
29979
29996
  });
29980
29997
  GameMetricsResponseSchema = exports_external.object({
29981
29998
  studentId: exports_external.string().min(1),
@@ -30252,6 +30269,124 @@ var init_timeback_admin_util = __esm(() => {
30252
30269
  init_errors();
30253
30270
  });
30254
30271
 
30272
+ // ../api-core/src/utils/timeback-game-metrics-comparison.util.ts
30273
+ function createMetricRow(definition) {
30274
+ const { gameValue, kind, metric, timebackValue, tolerance } = definition;
30275
+ if (timebackValue === undefined && gameValue === undefined) {
30276
+ return null;
30277
+ }
30278
+ if (gameValue === undefined) {
30279
+ return {
30280
+ metric,
30281
+ kind,
30282
+ status: "not_reported_by_game",
30283
+ ...timebackValue !== undefined ? { timebackValue } : {}
30284
+ };
30285
+ }
30286
+ if (timebackValue === undefined) {
30287
+ return {
30288
+ metric,
30289
+ kind,
30290
+ status: "not_recorded_by_timeback",
30291
+ gameValue
30292
+ };
30293
+ }
30294
+ const delta = gameValue - timebackValue;
30295
+ const isDiscrepant = tolerance === 0 ? delta !== 0 : Math.abs(delta) >= tolerance;
30296
+ return {
30297
+ metric,
30298
+ kind,
30299
+ status: isDiscrepant ? "discrepant" : "matched",
30300
+ timebackValue,
30301
+ gameValue,
30302
+ delta
30303
+ };
30304
+ }
30305
+ function createRunComparison(activity, gameRun) {
30306
+ const runId = activity.runId ?? "";
30307
+ if (!gameRun) {
30308
+ return {
30309
+ runId,
30310
+ status: "not_reported",
30311
+ discrepancyCount: 0,
30312
+ rows: []
30313
+ };
30314
+ }
30315
+ const rows = [
30316
+ createMetricRow({
30317
+ metric: "xp",
30318
+ kind: "number",
30319
+ timebackValue: activity.xpDelta,
30320
+ gameValue: gameRun.totalXp,
30321
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.xp
30322
+ }),
30323
+ createMetricRow({
30324
+ metric: "mastery",
30325
+ kind: "number",
30326
+ timebackValue: activity.masteredUnitsDelta,
30327
+ gameValue: gameRun.masteredUnits,
30328
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.mastery
30329
+ }),
30330
+ createMetricRow({
30331
+ metric: "time",
30332
+ kind: "time",
30333
+ timebackValue: activity.timeDeltaSeconds,
30334
+ gameValue: gameRun.activeTimeSeconds,
30335
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.time
30336
+ }),
30337
+ createMetricRow({
30338
+ metric: "score",
30339
+ kind: "percent",
30340
+ timebackValue: activity.score,
30341
+ gameValue: gameRun.score,
30342
+ tolerance: TIMEBACK_GAME_METRIC_COMPARISON_TOLERANCE.score
30343
+ })
30344
+ ].filter((row) => row !== null);
30345
+ const discrepancyCount = rows.filter((row) => row.status === "discrepant").length;
30346
+ return {
30347
+ runId,
30348
+ status: discrepancyCount > 0 ? "discrepant" : "matched",
30349
+ discrepancyCount,
30350
+ rows
30351
+ };
30352
+ }
30353
+ function summarizeGameRunMetricsComparison(comparison) {
30354
+ return {
30355
+ runId: comparison.runId,
30356
+ status: comparison.status,
30357
+ discrepancyCount: comparison.discrepancyCount,
30358
+ ...comparison.reason ? { reason: comparison.reason } : {}
30359
+ };
30360
+ }
30361
+ function buildGameRunMetricComparisons(activities, course, response) {
30362
+ const activitiesWithRunIds = activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0);
30363
+ const comparisons = new Map;
30364
+ if (activitiesWithRunIds.length === 0) {
30365
+ return comparisons;
30366
+ }
30367
+ if (!response.supported) {
30368
+ for (const activity of activitiesWithRunIds) {
30369
+ comparisons.set(activity.runId, {
30370
+ runId: activity.runId,
30371
+ status: "unavailable",
30372
+ discrepancyCount: 0,
30373
+ reason: response.reason,
30374
+ rows: []
30375
+ });
30376
+ }
30377
+ return comparisons;
30378
+ }
30379
+ const gameCourseMetrics = response.metrics.courses.find((gameCourse) => gameCourse.grade === course.grade && gameCourse.subject === course.subject);
30380
+ const gameRunsById = new Map(gameCourseMetrics?.activities?.map((gameRun) => [gameRun.runId.toLowerCase(), gameRun]));
30381
+ for (const activity of activitiesWithRunIds) {
30382
+ comparisons.set(activity.runId, createRunComparison(activity, gameRunsById.get(activity.runId.toLowerCase())));
30383
+ }
30384
+ return comparisons;
30385
+ }
30386
+ var init_timeback_game_metrics_comparison_util = __esm(() => {
30387
+ init_src();
30388
+ });
30389
+
30255
30390
  // ../api-core/src/utils/timeback-mastery-completion.util.ts
30256
30391
  async function upsertMasteryCompletionEntry(params) {
30257
30392
  const { client, courseId, studentId, appName, action } = params;
@@ -30329,7 +30464,7 @@ function mapEnrollmentsToUserEnrollments(enrollments, integrations) {
30329
30464
  subject: integration.subject,
30330
30465
  courseId: integration.courseId,
30331
30466
  orgId: courseToSchool.get(integration.courseId),
30332
- ...enrollment ? { enrollmentIds: { active: enrollment.sourcedId } } : {}
30467
+ ...enrollment ? { id: enrollment.sourcedId } : {}
30333
30468
  };
30334
30469
  });
30335
30470
  }
@@ -30642,6 +30777,8 @@ class TimebackAdminService {
30642
30777
  static ANALYTICS_CONCURRENCY = 8;
30643
30778
  static MASTERABLE_UNITS_CONCURRENCY = 4;
30644
30779
  static GAME_METRICS_FETCH_TIMEOUT_MS = 1e4;
30780
+ static GAME_METRICS_LIST_FETCH_TIMEOUT_MS = 3000;
30781
+ static GAME_METRICS_RUN_IDS_PER_REQUEST = 50;
30645
30782
  constructor(deps) {
30646
30783
  this.deps = deps;
30647
30784
  }
@@ -30651,13 +30788,42 @@ class TimebackAdminService {
30651
30788
  }
30652
30789
  return this.deps.config.localGameUrls[slug2] ?? deployedUrl;
30653
30790
  }
30654
- static resolveGameMetricsUrl(baseUrl) {
30791
+ static resolveGameMetricsUrl(baseUrl, runIds) {
30655
30792
  try {
30656
- return new URL("/__playcademy/metrics", baseUrl);
30793
+ const url2 = new URL("/__playcademy/metrics", baseUrl);
30794
+ for (const runId of runIds ?? []) {
30795
+ url2.searchParams.append("runId", runId);
30796
+ }
30797
+ return url2;
30657
30798
  } catch {
30658
30799
  return null;
30659
30800
  }
30660
30801
  }
30802
+ static normalizeRunIds(runIds, limit = Number.POSITIVE_INFINITY) {
30803
+ const normalized = [];
30804
+ const seen = new Set;
30805
+ for (const runId of runIds ?? []) {
30806
+ const value = runId.trim().toLowerCase();
30807
+ if (isValidUUID(value) && !seen.has(value)) {
30808
+ seen.add(value);
30809
+ normalized.push(value);
30810
+ if (normalized.length >= limit) {
30811
+ break;
30812
+ }
30813
+ }
30814
+ }
30815
+ return normalized;
30816
+ }
30817
+ static chunkRunIds(runIds) {
30818
+ const chunks = [];
30819
+ for (let index2 = 0;index2 < runIds.length; index2 += this.GAME_METRICS_RUN_IDS_PER_REQUEST) {
30820
+ chunks.push(runIds.slice(index2, index2 + this.GAME_METRICS_RUN_IDS_PER_REQUEST));
30821
+ }
30822
+ return chunks;
30823
+ }
30824
+ static isAbortError(error) {
30825
+ return error instanceof Error && error.name === "AbortError";
30826
+ }
30661
30827
  static roundXpToTenths(value) {
30662
30828
  const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
30663
30829
  return Object.is(rounded, -0) ? 0 : rounded;
@@ -30818,6 +30984,56 @@ class TimebackAdminService {
30818
30984
  const remediationItems = events.map((event) => mapCaliperEventToRemediationActivity(event, relevantCourseIds)).filter((item) => Boolean(item));
30819
30985
  return [...groupedGameplayItems, ...remediationItems].toSorted((a, b) => b.occurredAt.localeCompare(a.occurredAt));
30820
30986
  }
30987
+ async getGameMetricComparisonsForActivities(user, options) {
30988
+ const runIds = TimebackAdminService.normalizeRunIds(options.activities.map((activity) => activity.runId).filter((runId) => Boolean(runId)));
30989
+ if (runIds.length === 0) {
30990
+ return new Map;
30991
+ }
30992
+ const activitiesByRunId = new Map(options.activities.filter((activity) => typeof activity.runId === "string" && activity.runId.length > 0).map((activity) => [activity.runId.toLowerCase(), activity]));
30993
+ const comparisons = new Map;
30994
+ await Promise.all(TimebackAdminService.chunkRunIds(runIds).map(async (chunk) => {
30995
+ const activities = [];
30996
+ for (const runId of chunk) {
30997
+ const activity = activitiesByRunId.get(runId);
30998
+ if (activity) {
30999
+ activities.push(activity);
31000
+ }
31001
+ }
31002
+ if (activities.length === 0) {
31003
+ return;
31004
+ }
31005
+ let response;
31006
+ try {
31007
+ response = await this.getGameMetrics(options.gameId, options.studentId, user, {
31008
+ runIds: chunk,
31009
+ timeoutMs: options.timeoutMs
31010
+ });
31011
+ } catch (error) {
31012
+ response = {
31013
+ supported: false,
31014
+ reason: "fetch_failed",
31015
+ details: error instanceof Error ? error.message : String(error)
31016
+ };
31017
+ }
31018
+ for (const [runId, comparison] of buildGameRunMetricComparisons(activities, options.course, response)) {
31019
+ comparisons.set(runId, comparison);
31020
+ }
31021
+ }));
31022
+ return comparisons;
31023
+ }
31024
+ async attachGameMetricSummariesToActivities(user, options) {
31025
+ const comparisons = await this.getGameMetricComparisonsForActivities(user, options);
31026
+ if (comparisons.size === 0) {
31027
+ return [...options.activities];
31028
+ }
31029
+ return options.activities.map((activity) => {
31030
+ const comparison = activity.runId ? comparisons.get(activity.runId) : undefined;
31031
+ return comparison ? {
31032
+ ...activity,
31033
+ gameMetricsComparison: summarizeGameRunMetricsComparison(comparison)
31034
+ } : activity;
31035
+ });
31036
+ }
30821
31037
  async getStudentEnrollmentsByCourseId(client, studentId, courseIds, options) {
30822
31038
  const enrollments = new Map;
30823
31039
  const allEnrollments = new Map;
@@ -30977,7 +31193,7 @@ class TimebackAdminService {
30977
31193
  });
30978
31194
  return { gameId, courseId, students: deduped };
30979
31195
  }
30980
- async getGameMetrics(gameId, timebackId, user) {
31196
+ async getGameMetrics(gameId, timebackId, user, options) {
30981
31197
  const client = this.requireClient();
30982
31198
  await this.deps.validateGameManagementAccess(user, gameId);
30983
31199
  const [targetUser, integrations, game2, deployment] = await Promise.all([
@@ -31012,7 +31228,8 @@ class TimebackAdminService {
31012
31228
  if (!metricsBaseUrl) {
31013
31229
  return { supported: false, reason: "no_active_deployment" };
31014
31230
  }
31015
- const metricsUrl = TimebackAdminService.resolveGameMetricsUrl(metricsBaseUrl);
31231
+ const runIds = TimebackAdminService.normalizeRunIds(options?.runIds, TimebackAdminService.GAME_METRICS_RUN_IDS_PER_REQUEST);
31232
+ const metricsUrl = TimebackAdminService.resolveGameMetricsUrl(metricsBaseUrl, runIds);
31016
31233
  if (!metricsUrl) {
31017
31234
  return {
31018
31235
  supported: false,
@@ -31022,7 +31239,7 @@ class TimebackAdminService {
31022
31239
  }
31023
31240
  const token = await this.deps.mintPlatformServiceToken(gameId, targetUser.id);
31024
31241
  const controller = new AbortController;
31025
- const timeout = setTimeout(() => controller.abort(), TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS);
31242
+ const timeout = setTimeout(() => controller.abort(), options?.timeoutMs ?? TimebackAdminService.GAME_METRICS_FETCH_TIMEOUT_MS);
31026
31243
  let response;
31027
31244
  try {
31028
31245
  response = await fetch(metricsUrl, {
@@ -31034,10 +31251,19 @@ class TimebackAdminService {
31034
31251
  signal: controller.signal
31035
31252
  });
31036
31253
  } catch (error) {
31254
+ const timedOut = TimebackAdminService.isAbortError(error);
31255
+ let details;
31256
+ if (timedOut) {
31257
+ details = "Game metrics request timed out";
31258
+ } else if (error instanceof Error) {
31259
+ details = error.message;
31260
+ } else {
31261
+ details = String(error);
31262
+ }
31037
31263
  return {
31038
31264
  supported: false,
31039
- reason: "fetch_failed",
31040
- details: error instanceof Error ? error.message : String(error)
31265
+ reason: timedOut ? "timeout" : "fetch_failed",
31266
+ details
31041
31267
  };
31042
31268
  } finally {
31043
31269
  clearTimeout(timeout);
@@ -31141,7 +31367,14 @@ class TimebackAdminService {
31141
31367
  const allActivities = await this.listRecentActivityForStudent(client, studentId, gameSource, relevantCourseIds, fetchLimit);
31142
31368
  const activities = allActivities.slice(safeOffset, safeOffset + safeLimit);
31143
31369
  const hasMore = allActivities.length > safeOffset + safeLimit;
31144
- return { activities, hasMore };
31370
+ const activitiesWithGameMetrics = await this.attachGameMetricSummariesToActivities(user, {
31371
+ gameId,
31372
+ studentId,
31373
+ course: { grade: integration.grade, subject: integration.subject },
31374
+ activities,
31375
+ timeoutMs: TimebackAdminService.GAME_METRICS_LIST_FETCH_TIMEOUT_MS
31376
+ });
31377
+ return { activities: activitiesWithGameMetrics, hasMore };
31145
31378
  }
31146
31379
  async getActivityDetail(user, options) {
31147
31380
  const { gameId, studentId, courseId, activityId, runId } = options;
@@ -31177,7 +31410,22 @@ class TimebackAdminService {
31177
31410
  if (!activity) {
31178
31411
  throw new NotFoundError("Activity", activityId);
31179
31412
  }
31180
- return { activity, rawEvents: matchedEvents };
31413
+ const comparisons = await this.getGameMetricComparisonsForActivities(user, {
31414
+ gameId,
31415
+ studentId,
31416
+ course: { grade: integration.grade, subject: integration.subject },
31417
+ activities: [activity]
31418
+ });
31419
+ const gameMetricsComparison = activity.runId ? comparisons.get(activity.runId) : undefined;
31420
+ const activityWithGameMetrics = gameMetricsComparison ? {
31421
+ ...activity,
31422
+ gameMetricsComparison: summarizeGameRunMetricsComparison(gameMetricsComparison)
31423
+ } : activity;
31424
+ return {
31425
+ activity: activityWithGameMetrics,
31426
+ rawEvents: matchedEvents,
31427
+ ...gameMetricsComparison ? { gameMetricsComparison } : {}
31428
+ };
31181
31429
  }
31182
31430
  async grantManualXp(data, user) {
31183
31431
  const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
@@ -31465,6 +31713,7 @@ var init_timeback_admin_service = __esm(() => {
31465
31713
  init_errors();
31466
31714
  init_timeback_admin_metrics_util();
31467
31715
  init_timeback_admin_util();
31716
+ init_timeback_game_metrics_comparison_util();
31468
31717
  init_timeback_mastery_completion_util();
31469
31718
  init_timeback_util();
31470
31719
  logger17 = log.scope("TimebackAdminService");
@@ -32886,6 +33135,7 @@ var init_timeback_service = __esm(() => {
32886
33135
  sessionTimingData,
32887
33136
  xpEarned,
32888
33137
  masteredUnits,
33138
+ masteredUnitsAbsolute,
32889
33139
  extensions,
32890
33140
  user
32891
33141
  }) {
@@ -32909,6 +33159,7 @@ var init_timeback_service = __esm(() => {
32909
33159
  durationSeconds: timingData.durationSeconds,
32910
33160
  xpEarned,
32911
33161
  masteredUnits,
33162
+ masteredUnitsAbsolute,
32912
33163
  extensions: extensionsWithResumeId,
32913
33164
  activityId: activityData.activityId,
32914
33165
  activityName: activityData.activityName,
@@ -33157,6 +33408,46 @@ var init_timeback_service = __esm(() => {
33157
33408
  });
33158
33409
  return result;
33159
33410
  }
33411
+ async getStudentMastery(timebackId, user, options) {
33412
+ const client = this.requireClient();
33413
+ const db2 = this.deps.db;
33414
+ await this.deps.validateDeveloperAccess(user, options.gameId);
33415
+ const conditions2 = [eq(gameTimebackIntegrations.gameId, options.gameId)];
33416
+ if (options.grade !== undefined && options.subject) {
33417
+ conditions2.push(eq(gameTimebackIntegrations.grade, options.grade));
33418
+ conditions2.push(eq(gameTimebackIntegrations.subject, options.subject));
33419
+ }
33420
+ const integrations = await db2.query.gameTimebackIntegrations.findMany({
33421
+ where: and(...conditions2)
33422
+ });
33423
+ const courseIds = integrations.map((i2) => i2.courseId);
33424
+ if (courseIds.length === 0) {
33425
+ logger20.debug("No integrations found for game, returning empty mastery", {
33426
+ timebackId,
33427
+ gameId: options.gameId,
33428
+ grade: options.grade,
33429
+ subject: options.subject
33430
+ });
33431
+ return {
33432
+ totalMasteredUnits: 0,
33433
+ totalMasterableUnits: 0,
33434
+ ...options.include?.perCourse && { courses: [] }
33435
+ };
33436
+ }
33437
+ const result = await client.getStudentMastery(timebackId, {
33438
+ courseIds,
33439
+ include: options.include
33440
+ });
33441
+ logger20.debug("Retrieved student mastery", {
33442
+ timebackId,
33443
+ gameId: options.gameId,
33444
+ grade: options.grade,
33445
+ subject: options.subject,
33446
+ totalMasteredUnits: result.totalMasteredUnits,
33447
+ courseCount: result.courses?.length
33448
+ });
33449
+ return result;
33450
+ }
33160
33451
  };
33161
33452
  });
33162
33453
 
@@ -35482,15 +35773,18 @@ class MasteryTracker {
35482
35773
  this.edubridgeNamespace = edubridgeNamespace;
35483
35774
  }
35484
35775
  async checkProgress(input) {
35485
- const { studentId, courseId, resourceId, masteredUnits } = input;
35486
- if (typeof masteredUnits !== "number" || masteredUnits === 0) {
35776
+ const { studentId, courseId, resourceId, masteredUnits, masteredUnitsAbsolute } = input;
35777
+ const hasIncremental = typeof masteredUnits === "number" && masteredUnits !== 0;
35778
+ const hasAbsolute = typeof masteredUnitsAbsolute === "number";
35779
+ if (!hasIncremental && !hasAbsolute) {
35487
35780
  return;
35488
35781
  }
35489
35782
  const status = await this.calculateStatus({
35490
35783
  studentId,
35491
35784
  courseId,
35492
35785
  resourceId,
35493
- additionalMasteredUnits: masteredUnits
35786
+ additionalMasteredUnits: hasAbsolute ? 0 : masteredUnits,
35787
+ absoluteMasteredUnits: hasAbsolute ? masteredUnitsAbsolute : undefined
35494
35788
  });
35495
35789
  if (!status) {
35496
35790
  return;
@@ -35499,7 +35793,8 @@ class MasteryTracker {
35499
35793
  return {
35500
35794
  pctCompleteApp: status.pctCompleteApp,
35501
35795
  masteryAchieved: !wasComplete && status.isComplete,
35502
- masteryRevoked: wasComplete && !status.isComplete
35796
+ masteryRevoked: wasComplete && !status.isComplete,
35797
+ effectiveDelta: status.effectiveDelta
35503
35798
  };
35504
35799
  }
35505
35800
  async getStatus(input) {
@@ -35521,7 +35816,8 @@ class MasteryTracker {
35521
35816
  studentId,
35522
35817
  courseId,
35523
35818
  resourceId,
35524
- additionalMasteredUnits
35819
+ additionalMasteredUnits,
35820
+ absoluteMasteredUnits
35525
35821
  }) {
35526
35822
  const masterableUnits = await this.resolveMasterableUnits(resourceId);
35527
35823
  if (!masterableUnits || masterableUnits <= 0) {
@@ -35540,7 +35836,15 @@ class MasteryTracker {
35540
35836
  return;
35541
35837
  }
35542
35838
  const historicalMasteredUnits = this.sumAnalyticsMetric(facts, "masteredUnits");
35543
- const totalMastered = Math.max(0, historicalMasteredUnits + additionalMasteredUnits);
35839
+ let totalMastered;
35840
+ let effectiveDelta;
35841
+ if (absoluteMasteredUnits !== undefined) {
35842
+ totalMastered = Math.max(0, absoluteMasteredUnits);
35843
+ effectiveDelta = totalMastered - historicalMasteredUnits;
35844
+ } else {
35845
+ effectiveDelta = additionalMasteredUnits;
35846
+ totalMastered = Math.max(0, historicalMasteredUnits + effectiveDelta);
35847
+ }
35544
35848
  const rawPct = totalMastered / masterableUnits * 100;
35545
35849
  const pctCompleteApp = Math.min(100, Math.max(0, Math.round(rawPct)));
35546
35850
  return {
@@ -35548,7 +35852,8 @@ class MasteryTracker {
35548
35852
  masterableUnits,
35549
35853
  pctCompleteApp,
35550
35854
  isComplete: totalMastered >= masterableUnits,
35551
- historicalMasteredUnits
35855
+ historicalMasteredUnits,
35856
+ effectiveDelta
35552
35857
  };
35553
35858
  }
35554
35859
  async createCompletionEntry(studentId, courseId, classId, appName) {
@@ -35777,7 +36082,7 @@ class ProgressRecorder {
35777
36082
  validateProgressData(progressData);
35778
36083
  const { ids, activityId, activityName, courseName, student } = await this.resolveContext(courseId, studentIdentifier, progressData);
35779
36084
  const { id: studentId, email: studentEmail } = student;
35780
- const { score, totalQuestions, correctQuestions, xpEarned, masteredUnits, attemptNumber } = progressData;
36085
+ const { score, totalQuestions, correctQuestions, xpEarned, attemptNumber } = progressData;
35781
36086
  const actualLineItemId = await this.resolveAssessmentLineItem(activityId, activityName, progressData.classId, ids);
35782
36087
  const currentAttemptNumber = await this.resolveAttemptNumber(attemptNumber, score, studentId, actualLineItemId);
35783
36088
  const calculatedXp = this.calculateXpForProgress(progressData, totalQuestions, correctQuestions, xpEarned, currentAttemptNumber);
@@ -35786,8 +36091,10 @@ class ProgressRecorder {
35786
36091
  studentId,
35787
36092
  courseId,
35788
36093
  resourceId: ids.resource,
35789
- masteredUnits: progressData.masteredUnits ?? 0
36094
+ masteredUnits: progressData.masteredUnits ?? 0,
36095
+ masteredUnitsAbsolute: progressData.masteredUnitsAbsolute
35790
36096
  });
36097
+ const effectiveMasteredUnits = masteryProgress ? masteryProgress.effectiveDelta : progressData.masteredUnits ?? 0;
35791
36098
  let pctCompleteApp;
35792
36099
  let masteryAchieved = false;
35793
36100
  let scoreStatus = SCORE_STATUS5.fullyGraded;
@@ -35815,7 +36122,7 @@ class ProgressRecorder {
35815
36122
  appName: progressData.appName,
35816
36123
  totalQuestions,
35817
36124
  correctQuestions,
35818
- masteredUnits,
36125
+ masteredUnits: effectiveMasteredUnits || undefined,
35819
36126
  pctCompleteApp
35820
36127
  });
35821
36128
  } else {
@@ -35853,7 +36160,7 @@ class ProgressRecorder {
35853
36160
  totalQuestions,
35854
36161
  correctQuestions,
35855
36162
  xpEarned: calculatedXp,
35856
- masteredUnits,
36163
+ masteredUnits: effectiveMasteredUnits || undefined,
35857
36164
  attemptNumber: currentAttemptNumber,
35858
36165
  progressData,
35859
36166
  extensions,
@@ -35862,7 +36169,7 @@ class ProgressRecorder {
35862
36169
  return {
35863
36170
  xpAwarded: calculatedXp,
35864
36171
  attemptNumber: currentAttemptNumber,
35865
- masteredUnitsApplied: progressData.masteredUnits ?? 0,
36172
+ masteredUnitsApplied: effectiveMasteredUnits,
35866
36173
  pctCompleteApp,
35867
36174
  scoreStatus,
35868
36175
  inProgress
@@ -36397,6 +36704,65 @@ class TimebackClient {
36397
36704
  resourceId: ids.resource
36398
36705
  });
36399
36706
  }
36707
+ async getStudentMastery(studentId, options) {
36708
+ await this._ensureAuthenticated();
36709
+ const enrollments = await this.edubridge.enrollments.listByUser(studentId);
36710
+ const filteredEnrollments = options?.courseIds?.length ? enrollments.filter((e) => options.courseIds.includes(e.course.id)) : enrollments;
36711
+ if (filteredEnrollments.length === 0) {
36712
+ return {
36713
+ totalMasteredUnits: 0,
36714
+ totalMasterableUnits: 0,
36715
+ ...options?.include?.perCourse && { courses: [] }
36716
+ };
36717
+ }
36718
+ const masteryResults = await Promise.all(filteredEnrollments.map(async (enrollment) => {
36719
+ try {
36720
+ const ids = deriveSourcedIds2(enrollment.course.id);
36721
+ const status = await this.masteryTracker.getStatus({
36722
+ studentId,
36723
+ courseId: enrollment.course.id,
36724
+ resourceId: ids.resource
36725
+ });
36726
+ return { enrollment, status };
36727
+ } catch (error) {
36728
+ log.warn("[TimebackClient] Failed to fetch mastery for enrollment", {
36729
+ enrollmentId: enrollment.id,
36730
+ error
36731
+ });
36732
+ return { enrollment, status: undefined };
36733
+ }
36734
+ }));
36735
+ let totalMasteredUnits = 0;
36736
+ let totalMasterableUnits = 0;
36737
+ const courses = [];
36738
+ for (const { enrollment, status } of masteryResults) {
36739
+ const masteredUnits = status?.masteredUnits ?? 0;
36740
+ const masterableUnits = status?.masterableUnits ?? 0;
36741
+ totalMasteredUnits += masteredUnits;
36742
+ totalMasterableUnits += masterableUnits;
36743
+ if (options?.include?.perCourse) {
36744
+ const gradeStr = enrollment.course.grades?.[0];
36745
+ const parsedGrade = gradeStr ? parseInt(gradeStr, 10) : 0;
36746
+ const grade = isTimebackGrade3(parsedGrade) ? parsedGrade : 0;
36747
+ const subjectStr = enrollment.course.subjects?.[0];
36748
+ const subject = subjectStr && isTimebackSubject3(subjectStr) ? subjectStr : "None";
36749
+ courses.push({
36750
+ grade,
36751
+ subject,
36752
+ title: enrollment.course.title,
36753
+ masteredUnits,
36754
+ masterableUnits,
36755
+ pctComplete: status?.pctCompleteApp ?? 0,
36756
+ isComplete: status?.isComplete ?? false
36757
+ });
36758
+ }
36759
+ }
36760
+ return {
36761
+ totalMasteredUnits,
36762
+ totalMasterableUnits,
36763
+ ...options?.include?.perCourse && { courses }
36764
+ };
36765
+ }
36400
36766
  async getStudentXp(studentId, options) {
36401
36767
  await this._ensureAuthenticated();
36402
36768
  const enrollments = await this.edubridge.enrollments.listByUser(studentId);
@@ -93719,7 +94085,7 @@ var init_session_controller = __esm(() => {
93719
94085
  });
93720
94086
 
93721
94087
  // ../api-core/src/controllers/timeback.controller.ts
93722
- var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, getStudentXp, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
94088
+ var logger45, populateStudent, getUser, getUserEnrollments, getUserById, setupIntegration, getIntegrations, updateIntegration, getIntegrationConfig, verifyIntegration, getConfig, deleteIntegrations, endActivity, heartbeat, advanceCourse, getStudentXp, getStudentMastery, getRoster, getStudentOverview, getGameMetrics, getStudentActivity, getActivityDetail, grantXp, adjustTime, adjustMastery, reconcileMasteryForConfigChange, searchStudents, enrollStudent, unenrollStudent, reactivateEnrollment, listAssessments, createAssessment, deleteAssessment, reorderAssessments, reorderQuestions, activateAssessment, deactivateAssessment, listQuestions, createQuestion, updateQuestion, deleteQuestion, getAssessmentBankStatus, destroyAssessmentBank, timeback2;
93723
94089
  var init_timeback_controller = __esm(() => {
93724
94090
  init_esm();
93725
94091
  init_schemas_index();
@@ -93877,6 +94243,7 @@ var init_timeback_controller = __esm(() => {
93877
94243
  sessionTimingData,
93878
94244
  xpEarned,
93879
94245
  masteredUnits,
94246
+ masteredUnitsAbsolute,
93880
94247
  extensions
93881
94248
  } = body2;
93882
94249
  logger45.debug("Ending activity", { userId: ctx.user.id, gameId });
@@ -93891,6 +94258,7 @@ var init_timeback_controller = __esm(() => {
93891
94258
  sessionTimingData,
93892
94259
  xpEarned,
93893
94260
  masteredUnits,
94261
+ masteredUnitsAbsolute,
93894
94262
  extensions,
93895
94263
  user: ctx.user
93896
94264
  });
@@ -94000,6 +94368,53 @@ var init_timeback_controller = __esm(() => {
94000
94368
  include
94001
94369
  });
94002
94370
  });
94371
+ getStudentMastery = requireDeveloper(async (ctx) => {
94372
+ const timebackId = ctx.params.timebackId;
94373
+ if (!timebackId) {
94374
+ throw ApiError.badRequest("Missing timebackId parameter");
94375
+ }
94376
+ const gameId = ctx.url.searchParams.get("gameId");
94377
+ if (!gameId) {
94378
+ throw ApiError.badRequest("Missing required gameId query parameter");
94379
+ }
94380
+ const gradeParam = ctx.url.searchParams.get("grade");
94381
+ const subjectParam = ctx.url.searchParams.get("subject");
94382
+ if (gradeParam !== null !== (subjectParam !== null)) {
94383
+ throw ApiError.badRequest("Both grade and subject must be provided together");
94384
+ }
94385
+ let grade;
94386
+ let subject;
94387
+ if (gradeParam !== null && subjectParam !== null) {
94388
+ const parsedGrade = parseInt(gradeParam, 10);
94389
+ if (!isTimebackGrade2(parsedGrade)) {
94390
+ throw ApiError.badRequest(`Invalid grade: ${gradeParam}. Valid grades: ${TIMEBACK_GRADES.join(", ")}`);
94391
+ }
94392
+ if (!isTimebackSubject2(subjectParam)) {
94393
+ throw ApiError.badRequest(`Invalid subject: ${subjectParam}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
94394
+ }
94395
+ grade = parsedGrade;
94396
+ subject = subjectParam;
94397
+ }
94398
+ const includeParam = ctx.url.searchParams.get("include");
94399
+ const includeOptions = includeParam ? includeParam.split(",").map((opt) => opt.trim().toLowerCase()) : [];
94400
+ const include = {
94401
+ perCourse: includeOptions.includes("percourse")
94402
+ };
94403
+ logger45.debug("Getting student mastery", {
94404
+ requesterId: ctx.user.id,
94405
+ timebackId,
94406
+ gameId,
94407
+ grade,
94408
+ subject,
94409
+ include
94410
+ });
94411
+ return ctx.services.timeback.getStudentMastery(timebackId, ctx.user, {
94412
+ gameId,
94413
+ grade,
94414
+ subject,
94415
+ include
94416
+ });
94417
+ });
94003
94418
  getRoster = requireGameManagementAccess(async (ctx) => {
94004
94419
  const gameId = ctx.params.gameId;
94005
94420
  const courseId = ctx.params.courseId;
@@ -94035,15 +94450,19 @@ var init_timeback_controller = __esm(() => {
94035
94450
  getGameMetrics = requireGameManagementAccess(async (ctx) => {
94036
94451
  const gameId = ctx.params.gameId;
94037
94452
  const timebackId = ctx.params.timebackId;
94453
+ const runIds = [
94454
+ ...new Set(ctx.url.searchParams.getAll("runId").map((runId) => runId.trim().toLowerCase()).filter(isValidUUID))
94455
+ ];
94038
94456
  if (!gameId || !timebackId) {
94039
94457
  throw ApiError.badRequest("Missing gameId or timebackId path parameter");
94040
94458
  }
94041
94459
  logger45.debug("Getting game metrics", {
94042
94460
  requesterId: ctx.user.id,
94043
94461
  gameId,
94044
- timebackId
94462
+ timebackId,
94463
+ runIds
94045
94464
  });
94046
- return ctx.services.timebackAdmin.getGameMetrics(gameId, timebackId, ctx.user);
94465
+ return ctx.services.timebackAdmin.getGameMetrics(gameId, timebackId, ctx.user, { runIds });
94047
94466
  });
94048
94467
  getStudentActivity = requireGameManagementAccess(async (ctx) => {
94049
94468
  const timebackId = ctx.params.timebackId;
@@ -94332,6 +94751,7 @@ var init_timeback_controller = __esm(() => {
94332
94751
  heartbeat,
94333
94752
  advanceCourse,
94334
94753
  getStudentXp,
94754
+ getStudentMastery,
94335
94755
  getRoster,
94336
94756
  getStudentOverview,
94337
94757
  getGameMetrics,
@@ -95218,6 +95638,7 @@ var init_timeback6 = __esm(() => {
95218
95638
  init_controllers();
95219
95639
  init_errors();
95220
95640
  init_utils11();
95641
+ init_schemas_index();
95221
95642
  init_api();
95222
95643
  init_error_handler();
95223
95644
  init_timeback5();
@@ -95297,6 +95718,14 @@ var init_timeback6 = __esm(() => {
95297
95718
  }
95298
95719
  if (gradeParam !== null && subjectParam !== null) {
95299
95720
  const grade = parseInt(gradeParam, 10);
95721
+ if (!Number.isFinite(grade) || !isTimebackGrade2(grade)) {
95722
+ const error2 = ApiError.badRequest(`Invalid grade: ${gradeParam}. Valid grades: ${TIMEBACK_GRADES.join(", ")}`);
95723
+ return c2.json(createErrorResponse(error2), error2.status);
95724
+ }
95725
+ if (!isTimebackSubject2(subjectParam)) {
95726
+ const error2 = ApiError.badRequest(`Invalid subject: ${subjectParam}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
95727
+ return c2.json(createErrorResponse(error2), error2.status);
95728
+ }
95300
95729
  enrollments = enrollments.filter((e) => e.grade === grade && e.subject === subjectParam);
95301
95730
  }
95302
95731
  const mockCourses = enrollments.map((e) => {
@@ -95321,6 +95750,62 @@ var init_timeback6 = __esm(() => {
95321
95750
  }
95322
95751
  return handle2(timeback2.getStudentXp)(c2);
95323
95752
  });
95753
+ timebackRouter.get("/student-mastery/:timebackId", async (c2) => {
95754
+ const user = c2.get("user");
95755
+ if (!user) {
95756
+ const error2 = ApiError.unauthorized("Must be logged in to get student mastery");
95757
+ return c2.json(createErrorResponse(error2), error2.status);
95758
+ }
95759
+ if (shouldMockTimeback()) {
95760
+ const url2 = new URL(c2.req.url);
95761
+ const gradeParam = url2.searchParams.get("grade");
95762
+ const subjectParam = url2.searchParams.get("subject");
95763
+ const includeParam = url2.searchParams.get("include") || "";
95764
+ const includeOptions = includeParam.split(",").map((opt) => opt.trim().toLowerCase());
95765
+ const includePerCourse = includeOptions.includes("percourse");
95766
+ const db2 = c2.get("db");
95767
+ let enrollments = await getMockEnrollments(db2);
95768
+ if (gradeParam !== null !== (subjectParam !== null)) {
95769
+ const error2 = ApiError.badRequest("Both grade and subject must be provided together");
95770
+ return c2.json(createErrorResponse(error2), error2.status);
95771
+ }
95772
+ if (gradeParam !== null && subjectParam !== null) {
95773
+ const grade = parseInt(gradeParam, 10);
95774
+ if (!Number.isFinite(grade) || !isTimebackGrade2(grade)) {
95775
+ const error2 = ApiError.badRequest(`Invalid grade: ${gradeParam}. Valid grades: ${TIMEBACK_GRADES.join(", ")}`);
95776
+ return c2.json(createErrorResponse(error2), error2.status);
95777
+ }
95778
+ if (!isTimebackSubject2(subjectParam)) {
95779
+ const error2 = ApiError.badRequest(`Invalid subject: ${subjectParam}. Valid subjects: ${TIMEBACK_SUBJECTS4.join(", ")}`);
95780
+ return c2.json(createErrorResponse(error2), error2.status);
95781
+ }
95782
+ enrollments = enrollments.filter((e) => e.grade === grade && e.subject === subjectParam);
95783
+ }
95784
+ const mockCourses = enrollments.map((e) => {
95785
+ const seed3 = hashCode(`${e.grade}-${e.subject}`);
95786
+ const masterableUnits = 5 + seed3 % 16;
95787
+ const masteredUnits = seed3 % (masterableUnits + 1);
95788
+ const pctComplete = masterableUnits > 0 ? Math.round(masteredUnits / masterableUnits * 1e4) / 100 : 0;
95789
+ return {
95790
+ grade: e.grade,
95791
+ subject: e.subject,
95792
+ title: `${e.subject} ${formatGradeLabel(e.grade)}`,
95793
+ masteredUnits,
95794
+ masterableUnits,
95795
+ pctComplete,
95796
+ isComplete: masteredUnits >= masterableUnits
95797
+ };
95798
+ });
95799
+ const totalMasteredUnits = mockCourses.reduce((sum2, course) => sum2 + course.masteredUnits, 0);
95800
+ const totalMasterableUnits = mockCourses.reduce((sum2, course) => sum2 + course.masterableUnits, 0);
95801
+ return c2.json({
95802
+ totalMasteredUnits,
95803
+ totalMasterableUnits,
95804
+ ...includePerCourse && { courses: mockCourses }
95805
+ });
95806
+ }
95807
+ return handle2(timeback2.getStudentMastery)(c2);
95808
+ });
95324
95809
  });
95325
95810
 
95326
95811
  // src/routes/integrations/lti.ts