@playcademy/sandbox 0.3.16-beta.3 → 0.3.16-beta.4

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.
Files changed (3) hide show
  1. package/dist/cli.js +170 -45
  2. package/dist/server.js +170 -45
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1310,7 +1310,7 @@ var package_default;
1310
1310
  var init_package = __esm(() => {
1311
1311
  package_default = {
1312
1312
  name: "@playcademy/sandbox",
1313
- version: "0.3.16-beta.3",
1313
+ version: "0.3.16-beta.4",
1314
1314
  description: "Local development server for Playcademy game development",
1315
1315
  type: "module",
1316
1316
  exports: {
@@ -30207,16 +30207,7 @@ function mapAssessmentResultToRecentActivity(assessment, relevantCourseIds, cour
30207
30207
  return null;
30208
30208
  }
30209
30209
  if (isMasteryCompletionEntry(assessment)) {
30210
- const metadata3 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
30211
- const isResume = Boolean(metadata3?.resumedAt);
30212
- return {
30213
- id: assessment.sourcedId || `${assessment.assessmentLineItem.sourcedId}:${assessment.scoreDate}`,
30214
- kind: "admin-completion",
30215
- occurredAt: assessment.scoreDate,
30216
- courseId,
30217
- title: isResume ? "Course resumed" : "Course marked complete",
30218
- reason: metadata3?.adminAction ? "Admin action" : undefined
30219
- };
30210
+ return null;
30220
30211
  }
30221
30212
  const metadata2 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
30222
30213
  const activityName = getStringValue(metadata2?.activityName);
@@ -30249,6 +30240,7 @@ function parseCaliperEventContext(event, relevantCourseIds) {
30249
30240
  courseId,
30250
30241
  occurredAt,
30251
30242
  eventKind: getStringValue(playcademy?.eventKind),
30243
+ source: getStringValue(playcademy?.source),
30252
30244
  reason: getStringValue(playcademy?.reason),
30253
30245
  titleFromEvent: getStringValue(event.object.activity?.name),
30254
30246
  appName: getStringValue(event.object.app?.name),
@@ -30272,30 +30264,59 @@ function mapTimeSpentRemediation(event, ctx) {
30272
30264
  };
30273
30265
  }
30274
30266
  function mapActivityRemediation(event, ctx) {
30275
- let kind;
30276
30267
  if (ctx.eventKind === "remediation-xp") {
30277
- kind = "remediation-xp";
30278
- } else if (ctx.eventKind === "remediation-mastery") {
30279
- kind = "remediation-mastery";
30280
- } else {
30281
- return null;
30268
+ return {
30269
+ id: event.externalId,
30270
+ kind: "remediation-xp",
30271
+ occurredAt: ctx.occurredAt,
30272
+ courseId: ctx.courseId,
30273
+ title: "XP Adjustment",
30274
+ activityId: ctx.activityId,
30275
+ appName: ctx.appName,
30276
+ reason: ctx.reason,
30277
+ xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30278
+ masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30279
+ };
30282
30280
  }
30283
- const titleMap = {
30284
- "remediation-xp": "XP Adjustment",
30285
- "remediation-mastery": "Mastery Adjustment"
30286
- };
30287
- return {
30288
- id: event.externalId,
30289
- kind,
30290
- occurredAt: ctx.occurredAt,
30291
- courseId: ctx.courseId,
30292
- title: titleMap[kind] || "Remediation Activity",
30293
- activityId: ctx.activityId,
30294
- appName: ctx.appName,
30295
- reason: ctx.reason,
30296
- xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30297
- masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30298
- };
30281
+ if (ctx.eventKind === "remediation-mastery") {
30282
+ return {
30283
+ id: event.externalId,
30284
+ kind: "remediation-mastery",
30285
+ occurredAt: ctx.occurredAt,
30286
+ courseId: ctx.courseId,
30287
+ title: "Mastery Adjustment",
30288
+ activityId: ctx.activityId,
30289
+ appName: ctx.appName,
30290
+ reason: ctx.reason,
30291
+ xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30292
+ masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30293
+ };
30294
+ }
30295
+ if (ctx.eventKind === "course-completed") {
30296
+ return {
30297
+ id: event.externalId,
30298
+ kind: "course-completed",
30299
+ occurredAt: ctx.occurredAt,
30300
+ courseId: ctx.courseId,
30301
+ title: ctx.source === "admin" ? "Course marked complete" : "Course completed",
30302
+ activityId: ctx.activityId,
30303
+ appName: ctx.appName,
30304
+ reason: ctx.reason
30305
+ };
30306
+ }
30307
+ if (ctx.eventKind === "course-resumed") {
30308
+ return {
30309
+ id: event.externalId,
30310
+ kind: "course-resumed",
30311
+ occurredAt: ctx.occurredAt,
30312
+ courseId: ctx.courseId,
30313
+ title: "Course resumed",
30314
+ activityId: ctx.activityId,
30315
+ appName: ctx.appName,
30316
+ reason: ctx.reason
30317
+ };
30318
+ }
30319
+ return null;
30299
30320
  }
30300
30321
  function mapCaliperEventToRemediationActivity(event, relevantCourseIds) {
30301
30322
  const ctx = parseCaliperEventContext(event, relevantCourseIds);
@@ -30317,6 +30338,7 @@ var init_timeback_util = __esm(() => {
30317
30338
  // ../api-core/src/services/timeback-admin.service.ts
30318
30339
  class TimebackAdminService {
30319
30340
  deps;
30341
+ static XP_PRECISION_FACTOR = 10;
30320
30342
  static RECENT_ACTIVITY_LIMIT = 20;
30321
30343
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
30322
30344
  static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
@@ -30328,6 +30350,10 @@ class TimebackAdminService {
30328
30350
  constructor(deps) {
30329
30351
  this.deps = deps;
30330
30352
  }
30353
+ static roundXpToTenths(value) {
30354
+ const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
30355
+ return Object.is(rounded, -0) ? 0 : rounded;
30356
+ }
30331
30357
  requireClient() {
30332
30358
  if (!this.deps.timeback) {
30333
30359
  logger16.error("Timeback client not available in context");
@@ -30335,6 +30361,17 @@ class TimebackAdminService {
30335
30361
  }
30336
30362
  return this.deps.timeback;
30337
30363
  }
30364
+ async recordCourseCompletionHistory(client, data) {
30365
+ await client.recordAdminCourseCompletionChange(data).catch((error) => {
30366
+ logger16.error("Failed to record admin course completion history event", {
30367
+ gameId: data.gameId,
30368
+ courseId: data.courseId,
30369
+ studentId: data.studentId,
30370
+ action: data.action,
30371
+ error: error instanceof Error ? error.message : String(error)
30372
+ });
30373
+ });
30374
+ }
30338
30375
  async resolveAdminMutationContext(gameId, courseId, user, studentId) {
30339
30376
  const client = this.requireClient();
30340
30377
  await this.deps.validateDeveloperAccess(user, gameId);
@@ -30374,8 +30411,8 @@ class TimebackAdminService {
30374
30411
  }
30375
30412
  const today = formatDateYMD();
30376
30413
  const history = [];
30377
- let totalXp = 0;
30378
- let todayXp = 0;
30414
+ let totalXpRaw = 0;
30415
+ let todayXpRaw = 0;
30379
30416
  let activeTimeSeconds = 0;
30380
30417
  let masteredUnits = 0;
30381
30418
  for (const [date3, subjectFacts] of Object.entries(facts)) {
@@ -30392,15 +30429,16 @@ class TimebackAdminService {
30392
30429
  masteredUnitsForDay += masteredUnitsFromFact;
30393
30430
  }
30394
30431
  }
30395
- totalXp += xpForDay;
30432
+ const roundedXpForDay = TimebackAdminService.roundXpToTenths(xpForDay);
30433
+ totalXpRaw += xpForDay;
30396
30434
  activeTimeSeconds += activeSecondsForDay;
30397
30435
  masteredUnits += masteredUnitsForDay;
30398
30436
  if (date3 === today) {
30399
- todayXp += xpForDay;
30437
+ todayXpRaw += xpForDay;
30400
30438
  }
30401
30439
  history.push({
30402
30440
  date: date3,
30403
- xpEarned: xpForDay,
30441
+ xpEarned: roundedXpForDay,
30404
30442
  activeTimeSeconds: activeSecondsForDay,
30405
30443
  masteredUnits: masteredUnitsForDay
30406
30444
  });
@@ -30408,8 +30446,8 @@ class TimebackAdminService {
30408
30446
  history.sort((a, b) => a.date.localeCompare(b.date));
30409
30447
  return {
30410
30448
  analyticsAvailable: true,
30411
- totalXp,
30412
- todayXp,
30449
+ totalXp: TimebackAdminService.roundXpToTenths(totalXpRaw),
30450
+ todayXp: TimebackAdminService.roundXpToTenths(todayXpRaw),
30413
30451
  activeTimeSeconds,
30414
30452
  masteredUnits,
30415
30453
  history
@@ -30751,6 +30789,7 @@ class TimebackAdminService {
30751
30789
  }
30752
30790
  async toggleCourseCompletion(data, user) {
30753
30791
  const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
30792
+ const historyClient = client;
30754
30793
  const ids = deriveSourcedIds(data.courseId);
30755
30794
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
30756
30795
  const resultId = `${lineItemId}:${data.studentId}:completion`;
@@ -30801,11 +30840,19 @@ class TimebackAdminService {
30801
30840
  inProgress: "false",
30802
30841
  metadata: {
30803
30842
  isMasteryCompletion: true,
30804
- completedAt: new Date().toISOString(),
30805
30843
  adminAction: true,
30806
30844
  appName
30807
30845
  }
30808
30846
  });
30847
+ await this.recordCourseCompletionHistory(historyClient, {
30848
+ gameId: data.gameId,
30849
+ courseId: data.courseId,
30850
+ studentId: data.studentId,
30851
+ action: "complete",
30852
+ actor,
30853
+ appName,
30854
+ sensorUrl
30855
+ });
30809
30856
  } else {
30810
30857
  await client.oneroster.assessmentResults.upsert(resultId, {
30811
30858
  sourcedId: resultId,
@@ -30818,11 +30865,19 @@ class TimebackAdminService {
30818
30865
  inProgress: "true",
30819
30866
  metadata: {
30820
30867
  isMasteryCompletion: true,
30821
- resumedAt: new Date().toISOString(),
30822
30868
  adminAction: true,
30823
30869
  appName
30824
30870
  }
30825
30871
  });
30872
+ await this.recordCourseCompletionHistory(historyClient, {
30873
+ gameId: data.gameId,
30874
+ courseId: data.courseId,
30875
+ studentId: data.studentId,
30876
+ action: "resume",
30877
+ actor,
30878
+ appName,
30879
+ sensorUrl
30880
+ });
30826
30881
  }
30827
30882
  return { status: "ok" };
30828
30883
  }
@@ -35089,7 +35144,8 @@ function buildAdminEventMetadata({
35089
35144
  return {
35090
35145
  playcademy: {
35091
35146
  eventKind,
35092
- reason
35147
+ reason,
35148
+ source: "admin"
35093
35149
  }
35094
35150
  };
35095
35151
  }
@@ -35205,6 +35261,30 @@ class AdminEventRecorder {
35205
35261
  eventExtensions: ctx.metadata
35206
35262
  });
35207
35263
  }
35264
+ async recordCourseCompletionChange(data) {
35265
+ const isResume = data.action === "resume";
35266
+ const ctx = await this.prepareAdminEvent({
35267
+ ...data,
35268
+ defaultActivityId: isResume ? "playcademy-admin-course-resumed" : "playcademy-admin-course-completed",
35269
+ reason: "Admin action",
35270
+ eventKind: isResume ? "course-resumed" : "course-completed"
35271
+ });
35272
+ await this.caliper.emitActivityEvent({
35273
+ studentId: ctx.student.id,
35274
+ studentEmail: ctx.student.email,
35275
+ activityId: ctx.activityId,
35276
+ activityName: isResume ? "Course resumed" : "Course marked complete",
35277
+ courseId: data.courseId,
35278
+ courseName: ctx.courseContext.courseName,
35279
+ subject: ctx.courseContext.subject,
35280
+ appName: ctx.appName,
35281
+ sensorUrl: ctx.sensorUrl,
35282
+ process: false,
35283
+ includeAttempt: false,
35284
+ generatedExtensions: ctx.metadata,
35285
+ eventExtensions: ctx.metadata
35286
+ });
35287
+ }
35208
35288
  }
35209
35289
 
35210
35290
  class TimebackCache {
@@ -35418,7 +35498,7 @@ class MasteryTracker {
35418
35498
  const totalMastered = historicalMasteredUnits + masteredUnits;
35419
35499
  const rawPct = totalMastered / masterableUnits * 100;
35420
35500
  const pctCompleteApp = Math.min(100, Math.max(0, Math.round(rawPct)));
35421
- const masteryAchieved = totalMastered >= masterableUnits;
35501
+ const masteryAchieved = historicalMasteredUnits < masterableUnits && totalMastered >= masterableUnits;
35422
35502
  return { pctCompleteApp, masteryAchieved };
35423
35503
  }
35424
35504
  async createCompletionEntry(studentId, courseId, classId, appName) {
@@ -35444,7 +35524,6 @@ class MasteryTracker {
35444
35524
  inProgress: "false",
35445
35525
  metadata: {
35446
35526
  isMasteryCompletion: true,
35447
- completedAt: new Date().toISOString(),
35448
35527
  appName
35449
35528
  }
35450
35529
  });
@@ -35660,6 +35739,16 @@ class ProgressRecorder {
35660
35739
  }
35661
35740
  if (masteryAchieved) {
35662
35741
  await this.masteryTracker.createCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
35742
+ await this.emitCourseCompletionHistoryEvent({
35743
+ studentId,
35744
+ studentEmail,
35745
+ activityId,
35746
+ courseId: ids.course,
35747
+ courseName,
35748
+ subject: progressData.subject,
35749
+ appName: progressData.appName,
35750
+ sensorUrl: progressData.sensorUrl
35751
+ });
35663
35752
  }
35664
35753
  await this.emitCaliperEvent({
35665
35754
  studentId,
@@ -35834,6 +35923,38 @@ class ProgressRecorder {
35834
35923
  log.error("[ProgressRecorder] Failed to emit activity event", { error });
35835
35924
  });
35836
35925
  }
35926
+ async emitCourseCompletionHistoryEvent(data) {
35927
+ await this.caliperNamespace.emitActivityEvent({
35928
+ studentId: data.studentId,
35929
+ studentEmail: data.studentEmail,
35930
+ activityId: data.activityId,
35931
+ activityName: "Course completed",
35932
+ courseId: data.courseId,
35933
+ courseName: data.courseName,
35934
+ subject: data.subject,
35935
+ appName: data.appName,
35936
+ sensorUrl: data.sensorUrl,
35937
+ process: false,
35938
+ includeAttempt: false,
35939
+ eventExtensions: {
35940
+ playcademy: {
35941
+ eventKind: "course-completed",
35942
+ source: "gameplay"
35943
+ }
35944
+ },
35945
+ generatedExtensions: {
35946
+ playcademy: {
35947
+ eventKind: "course-completed",
35948
+ source: "gameplay",
35949
+ activityId: data.activityId
35950
+ }
35951
+ }
35952
+ }).catch((error) => {
35953
+ log.error("[ProgressRecorder] Failed to emit course completion history event", {
35954
+ error
35955
+ });
35956
+ });
35957
+ }
35837
35958
  }
35838
35959
 
35839
35960
  class SessionRecorder {
@@ -36228,6 +36349,10 @@ class TimebackClient {
36228
36349
  await this._ensureAuthenticated();
36229
36350
  return this.adminEventRecorder.recordMasteryAdjustment(data);
36230
36351
  }
36352
+ async recordAdminCourseCompletionChange(data) {
36353
+ await this._ensureAuthenticated();
36354
+ return this.adminEventRecorder.recordCourseCompletionChange(data);
36355
+ }
36231
36356
  clearCaches() {
36232
36357
  this.cacheManager.clearAll();
36233
36358
  }
package/dist/server.js CHANGED
@@ -1309,7 +1309,7 @@ var package_default;
1309
1309
  var init_package = __esm(() => {
1310
1310
  package_default = {
1311
1311
  name: "@playcademy/sandbox",
1312
- version: "0.3.16-beta.3",
1312
+ version: "0.3.16-beta.4",
1313
1313
  description: "Local development server for Playcademy game development",
1314
1314
  type: "module",
1315
1315
  exports: {
@@ -30206,16 +30206,7 @@ function mapAssessmentResultToRecentActivity(assessment, relevantCourseIds, cour
30206
30206
  return null;
30207
30207
  }
30208
30208
  if (isMasteryCompletionEntry(assessment)) {
30209
- const metadata3 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
30210
- const isResume = Boolean(metadata3?.resumedAt);
30211
- return {
30212
- id: assessment.sourcedId || `${assessment.assessmentLineItem.sourcedId}:${assessment.scoreDate}`,
30213
- kind: "admin-completion",
30214
- occurredAt: assessment.scoreDate,
30215
- courseId,
30216
- title: isResume ? "Course resumed" : "Course marked complete",
30217
- reason: metadata3?.adminAction ? "Admin action" : undefined
30218
- };
30209
+ return null;
30219
30210
  }
30220
30211
  const metadata2 = isRecord2(assessment.metadata) ? assessment.metadata : undefined;
30221
30212
  const activityName = getStringValue(metadata2?.activityName);
@@ -30248,6 +30239,7 @@ function parseCaliperEventContext(event, relevantCourseIds) {
30248
30239
  courseId,
30249
30240
  occurredAt,
30250
30241
  eventKind: getStringValue(playcademy?.eventKind),
30242
+ source: getStringValue(playcademy?.source),
30251
30243
  reason: getStringValue(playcademy?.reason),
30252
30244
  titleFromEvent: getStringValue(event.object.activity?.name),
30253
30245
  appName: getStringValue(event.object.app?.name),
@@ -30271,30 +30263,59 @@ function mapTimeSpentRemediation(event, ctx) {
30271
30263
  };
30272
30264
  }
30273
30265
  function mapActivityRemediation(event, ctx) {
30274
- let kind;
30275
30266
  if (ctx.eventKind === "remediation-xp") {
30276
- kind = "remediation-xp";
30277
- } else if (ctx.eventKind === "remediation-mastery") {
30278
- kind = "remediation-mastery";
30279
- } else {
30280
- return null;
30267
+ return {
30268
+ id: event.externalId,
30269
+ kind: "remediation-xp",
30270
+ occurredAt: ctx.occurredAt,
30271
+ courseId: ctx.courseId,
30272
+ title: "XP Adjustment",
30273
+ activityId: ctx.activityId,
30274
+ appName: ctx.appName,
30275
+ reason: ctx.reason,
30276
+ xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30277
+ masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30278
+ };
30281
30279
  }
30282
- const titleMap = {
30283
- "remediation-xp": "XP Adjustment",
30284
- "remediation-mastery": "Mastery Adjustment"
30285
- };
30286
- return {
30287
- id: event.externalId,
30288
- kind,
30289
- occurredAt: ctx.occurredAt,
30290
- courseId: ctx.courseId,
30291
- title: titleMap[kind] || "Remediation Activity",
30292
- activityId: ctx.activityId,
30293
- appName: ctx.appName,
30294
- reason: ctx.reason,
30295
- xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30296
- masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30297
- };
30280
+ if (ctx.eventKind === "remediation-mastery") {
30281
+ return {
30282
+ id: event.externalId,
30283
+ kind: "remediation-mastery",
30284
+ occurredAt: ctx.occurredAt,
30285
+ courseId: ctx.courseId,
30286
+ title: "Mastery Adjustment",
30287
+ activityId: ctx.activityId,
30288
+ appName: ctx.appName,
30289
+ reason: ctx.reason,
30290
+ xpDelta: getGeneratedMetricValue(event, "xpEarned"),
30291
+ masteredUnitsDelta: getGeneratedMetricValue(event, "masteredUnits")
30292
+ };
30293
+ }
30294
+ if (ctx.eventKind === "course-completed") {
30295
+ return {
30296
+ id: event.externalId,
30297
+ kind: "course-completed",
30298
+ occurredAt: ctx.occurredAt,
30299
+ courseId: ctx.courseId,
30300
+ title: ctx.source === "admin" ? "Course marked complete" : "Course completed",
30301
+ activityId: ctx.activityId,
30302
+ appName: ctx.appName,
30303
+ reason: ctx.reason
30304
+ };
30305
+ }
30306
+ if (ctx.eventKind === "course-resumed") {
30307
+ return {
30308
+ id: event.externalId,
30309
+ kind: "course-resumed",
30310
+ occurredAt: ctx.occurredAt,
30311
+ courseId: ctx.courseId,
30312
+ title: "Course resumed",
30313
+ activityId: ctx.activityId,
30314
+ appName: ctx.appName,
30315
+ reason: ctx.reason
30316
+ };
30317
+ }
30318
+ return null;
30298
30319
  }
30299
30320
  function mapCaliperEventToRemediationActivity(event, relevantCourseIds) {
30300
30321
  const ctx = parseCaliperEventContext(event, relevantCourseIds);
@@ -30316,6 +30337,7 @@ var init_timeback_util = __esm(() => {
30316
30337
  // ../api-core/src/services/timeback-admin.service.ts
30317
30338
  class TimebackAdminService {
30318
30339
  deps;
30340
+ static XP_PRECISION_FACTOR = 10;
30319
30341
  static RECENT_ACTIVITY_LIMIT = 20;
30320
30342
  static MAX_STUDENT_ACTIVITY_LIMIT = 200;
30321
30343
  static MAX_STUDENT_ACTIVITY_OFFSET = 1000;
@@ -30327,6 +30349,10 @@ class TimebackAdminService {
30327
30349
  constructor(deps) {
30328
30350
  this.deps = deps;
30329
30351
  }
30352
+ static roundXpToTenths(value) {
30353
+ const rounded = Math.round(value * TimebackAdminService.XP_PRECISION_FACTOR) / TimebackAdminService.XP_PRECISION_FACTOR;
30354
+ return Object.is(rounded, -0) ? 0 : rounded;
30355
+ }
30330
30356
  requireClient() {
30331
30357
  if (!this.deps.timeback) {
30332
30358
  logger16.error("Timeback client not available in context");
@@ -30334,6 +30360,17 @@ class TimebackAdminService {
30334
30360
  }
30335
30361
  return this.deps.timeback;
30336
30362
  }
30363
+ async recordCourseCompletionHistory(client, data) {
30364
+ await client.recordAdminCourseCompletionChange(data).catch((error) => {
30365
+ logger16.error("Failed to record admin course completion history event", {
30366
+ gameId: data.gameId,
30367
+ courseId: data.courseId,
30368
+ studentId: data.studentId,
30369
+ action: data.action,
30370
+ error: error instanceof Error ? error.message : String(error)
30371
+ });
30372
+ });
30373
+ }
30337
30374
  async resolveAdminMutationContext(gameId, courseId, user, studentId) {
30338
30375
  const client = this.requireClient();
30339
30376
  await this.deps.validateDeveloperAccess(user, gameId);
@@ -30373,8 +30410,8 @@ class TimebackAdminService {
30373
30410
  }
30374
30411
  const today = formatDateYMD();
30375
30412
  const history = [];
30376
- let totalXp = 0;
30377
- let todayXp = 0;
30413
+ let totalXpRaw = 0;
30414
+ let todayXpRaw = 0;
30378
30415
  let activeTimeSeconds = 0;
30379
30416
  let masteredUnits = 0;
30380
30417
  for (const [date3, subjectFacts] of Object.entries(facts)) {
@@ -30391,15 +30428,16 @@ class TimebackAdminService {
30391
30428
  masteredUnitsForDay += masteredUnitsFromFact;
30392
30429
  }
30393
30430
  }
30394
- totalXp += xpForDay;
30431
+ const roundedXpForDay = TimebackAdminService.roundXpToTenths(xpForDay);
30432
+ totalXpRaw += xpForDay;
30395
30433
  activeTimeSeconds += activeSecondsForDay;
30396
30434
  masteredUnits += masteredUnitsForDay;
30397
30435
  if (date3 === today) {
30398
- todayXp += xpForDay;
30436
+ todayXpRaw += xpForDay;
30399
30437
  }
30400
30438
  history.push({
30401
30439
  date: date3,
30402
- xpEarned: xpForDay,
30440
+ xpEarned: roundedXpForDay,
30403
30441
  activeTimeSeconds: activeSecondsForDay,
30404
30442
  masteredUnits: masteredUnitsForDay
30405
30443
  });
@@ -30407,8 +30445,8 @@ class TimebackAdminService {
30407
30445
  history.sort((a, b) => a.date.localeCompare(b.date));
30408
30446
  return {
30409
30447
  analyticsAvailable: true,
30410
- totalXp,
30411
- todayXp,
30448
+ totalXp: TimebackAdminService.roundXpToTenths(totalXpRaw),
30449
+ todayXp: TimebackAdminService.roundXpToTenths(todayXpRaw),
30412
30450
  activeTimeSeconds,
30413
30451
  masteredUnits,
30414
30452
  history
@@ -30750,6 +30788,7 @@ class TimebackAdminService {
30750
30788
  }
30751
30789
  async toggleCourseCompletion(data, user) {
30752
30790
  const { client, sensorUrl, appName, actor } = await this.resolveAdminMutationContext(data.gameId, data.courseId, user, data.studentId);
30791
+ const historyClient = client;
30753
30792
  const ids = deriveSourcedIds(data.courseId);
30754
30793
  const lineItemId = `${ids.course}-mastery-completion-assessment`;
30755
30794
  const resultId = `${lineItemId}:${data.studentId}:completion`;
@@ -30800,11 +30839,19 @@ class TimebackAdminService {
30800
30839
  inProgress: "false",
30801
30840
  metadata: {
30802
30841
  isMasteryCompletion: true,
30803
- completedAt: new Date().toISOString(),
30804
30842
  adminAction: true,
30805
30843
  appName
30806
30844
  }
30807
30845
  });
30846
+ await this.recordCourseCompletionHistory(historyClient, {
30847
+ gameId: data.gameId,
30848
+ courseId: data.courseId,
30849
+ studentId: data.studentId,
30850
+ action: "complete",
30851
+ actor,
30852
+ appName,
30853
+ sensorUrl
30854
+ });
30808
30855
  } else {
30809
30856
  await client.oneroster.assessmentResults.upsert(resultId, {
30810
30857
  sourcedId: resultId,
@@ -30817,11 +30864,19 @@ class TimebackAdminService {
30817
30864
  inProgress: "true",
30818
30865
  metadata: {
30819
30866
  isMasteryCompletion: true,
30820
- resumedAt: new Date().toISOString(),
30821
30867
  adminAction: true,
30822
30868
  appName
30823
30869
  }
30824
30870
  });
30871
+ await this.recordCourseCompletionHistory(historyClient, {
30872
+ gameId: data.gameId,
30873
+ courseId: data.courseId,
30874
+ studentId: data.studentId,
30875
+ action: "resume",
30876
+ actor,
30877
+ appName,
30878
+ sensorUrl
30879
+ });
30825
30880
  }
30826
30881
  return { status: "ok" };
30827
30882
  }
@@ -35088,7 +35143,8 @@ function buildAdminEventMetadata({
35088
35143
  return {
35089
35144
  playcademy: {
35090
35145
  eventKind,
35091
- reason
35146
+ reason,
35147
+ source: "admin"
35092
35148
  }
35093
35149
  };
35094
35150
  }
@@ -35204,6 +35260,30 @@ class AdminEventRecorder {
35204
35260
  eventExtensions: ctx.metadata
35205
35261
  });
35206
35262
  }
35263
+ async recordCourseCompletionChange(data) {
35264
+ const isResume = data.action === "resume";
35265
+ const ctx = await this.prepareAdminEvent({
35266
+ ...data,
35267
+ defaultActivityId: isResume ? "playcademy-admin-course-resumed" : "playcademy-admin-course-completed",
35268
+ reason: "Admin action",
35269
+ eventKind: isResume ? "course-resumed" : "course-completed"
35270
+ });
35271
+ await this.caliper.emitActivityEvent({
35272
+ studentId: ctx.student.id,
35273
+ studentEmail: ctx.student.email,
35274
+ activityId: ctx.activityId,
35275
+ activityName: isResume ? "Course resumed" : "Course marked complete",
35276
+ courseId: data.courseId,
35277
+ courseName: ctx.courseContext.courseName,
35278
+ subject: ctx.courseContext.subject,
35279
+ appName: ctx.appName,
35280
+ sensorUrl: ctx.sensorUrl,
35281
+ process: false,
35282
+ includeAttempt: false,
35283
+ generatedExtensions: ctx.metadata,
35284
+ eventExtensions: ctx.metadata
35285
+ });
35286
+ }
35207
35287
  }
35208
35288
 
35209
35289
  class TimebackCache {
@@ -35417,7 +35497,7 @@ class MasteryTracker {
35417
35497
  const totalMastered = historicalMasteredUnits + masteredUnits;
35418
35498
  const rawPct = totalMastered / masterableUnits * 100;
35419
35499
  const pctCompleteApp = Math.min(100, Math.max(0, Math.round(rawPct)));
35420
- const masteryAchieved = totalMastered >= masterableUnits;
35500
+ const masteryAchieved = historicalMasteredUnits < masterableUnits && totalMastered >= masterableUnits;
35421
35501
  return { pctCompleteApp, masteryAchieved };
35422
35502
  }
35423
35503
  async createCompletionEntry(studentId, courseId, classId, appName) {
@@ -35443,7 +35523,6 @@ class MasteryTracker {
35443
35523
  inProgress: "false",
35444
35524
  metadata: {
35445
35525
  isMasteryCompletion: true,
35446
- completedAt: new Date().toISOString(),
35447
35526
  appName
35448
35527
  }
35449
35528
  });
@@ -35659,6 +35738,16 @@ class ProgressRecorder {
35659
35738
  }
35660
35739
  if (masteryAchieved) {
35661
35740
  await this.masteryTracker.createCompletionEntry(studentId, courseId, progressData.classId, progressData.appName);
35741
+ await this.emitCourseCompletionHistoryEvent({
35742
+ studentId,
35743
+ studentEmail,
35744
+ activityId,
35745
+ courseId: ids.course,
35746
+ courseName,
35747
+ subject: progressData.subject,
35748
+ appName: progressData.appName,
35749
+ sensorUrl: progressData.sensorUrl
35750
+ });
35662
35751
  }
35663
35752
  await this.emitCaliperEvent({
35664
35753
  studentId,
@@ -35833,6 +35922,38 @@ class ProgressRecorder {
35833
35922
  log.error("[ProgressRecorder] Failed to emit activity event", { error });
35834
35923
  });
35835
35924
  }
35925
+ async emitCourseCompletionHistoryEvent(data) {
35926
+ await this.caliperNamespace.emitActivityEvent({
35927
+ studentId: data.studentId,
35928
+ studentEmail: data.studentEmail,
35929
+ activityId: data.activityId,
35930
+ activityName: "Course completed",
35931
+ courseId: data.courseId,
35932
+ courseName: data.courseName,
35933
+ subject: data.subject,
35934
+ appName: data.appName,
35935
+ sensorUrl: data.sensorUrl,
35936
+ process: false,
35937
+ includeAttempt: false,
35938
+ eventExtensions: {
35939
+ playcademy: {
35940
+ eventKind: "course-completed",
35941
+ source: "gameplay"
35942
+ }
35943
+ },
35944
+ generatedExtensions: {
35945
+ playcademy: {
35946
+ eventKind: "course-completed",
35947
+ source: "gameplay",
35948
+ activityId: data.activityId
35949
+ }
35950
+ }
35951
+ }).catch((error) => {
35952
+ log.error("[ProgressRecorder] Failed to emit course completion history event", {
35953
+ error
35954
+ });
35955
+ });
35956
+ }
35836
35957
  }
35837
35958
 
35838
35959
  class SessionRecorder {
@@ -36227,6 +36348,10 @@ class TimebackClient {
36227
36348
  await this._ensureAuthenticated();
36228
36349
  return this.adminEventRecorder.recordMasteryAdjustment(data);
36229
36350
  }
36351
+ async recordAdminCourseCompletionChange(data) {
36352
+ await this._ensureAuthenticated();
36353
+ return this.adminEventRecorder.recordCourseCompletionChange(data);
36354
+ }
36230
36355
  clearCaches() {
36231
36356
  this.cacheManager.clearAll();
36232
36357
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.3.16-beta.3",
3
+ "version": "0.3.16-beta.4",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {