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