@playcademy/sandbox 0.6.0 → 0.6.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
@@ -234,7 +234,7 @@ var init_game = __esm(() => {
234
234
  });
235
235
 
236
236
  // ../constants/src/platform.ts
237
- var CORE_GAME_UUIDS;
237
+ var PLAYCADEMY_BROWSER_TIME_ZONE_HEADER = "x-playcademy-browser-time-zone", CORE_GAME_UUIDS;
238
238
  var init_platform = __esm(() => {
239
239
  CORE_GAME_UUIDS = {
240
240
  DEMO: "00000000-0000-0000-0000-000000000001"
@@ -1080,7 +1080,7 @@ var package_default;
1080
1080
  var init_package = __esm(() => {
1081
1081
  package_default = {
1082
1082
  name: "@playcademy/sandbox",
1083
- version: "0.6.0",
1083
+ version: "0.6.1-beta.2",
1084
1084
  description: "Local development server for Playcademy game development",
1085
1085
  type: "module",
1086
1086
  exports: {
@@ -11057,6 +11057,11 @@ var init_table3 = __esm(() => {
11057
11057
  email: text("email").notNull().unique(),
11058
11058
  isAnonymous: boolean("is_anonymous").notNull().default(false),
11059
11059
  timebackId: text("timeback_id").unique(),
11060
+ learningTimeZone: text("learning_time_zone"),
11061
+ learningTimeZoneUpdatedAt: timestamp("learning_time_zone_updated_at", {
11062
+ mode: "date",
11063
+ withTimezone: true
11064
+ }),
11060
11065
  emailVerified: boolean("email_verified").notNull().default(false),
11061
11066
  image: text("image"),
11062
11067
  role: userRoleEnum("role").notNull().default("player"),
@@ -31042,7 +31047,7 @@ var init_schemas4 = __esm(() => {
31042
31047
  subject: TimebackSubjectSchema,
31043
31048
  appName: exports_external.string().optional(),
31044
31049
  sensorUrl: exports_external.string().url().optional(),
31045
- courseId: exports_external.string().optional(),
31050
+ courseId: exports_external.string().min(1).optional(),
31046
31051
  courseName: exports_external.string().optional(),
31047
31052
  studentEmail: exports_external.string().email().optional()
31048
31053
  });
@@ -75585,6 +75590,7 @@ class AnalyticsXp {
75585
75590
  this.core = core3;
75586
75591
  }
75587
75592
  async get(studentId, options) {
75593
+ const timezone2 = options?.timezone ?? PLATFORM_TIMEZONE;
75588
75594
  const enrollments = await this.core.api.edubridge.enrollments.list({ userId: studentId });
75589
75595
  const filteredEnrollments = options?.courseIds?.length ? enrollments.filter((e) => options.courseIds.includes(e.course.id)) : enrollments;
75590
75596
  if (filteredEnrollments.length === 0) {
@@ -75598,7 +75604,7 @@ class AnalyticsXp {
75598
75604
  try {
75599
75605
  const analytics = await this.core.api.edubridge.analytics.getEnrollmentFacts({
75600
75606
  enrollmentId: enrollment.id,
75601
- timezone: PLATFORM_TIMEZONE
75607
+ timezone: timezone2
75602
75608
  });
75603
75609
  return { enrollment, analytics };
75604
75610
  } catch (error88) {
@@ -75610,7 +75616,7 @@ class AnalyticsXp {
75610
75616
  return { enrollment, analytics: null };
75611
75617
  }
75612
75618
  }));
75613
- const today = formatDateYMDInTimezone(PLATFORM_TIMEZONE);
75619
+ const today = formatDateYMDInTimezone(timezone2);
75614
75620
  let totalXp = 0;
75615
75621
  let todayXp = 0;
75616
75622
  const courses = [];
@@ -80367,6 +80373,24 @@ var init_timeback_service = __esm(async () => {
80367
80373
  }
80368
80374
  };
80369
80375
  }
80376
+ static resolveRuntimeCourse(resolvedCourseId, requestedCourseId, courseName) {
80377
+ let requestedCourseIdStatus = "absent";
80378
+ if (requestedCourseId === resolvedCourseId) {
80379
+ requestedCourseIdStatus = "accepted";
80380
+ } else if (requestedCourseId !== undefined) {
80381
+ requestedCourseIdStatus = "ignored_mismatch";
80382
+ }
80383
+ return {
80384
+ courseId: resolvedCourseId,
80385
+ courseName: requestedCourseIdStatus === "ignored_mismatch" ? undefined : courseName,
80386
+ attributes: {
80387
+ "app.timeback.course_id": resolvedCourseId,
80388
+ "app.timeback.resolved_course_id": resolvedCourseId,
80389
+ "app.timeback.requested_course_id": requestedCourseId,
80390
+ "app.timeback.requested_course_id_status": requestedCourseIdStatus
80391
+ }
80392
+ };
80393
+ }
80370
80394
  static recordRuntimeContext({
80371
80395
  operation,
80372
80396
  studentId,
@@ -81232,8 +81256,13 @@ var init_timeback_service = __esm(async () => {
81232
81256
  if (!integration) {
81233
81257
  throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
81234
81258
  }
81259
+ const {
81260
+ courseId: runtimeCourseId,
81261
+ courseName: runtimeCourseName,
81262
+ attributes: courseAttributes
81263
+ } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
81235
81264
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
81236
- const result = await client.activity.record(integration.courseId, studentId, {
81265
+ const result = await client.activity.record(runtimeCourseId, studentId, {
81237
81266
  gameId,
81238
81267
  score: scorePercentage,
81239
81268
  totalQuestions: scoreData.totalQuestions,
@@ -81248,8 +81277,8 @@ var init_timeback_service = __esm(async () => {
81248
81277
  subject: activityData.subject,
81249
81278
  appName: activityData.appName,
81250
81279
  sensorUrl: activityData.sensorUrl,
81251
- courseId: activityData.courseId,
81252
- courseName: activityData.courseName,
81280
+ courseId: runtimeCourseId,
81281
+ courseName: runtimeCourseName,
81253
81282
  studentEmail: activityData.studentEmail,
81254
81283
  courseTotalXp: integration.totalXp,
81255
81284
  ...runId ? { runId } : {}
@@ -81258,7 +81287,7 @@ var init_timeback_service = __esm(async () => {
81258
81287
  const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
81259
81288
  const sessionEndEmitted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
81260
81289
  if (sessionEndEmitted) {
81261
- await client.activity.session(integration.courseId, studentId, {
81290
+ await client.activity.session(runtimeCourseId, studentId, {
81262
81291
  gameId,
81263
81292
  activeTimeSeconds: sessionEndActiveSeconds,
81264
81293
  ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
@@ -81267,15 +81296,15 @@ var init_timeback_service = __esm(async () => {
81267
81296
  subject: activityData.subject,
81268
81297
  appName: activityData.appName,
81269
81298
  sensorUrl: activityData.sensorUrl,
81270
- courseId: activityData.courseId,
81271
- courseName: activityData.courseName,
81299
+ courseId: runtimeCourseId,
81300
+ courseName: runtimeCourseName,
81272
81301
  studentEmail: activityData.studentEmail,
81273
81302
  extensions: extensionsWithResumeId,
81274
81303
  ...runId ? { runId } : {}
81275
81304
  });
81276
81305
  }
81277
81306
  setAttributes({
81278
- "app.timeback.course_id": integration.courseId,
81307
+ ...courseAttributes,
81279
81308
  "app.timeback.grade": activityData.grade,
81280
81309
  "app.timeback.subject": activityData.subject,
81281
81310
  "app.timeback.total_questions": scoreData.totalQuestions,
@@ -81292,7 +81321,7 @@ var init_timeback_service = __esm(async () => {
81292
81321
  });
81293
81322
  return {
81294
81323
  status: "ok",
81295
- courseId: integration.courseId,
81324
+ courseId: runtimeCourseId,
81296
81325
  xpAwarded: result.xpAwarded,
81297
81326
  masteredUnits: result.masteredUnitsApplied,
81298
81327
  pctCompleteApp: result.pctCompleteApp,
@@ -81503,10 +81532,15 @@ var init_timeback_service = __esm(async () => {
81503
81532
  if (!integration) {
81504
81533
  throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
81505
81534
  }
81535
+ const {
81536
+ courseId: runtimeCourseId,
81537
+ courseName: runtimeCourseName,
81538
+ attributes: courseAttributes
81539
+ } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
81506
81540
  const activeTimeSeconds = timingData.activeMs / 1000;
81507
81541
  const inactiveTimeSeconds = timingData.pausedMs / 1000;
81508
81542
  if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
81509
- await client.activity.session(integration.courseId, studentId, {
81543
+ await client.activity.session(runtimeCourseId, studentId, {
81510
81544
  gameId,
81511
81545
  activeTimeSeconds,
81512
81546
  ...inactiveTimeSeconds > 0 ? { inactiveTimeSeconds } : {},
@@ -81515,15 +81549,15 @@ var init_timeback_service = __esm(async () => {
81515
81549
  subject: activityData.subject,
81516
81550
  appName: activityData.appName,
81517
81551
  sensorUrl: activityData.sensorUrl,
81518
- courseId: activityData.courseId,
81519
- courseName: activityData.courseName,
81552
+ courseId: runtimeCourseId,
81553
+ courseName: runtimeCourseName,
81520
81554
  studentEmail: activityData.studentEmail,
81521
81555
  extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
81522
81556
  ...runId ? { runId } : {}
81523
81557
  });
81524
81558
  }
81525
81559
  setAttributes({
81526
- "app.timeback.course_id": integration.courseId,
81560
+ ...courseAttributes,
81527
81561
  "app.timeback.grade": activityData.grade,
81528
81562
  "app.timeback.subject": activityData.subject,
81529
81563
  "app.timeback.active_time_seconds": activeTimeSeconds,
@@ -81568,13 +81602,24 @@ var init_timeback_service = __esm(async () => {
81568
81602
  };
81569
81603
  }
81570
81604
  }
81605
+ const timezone2 = await this.getLearningTimeZoneForStudent(timebackId);
81571
81606
  const result = await client.analytics.getXp(timebackId, {
81572
81607
  courseIds: courseIds.length > 0 ? courseIds : undefined,
81608
+ timezone: timezone2,
81573
81609
  include: options?.include
81574
81610
  });
81575
81611
  return result;
81576
81612
  });
81577
81613
  }
81614
+ async getLearningTimeZoneForStudent(timebackId) {
81615
+ const user = await this.deps.db.query.users.findFirst({
81616
+ where: eq(users.timebackId, timebackId),
81617
+ columns: {
81618
+ learningTimeZone: true
81619
+ }
81620
+ });
81621
+ return user?.learningTimeZone ?? PLATFORM_TIMEZONE;
81622
+ }
81578
81623
  async getStudentMastery(timebackId, user, options) {
81579
81624
  return this.withClientTelemetry(async () => {
81580
81625
  const client = this.requireClient();
@@ -82356,13 +82401,28 @@ var init_session_service = __esm(() => {
82356
82401
  init_errors();
82357
82402
  });
82358
82403
 
82404
+ // ../api-core/src/utils/timezone.util.ts
82405
+ function normalizeIanaTimeZone(value) {
82406
+ const timeZone = value?.trim();
82407
+ if (!timeZone || timeZone.length > MAX_IANA_TIME_ZONE_LENGTH) {
82408
+ return;
82409
+ }
82410
+ try {
82411
+ new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date);
82412
+ return timeZone;
82413
+ } catch {
82414
+ return;
82415
+ }
82416
+ }
82417
+ var MAX_IANA_TIME_ZONE_LENGTH = 100;
82418
+
82359
82419
  // ../api-core/src/services/user.service.ts
82360
82420
  class UserService {
82361
82421
  deps;
82362
82422
  constructor(deps) {
82363
82423
  this.deps = deps;
82364
82424
  }
82365
- async getMe(user, gameId) {
82425
+ async getMe(user, gameId, observedTimeZone) {
82366
82426
  const db2 = this.deps.db;
82367
82427
  const userData = await db2.query.users.findFirst({
82368
82428
  where: eq(users.id, user.id)
@@ -82375,6 +82435,7 @@ class UserService {
82375
82435
  "app.user.has_timeback_account": Boolean(userData.timebackId),
82376
82436
  "app.user.timeback_enriched": false
82377
82437
  });
82438
+ const localDay = await this.resolveLocalDay(userData, observedTimeZone);
82378
82439
  const timeback2 = userData.timebackId ? await this.fetchTimebackData(userData.timebackId, gameId) : undefined;
82379
82440
  setAttribute("app.user.timeback_enriched", Boolean(timeback2));
82380
82441
  if (gameId) {
@@ -82384,6 +82445,7 @@ class UserService {
82384
82445
  role: userData.role,
82385
82446
  username: userData.username,
82386
82447
  email: userData.email,
82448
+ localDay,
82387
82449
  timeback: timeback2
82388
82450
  };
82389
82451
  }
@@ -82403,6 +82465,7 @@ class UserService {
82403
82465
  createdAt: userData.createdAt,
82404
82466
  updatedAt: userData.updatedAt,
82405
82467
  hasTimebackAccount: Boolean(timebackAccount),
82468
+ localDay,
82406
82469
  timeback: timeback2
82407
82470
  };
82408
82471
  }
@@ -82440,6 +82503,44 @@ class UserService {
82440
82503
  isDefault: updatedUser.name === DEMO_DISPLAY_NAME_PLACEHOLDER
82441
82504
  };
82442
82505
  }
82506
+ async resolveLocalDay(userData, observedTimeZone) {
82507
+ const normalizedTimeZone = normalizeIanaTimeZone(observedTimeZone);
82508
+ if (normalizedTimeZone) {
82509
+ const observedAt = new Date;
82510
+ const shouldPersist = userData.learningTimeZone !== normalizedTimeZone || !userData.learningTimeZoneUpdatedAt || observedAt.getTime() - userData.learningTimeZoneUpdatedAt.getTime() >= LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS;
82511
+ if (shouldPersist) {
82512
+ await this.deps.db.update(users).set({
82513
+ learningTimeZone: normalizedTimeZone,
82514
+ learningTimeZoneUpdatedAt: observedAt
82515
+ }).where(eq(users.id, userData.id));
82516
+ }
82517
+ setAttributes({
82518
+ "app.user.learning_time_zone_observed": true,
82519
+ "app.user.learning_time_zone_persisted": shouldPersist
82520
+ });
82521
+ return {
82522
+ timeZone: normalizedTimeZone,
82523
+ source: "browser_last_seen",
82524
+ observedAt: (shouldPersist ? observedAt : userData.learningTimeZoneUpdatedAt)?.toISOString() ?? null
82525
+ };
82526
+ }
82527
+ setAttributes({
82528
+ "app.user.learning_time_zone_observed": false,
82529
+ "app.user.learning_time_zone_persisted": false
82530
+ });
82531
+ if (userData.learningTimeZone) {
82532
+ return {
82533
+ timeZone: userData.learningTimeZone,
82534
+ source: "browser_last_seen",
82535
+ observedAt: userData.learningTimeZoneUpdatedAt?.toISOString() ?? null
82536
+ };
82537
+ }
82538
+ return {
82539
+ timeZone: PLATFORM_TIMEZONE,
82540
+ source: "platform_default",
82541
+ observedAt: null
82542
+ };
82543
+ }
82443
82544
  async fetchTimebackData(timebackId, gameId) {
82444
82545
  const [{ role, organizations: allOrganizations }, allEnrollments] = await Promise.all([
82445
82546
  withSpan("timeback.fetch_profile", () => this.fetchStudentProfile(timebackId)),
@@ -82521,6 +82622,7 @@ class UserService {
82521
82622
  return organizations.filter((o) => enrollmentOrgIds.has(o.id));
82522
82623
  }
82523
82624
  }
82625
+ var LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS;
82524
82626
  var init_user_service = __esm(() => {
82525
82627
  init_drizzle_orm();
82526
82628
  init_src();
@@ -82529,6 +82631,7 @@ var init_user_service = __esm(() => {
82529
82631
  init_spans();
82530
82632
  init_errors();
82531
82633
  init_timeback_util();
82634
+ LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
82532
82635
  });
82533
82636
 
82534
82637
  // ../api-core/src/services/verify.service.ts
@@ -140159,9 +140262,10 @@ var init_upload_controller = __esm(() => {
140159
140262
  // ../api-core/src/controllers/user.controller.ts
140160
140263
  var getMe, getDemoProfile, updateDemoProfile, users2;
140161
140264
  var init_user_controller = __esm(() => {
140265
+ init_src();
140162
140266
  init_schemas_index();
140163
140267
  init_utils11();
140164
- getMe = requireNonAnonymous(async (ctx) => ctx.services.user.getMe(ctx.user, ctx.gameId));
140268
+ getMe = requireNonAnonymous(async (ctx) => ctx.services.user.getMe(ctx.user, ctx.gameId, ctx.request.headers.get(PLAYCADEMY_BROWSER_TIME_ZONE_HEADER)));
140165
140269
  getDemoProfile = requireAnonymous(async (ctx) => ctx.services.user.getDemoProfile(ctx.user.id));
140166
140270
  updateDemoProfile = requireAnonymous(async (ctx) => {
140167
140271
  const body2 = await parseRequestBody(ctx.request, DemoProfileSchema);
@@ -140316,8 +140420,39 @@ function getMockHighestGradeMastered(enrollments, subject) {
140316
140420
  const subjectGrades = enrollments.filter((enrollment) => enrollment.subject === subject).map((enrollment) => enrollment.grade);
140317
140421
  return subjectGrades.length > 0 ? Math.max(...subjectGrades) : null;
140318
140422
  }
140319
- async function buildMockUserResponse(db2, user, gameId) {
140423
+ async function buildLocalDayContext(db2, user, observedTimeZone) {
140424
+ const normalizedTimeZone = normalizeIanaTimeZone(observedTimeZone);
140425
+ if (normalizedTimeZone) {
140426
+ const observedAt = new Date;
140427
+ const shouldPersist = user.learningTimeZone !== normalizedTimeZone || !user.learningTimeZoneUpdatedAt || observedAt.getTime() - user.learningTimeZoneUpdatedAt.getTime() >= LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS2;
140428
+ if (shouldPersist) {
140429
+ await db2.update(users).set({
140430
+ learningTimeZone: normalizedTimeZone,
140431
+ learningTimeZoneUpdatedAt: observedAt
140432
+ }).where(eq(users.id, user.id));
140433
+ }
140434
+ return {
140435
+ timeZone: normalizedTimeZone,
140436
+ source: "browser_last_seen",
140437
+ observedAt: (shouldPersist ? observedAt : user.learningTimeZoneUpdatedAt)?.toISOString() ?? null
140438
+ };
140439
+ }
140440
+ if (user.learningTimeZone) {
140441
+ return {
140442
+ timeZone: user.learningTimeZone,
140443
+ source: "browser_last_seen",
140444
+ observedAt: user.learningTimeZoneUpdatedAt?.toISOString() ?? null
140445
+ };
140446
+ }
140447
+ return {
140448
+ timeZone: PLATFORM_TIMEZONE,
140449
+ source: "platform_default",
140450
+ observedAt: null
140451
+ };
140452
+ }
140453
+ async function buildMockUserResponse(db2, user, gameId, observedTimeZone) {
140320
140454
  const timeback3 = user.timebackId ? await getMockTimebackData(db2, user.timebackId, gameId) : undefined;
140455
+ const localDay = await buildLocalDayContext(db2, user, observedTimeZone);
140321
140456
  if (gameId) {
140322
140457
  return {
140323
140458
  id: user.id,
@@ -140325,6 +140460,7 @@ async function buildMockUserResponse(db2, user, gameId) {
140325
140460
  role: user.role,
140326
140461
  username: user.username,
140327
140462
  email: user.email,
140463
+ localDay,
140328
140464
  timeback: timeback3
140329
140465
  };
140330
140466
  }
@@ -140343,15 +140479,19 @@ async function buildMockUserResponse(db2, user, gameId) {
140343
140479
  createdAt: user.createdAt,
140344
140480
  updatedAt: user.updatedAt,
140345
140481
  hasTimebackAccount: Boolean(timebackAccount),
140482
+ localDay,
140346
140483
  timeback: timeback3
140347
140484
  };
140348
140485
  }
140486
+ var LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS2;
140349
140487
  var init_timeback6 = __esm(() => {
140350
140488
  init_drizzle_orm();
140351
140489
  init_utils11();
140490
+ init_src();
140352
140491
  init_tables_index();
140353
140492
  init_src2();
140354
140493
  init_config();
140494
+ LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS2 = 24 * 60 * 60 * 1000;
140355
140495
  });
140356
140496
 
140357
140497
  // src/routes/platform/users.ts
@@ -140361,6 +140501,7 @@ var init_users = __esm(async () => {
140361
140501
  init_dist7();
140362
140502
  init_controllers();
140363
140503
  init_errors();
140504
+ init_src();
140364
140505
  init_tables_index();
140365
140506
  init_error_handler();
140366
140507
  init_timeback6();
@@ -140382,7 +140523,7 @@ var init_users = __esm(async () => {
140382
140523
  const error89 = ApiError.notFound("User not found");
140383
140524
  return c2.json(createErrorResponse(error89), error89.status);
140384
140525
  }
140385
- const response = await buildMockUserResponse(db2, userData, gameId);
140526
+ const response = await buildMockUserResponse(db2, userData, gameId, c2.req.header(PLAYCADEMY_BROWSER_TIME_ZONE_HEADER));
140386
140527
  return c2.json(response);
140387
140528
  }
140388
140529
  return handle2(users2.getMe)(c2);
package/dist/constants.js CHANGED
@@ -69,7 +69,7 @@ var init_game = __esm(() => {
69
69
  });
70
70
 
71
71
  // ../constants/src/platform.ts
72
- var CORE_GAME_UUIDS;
72
+ var PLAYCADEMY_BROWSER_TIME_ZONE_HEADER = "x-playcademy-browser-time-zone", CORE_GAME_UUIDS;
73
73
  var init_platform = __esm(() => {
74
74
  CORE_GAME_UUIDS = {
75
75
  DEMO: "00000000-0000-0000-0000-000000000001"
@@ -35,4 +35,4 @@ export declare function getMockHighestGradeMastered(enrollments: UserEnrollment[
35
35
  * Build a complete user response with mock timeback data.
36
36
  * Used to bypass api-core when in mock mode (avoids real API calls).
37
37
  */
38
- export declare function buildMockUserResponse(db: DatabaseInstance, user: User, gameId?: string): Promise<AuthenticatedUser | GameUser>;
38
+ export declare function buildMockUserResponse(db: DatabaseInstance, user: User, gameId?: string, observedTimeZone?: string | null): Promise<AuthenticatedUser | GameUser>;
package/dist/server.js CHANGED
@@ -233,7 +233,7 @@ var init_game = __esm(() => {
233
233
  });
234
234
 
235
235
  // ../constants/src/platform.ts
236
- var CORE_GAME_UUIDS;
236
+ var PLAYCADEMY_BROWSER_TIME_ZONE_HEADER = "x-playcademy-browser-time-zone", CORE_GAME_UUIDS;
237
237
  var init_platform = __esm(() => {
238
238
  CORE_GAME_UUIDS = {
239
239
  DEMO: "00000000-0000-0000-0000-000000000001"
@@ -1079,7 +1079,7 @@ var package_default;
1079
1079
  var init_package = __esm(() => {
1080
1080
  package_default = {
1081
1081
  name: "@playcademy/sandbox",
1082
- version: "0.6.0",
1082
+ version: "0.6.1-beta.2",
1083
1083
  description: "Local development server for Playcademy game development",
1084
1084
  type: "module",
1085
1085
  exports: {
@@ -11056,6 +11056,11 @@ var init_table3 = __esm(() => {
11056
11056
  email: text("email").notNull().unique(),
11057
11057
  isAnonymous: boolean("is_anonymous").notNull().default(false),
11058
11058
  timebackId: text("timeback_id").unique(),
11059
+ learningTimeZone: text("learning_time_zone"),
11060
+ learningTimeZoneUpdatedAt: timestamp("learning_time_zone_updated_at", {
11061
+ mode: "date",
11062
+ withTimezone: true
11063
+ }),
11059
11064
  emailVerified: boolean("email_verified").notNull().default(false),
11060
11065
  image: text("image"),
11061
11066
  role: userRoleEnum("role").notNull().default("player"),
@@ -31041,7 +31046,7 @@ var init_schemas4 = __esm(() => {
31041
31046
  subject: TimebackSubjectSchema,
31042
31047
  appName: exports_external.string().optional(),
31043
31048
  sensorUrl: exports_external.string().url().optional(),
31044
- courseId: exports_external.string().optional(),
31049
+ courseId: exports_external.string().min(1).optional(),
31045
31050
  courseName: exports_external.string().optional(),
31046
31051
  studentEmail: exports_external.string().email().optional()
31047
31052
  });
@@ -75584,6 +75589,7 @@ class AnalyticsXp {
75584
75589
  this.core = core3;
75585
75590
  }
75586
75591
  async get(studentId, options) {
75592
+ const timezone2 = options?.timezone ?? PLATFORM_TIMEZONE;
75587
75593
  const enrollments = await this.core.api.edubridge.enrollments.list({ userId: studentId });
75588
75594
  const filteredEnrollments = options?.courseIds?.length ? enrollments.filter((e) => options.courseIds.includes(e.course.id)) : enrollments;
75589
75595
  if (filteredEnrollments.length === 0) {
@@ -75597,7 +75603,7 @@ class AnalyticsXp {
75597
75603
  try {
75598
75604
  const analytics = await this.core.api.edubridge.analytics.getEnrollmentFacts({
75599
75605
  enrollmentId: enrollment.id,
75600
- timezone: PLATFORM_TIMEZONE
75606
+ timezone: timezone2
75601
75607
  });
75602
75608
  return { enrollment, analytics };
75603
75609
  } catch (error88) {
@@ -75609,7 +75615,7 @@ class AnalyticsXp {
75609
75615
  return { enrollment, analytics: null };
75610
75616
  }
75611
75617
  }));
75612
- const today = formatDateYMDInTimezone(PLATFORM_TIMEZONE);
75618
+ const today = formatDateYMDInTimezone(timezone2);
75613
75619
  let totalXp = 0;
75614
75620
  let todayXp = 0;
75615
75621
  const courses = [];
@@ -80366,6 +80372,24 @@ var init_timeback_service = __esm(async () => {
80366
80372
  }
80367
80373
  };
80368
80374
  }
80375
+ static resolveRuntimeCourse(resolvedCourseId, requestedCourseId, courseName) {
80376
+ let requestedCourseIdStatus = "absent";
80377
+ if (requestedCourseId === resolvedCourseId) {
80378
+ requestedCourseIdStatus = "accepted";
80379
+ } else if (requestedCourseId !== undefined) {
80380
+ requestedCourseIdStatus = "ignored_mismatch";
80381
+ }
80382
+ return {
80383
+ courseId: resolvedCourseId,
80384
+ courseName: requestedCourseIdStatus === "ignored_mismatch" ? undefined : courseName,
80385
+ attributes: {
80386
+ "app.timeback.course_id": resolvedCourseId,
80387
+ "app.timeback.resolved_course_id": resolvedCourseId,
80388
+ "app.timeback.requested_course_id": requestedCourseId,
80389
+ "app.timeback.requested_course_id_status": requestedCourseIdStatus
80390
+ }
80391
+ };
80392
+ }
80369
80393
  static recordRuntimeContext({
80370
80394
  operation,
80371
80395
  studentId,
@@ -81231,8 +81255,13 @@ var init_timeback_service = __esm(async () => {
81231
81255
  if (!integration) {
81232
81256
  throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
81233
81257
  }
81258
+ const {
81259
+ courseId: runtimeCourseId,
81260
+ courseName: runtimeCourseName,
81261
+ attributes: courseAttributes
81262
+ } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
81234
81263
  const scorePercentage = scoreData.totalQuestions > 0 ? scoreData.correctQuestions / scoreData.totalQuestions * 100 : 0;
81235
- const result = await client.activity.record(integration.courseId, studentId, {
81264
+ const result = await client.activity.record(runtimeCourseId, studentId, {
81236
81265
  gameId,
81237
81266
  score: scorePercentage,
81238
81267
  totalQuestions: scoreData.totalQuestions,
@@ -81247,8 +81276,8 @@ var init_timeback_service = __esm(async () => {
81247
81276
  subject: activityData.subject,
81248
81277
  appName: activityData.appName,
81249
81278
  sensorUrl: activityData.sensorUrl,
81250
- courseId: activityData.courseId,
81251
- courseName: activityData.courseName,
81279
+ courseId: runtimeCourseId,
81280
+ courseName: runtimeCourseName,
81252
81281
  studentEmail: activityData.studentEmail,
81253
81282
  courseTotalXp: integration.totalXp,
81254
81283
  ...runId ? { runId } : {}
@@ -81257,7 +81286,7 @@ var init_timeback_service = __esm(async () => {
81257
81286
  const sessionEndInactiveSeconds = sessionTimingData?.inactiveSeconds;
81258
81287
  const sessionEndEmitted = sessionEndActiveSeconds > 0 || (sessionEndInactiveSeconds ?? 0) > 0;
81259
81288
  if (sessionEndEmitted) {
81260
- await client.activity.session(integration.courseId, studentId, {
81289
+ await client.activity.session(runtimeCourseId, studentId, {
81261
81290
  gameId,
81262
81291
  activeTimeSeconds: sessionEndActiveSeconds,
81263
81292
  ...sessionEndInactiveSeconds !== undefined ? { inactiveTimeSeconds: sessionEndInactiveSeconds } : {},
@@ -81266,15 +81295,15 @@ var init_timeback_service = __esm(async () => {
81266
81295
  subject: activityData.subject,
81267
81296
  appName: activityData.appName,
81268
81297
  sensorUrl: activityData.sensorUrl,
81269
- courseId: activityData.courseId,
81270
- courseName: activityData.courseName,
81298
+ courseId: runtimeCourseId,
81299
+ courseName: runtimeCourseName,
81271
81300
  studentEmail: activityData.studentEmail,
81272
81301
  extensions: extensionsWithResumeId,
81273
81302
  ...runId ? { runId } : {}
81274
81303
  });
81275
81304
  }
81276
81305
  setAttributes({
81277
- "app.timeback.course_id": integration.courseId,
81306
+ ...courseAttributes,
81278
81307
  "app.timeback.grade": activityData.grade,
81279
81308
  "app.timeback.subject": activityData.subject,
81280
81309
  "app.timeback.total_questions": scoreData.totalQuestions,
@@ -81291,7 +81320,7 @@ var init_timeback_service = __esm(async () => {
81291
81320
  });
81292
81321
  return {
81293
81322
  status: "ok",
81294
- courseId: integration.courseId,
81323
+ courseId: runtimeCourseId,
81295
81324
  xpAwarded: result.xpAwarded,
81296
81325
  masteredUnits: result.masteredUnitsApplied,
81297
81326
  pctCompleteApp: result.pctCompleteApp,
@@ -81502,10 +81531,15 @@ var init_timeback_service = __esm(async () => {
81502
81531
  if (!integration) {
81503
81532
  throw new NotFoundError(`Timeback integration for game (grade ${activityData.grade}, subject ${activityData.subject})`);
81504
81533
  }
81534
+ const {
81535
+ courseId: runtimeCourseId,
81536
+ courseName: runtimeCourseName,
81537
+ attributes: courseAttributes
81538
+ } = TimebackService.resolveRuntimeCourse(integration.courseId, activityData.courseId, activityData.courseName);
81505
81539
  const activeTimeSeconds = timingData.activeMs / 1000;
81506
81540
  const inactiveTimeSeconds = timingData.pausedMs / 1000;
81507
81541
  if (activeTimeSeconds > 0 || inactiveTimeSeconds > 0) {
81508
- await client.activity.session(integration.courseId, studentId, {
81542
+ await client.activity.session(runtimeCourseId, studentId, {
81509
81543
  gameId,
81510
81544
  activeTimeSeconds,
81511
81545
  ...inactiveTimeSeconds > 0 ? { inactiveTimeSeconds } : {},
@@ -81514,15 +81548,15 @@ var init_timeback_service = __esm(async () => {
81514
81548
  subject: activityData.subject,
81515
81549
  appName: activityData.appName,
81516
81550
  sensorUrl: activityData.sensorUrl,
81517
- courseId: activityData.courseId,
81518
- courseName: activityData.courseName,
81551
+ courseId: runtimeCourseId,
81552
+ courseName: runtimeCourseName,
81519
81553
  studentEmail: activityData.studentEmail,
81520
81554
  extensions: TimebackService.addResumeIdToExtensions(undefined, effectiveResumeId),
81521
81555
  ...runId ? { runId } : {}
81522
81556
  });
81523
81557
  }
81524
81558
  setAttributes({
81525
- "app.timeback.course_id": integration.courseId,
81559
+ ...courseAttributes,
81526
81560
  "app.timeback.grade": activityData.grade,
81527
81561
  "app.timeback.subject": activityData.subject,
81528
81562
  "app.timeback.active_time_seconds": activeTimeSeconds,
@@ -81567,13 +81601,24 @@ var init_timeback_service = __esm(async () => {
81567
81601
  };
81568
81602
  }
81569
81603
  }
81604
+ const timezone2 = await this.getLearningTimeZoneForStudent(timebackId);
81570
81605
  const result = await client.analytics.getXp(timebackId, {
81571
81606
  courseIds: courseIds.length > 0 ? courseIds : undefined,
81607
+ timezone: timezone2,
81572
81608
  include: options?.include
81573
81609
  });
81574
81610
  return result;
81575
81611
  });
81576
81612
  }
81613
+ async getLearningTimeZoneForStudent(timebackId) {
81614
+ const user = await this.deps.db.query.users.findFirst({
81615
+ where: eq(users.timebackId, timebackId),
81616
+ columns: {
81617
+ learningTimeZone: true
81618
+ }
81619
+ });
81620
+ return user?.learningTimeZone ?? PLATFORM_TIMEZONE;
81621
+ }
81577
81622
  async getStudentMastery(timebackId, user, options) {
81578
81623
  return this.withClientTelemetry(async () => {
81579
81624
  const client = this.requireClient();
@@ -82355,13 +82400,28 @@ var init_session_service = __esm(() => {
82355
82400
  init_errors();
82356
82401
  });
82357
82402
 
82403
+ // ../api-core/src/utils/timezone.util.ts
82404
+ function normalizeIanaTimeZone(value) {
82405
+ const timeZone = value?.trim();
82406
+ if (!timeZone || timeZone.length > MAX_IANA_TIME_ZONE_LENGTH) {
82407
+ return;
82408
+ }
82409
+ try {
82410
+ new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date);
82411
+ return timeZone;
82412
+ } catch {
82413
+ return;
82414
+ }
82415
+ }
82416
+ var MAX_IANA_TIME_ZONE_LENGTH = 100;
82417
+
82358
82418
  // ../api-core/src/services/user.service.ts
82359
82419
  class UserService {
82360
82420
  deps;
82361
82421
  constructor(deps) {
82362
82422
  this.deps = deps;
82363
82423
  }
82364
- async getMe(user, gameId) {
82424
+ async getMe(user, gameId, observedTimeZone) {
82365
82425
  const db2 = this.deps.db;
82366
82426
  const userData = await db2.query.users.findFirst({
82367
82427
  where: eq(users.id, user.id)
@@ -82374,6 +82434,7 @@ class UserService {
82374
82434
  "app.user.has_timeback_account": Boolean(userData.timebackId),
82375
82435
  "app.user.timeback_enriched": false
82376
82436
  });
82437
+ const localDay = await this.resolveLocalDay(userData, observedTimeZone);
82377
82438
  const timeback2 = userData.timebackId ? await this.fetchTimebackData(userData.timebackId, gameId) : undefined;
82378
82439
  setAttribute("app.user.timeback_enriched", Boolean(timeback2));
82379
82440
  if (gameId) {
@@ -82383,6 +82444,7 @@ class UserService {
82383
82444
  role: userData.role,
82384
82445
  username: userData.username,
82385
82446
  email: userData.email,
82447
+ localDay,
82386
82448
  timeback: timeback2
82387
82449
  };
82388
82450
  }
@@ -82402,6 +82464,7 @@ class UserService {
82402
82464
  createdAt: userData.createdAt,
82403
82465
  updatedAt: userData.updatedAt,
82404
82466
  hasTimebackAccount: Boolean(timebackAccount),
82467
+ localDay,
82405
82468
  timeback: timeback2
82406
82469
  };
82407
82470
  }
@@ -82439,6 +82502,44 @@ class UserService {
82439
82502
  isDefault: updatedUser.name === DEMO_DISPLAY_NAME_PLACEHOLDER
82440
82503
  };
82441
82504
  }
82505
+ async resolveLocalDay(userData, observedTimeZone) {
82506
+ const normalizedTimeZone = normalizeIanaTimeZone(observedTimeZone);
82507
+ if (normalizedTimeZone) {
82508
+ const observedAt = new Date;
82509
+ const shouldPersist = userData.learningTimeZone !== normalizedTimeZone || !userData.learningTimeZoneUpdatedAt || observedAt.getTime() - userData.learningTimeZoneUpdatedAt.getTime() >= LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS;
82510
+ if (shouldPersist) {
82511
+ await this.deps.db.update(users).set({
82512
+ learningTimeZone: normalizedTimeZone,
82513
+ learningTimeZoneUpdatedAt: observedAt
82514
+ }).where(eq(users.id, userData.id));
82515
+ }
82516
+ setAttributes({
82517
+ "app.user.learning_time_zone_observed": true,
82518
+ "app.user.learning_time_zone_persisted": shouldPersist
82519
+ });
82520
+ return {
82521
+ timeZone: normalizedTimeZone,
82522
+ source: "browser_last_seen",
82523
+ observedAt: (shouldPersist ? observedAt : userData.learningTimeZoneUpdatedAt)?.toISOString() ?? null
82524
+ };
82525
+ }
82526
+ setAttributes({
82527
+ "app.user.learning_time_zone_observed": false,
82528
+ "app.user.learning_time_zone_persisted": false
82529
+ });
82530
+ if (userData.learningTimeZone) {
82531
+ return {
82532
+ timeZone: userData.learningTimeZone,
82533
+ source: "browser_last_seen",
82534
+ observedAt: userData.learningTimeZoneUpdatedAt?.toISOString() ?? null
82535
+ };
82536
+ }
82537
+ return {
82538
+ timeZone: PLATFORM_TIMEZONE,
82539
+ source: "platform_default",
82540
+ observedAt: null
82541
+ };
82542
+ }
82442
82543
  async fetchTimebackData(timebackId, gameId) {
82443
82544
  const [{ role, organizations: allOrganizations }, allEnrollments] = await Promise.all([
82444
82545
  withSpan("timeback.fetch_profile", () => this.fetchStudentProfile(timebackId)),
@@ -82520,6 +82621,7 @@ class UserService {
82520
82621
  return organizations.filter((o) => enrollmentOrgIds.has(o.id));
82521
82622
  }
82522
82623
  }
82624
+ var LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS;
82523
82625
  var init_user_service = __esm(() => {
82524
82626
  init_drizzle_orm();
82525
82627
  init_src();
@@ -82528,6 +82630,7 @@ var init_user_service = __esm(() => {
82528
82630
  init_spans();
82529
82631
  init_errors();
82530
82632
  init_timeback_util();
82633
+ LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
82531
82634
  });
82532
82635
 
82533
82636
  // ../api-core/src/services/verify.service.ts
@@ -140158,9 +140261,10 @@ var init_upload_controller = __esm(() => {
140158
140261
  // ../api-core/src/controllers/user.controller.ts
140159
140262
  var getMe, getDemoProfile, updateDemoProfile, users2;
140160
140263
  var init_user_controller = __esm(() => {
140264
+ init_src();
140161
140265
  init_schemas_index();
140162
140266
  init_utils11();
140163
- getMe = requireNonAnonymous(async (ctx) => ctx.services.user.getMe(ctx.user, ctx.gameId));
140267
+ getMe = requireNonAnonymous(async (ctx) => ctx.services.user.getMe(ctx.user, ctx.gameId, ctx.request.headers.get(PLAYCADEMY_BROWSER_TIME_ZONE_HEADER)));
140164
140268
  getDemoProfile = requireAnonymous(async (ctx) => ctx.services.user.getDemoProfile(ctx.user.id));
140165
140269
  updateDemoProfile = requireAnonymous(async (ctx) => {
140166
140270
  const body2 = await parseRequestBody(ctx.request, DemoProfileSchema);
@@ -140315,8 +140419,39 @@ function getMockHighestGradeMastered(enrollments, subject) {
140315
140419
  const subjectGrades = enrollments.filter((enrollment) => enrollment.subject === subject).map((enrollment) => enrollment.grade);
140316
140420
  return subjectGrades.length > 0 ? Math.max(...subjectGrades) : null;
140317
140421
  }
140318
- async function buildMockUserResponse(db2, user, gameId) {
140422
+ async function buildLocalDayContext(db2, user, observedTimeZone) {
140423
+ const normalizedTimeZone = normalizeIanaTimeZone(observedTimeZone);
140424
+ if (normalizedTimeZone) {
140425
+ const observedAt = new Date;
140426
+ const shouldPersist = user.learningTimeZone !== normalizedTimeZone || !user.learningTimeZoneUpdatedAt || observedAt.getTime() - user.learningTimeZoneUpdatedAt.getTime() >= LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS2;
140427
+ if (shouldPersist) {
140428
+ await db2.update(users).set({
140429
+ learningTimeZone: normalizedTimeZone,
140430
+ learningTimeZoneUpdatedAt: observedAt
140431
+ }).where(eq(users.id, user.id));
140432
+ }
140433
+ return {
140434
+ timeZone: normalizedTimeZone,
140435
+ source: "browser_last_seen",
140436
+ observedAt: (shouldPersist ? observedAt : user.learningTimeZoneUpdatedAt)?.toISOString() ?? null
140437
+ };
140438
+ }
140439
+ if (user.learningTimeZone) {
140440
+ return {
140441
+ timeZone: user.learningTimeZone,
140442
+ source: "browser_last_seen",
140443
+ observedAt: user.learningTimeZoneUpdatedAt?.toISOString() ?? null
140444
+ };
140445
+ }
140446
+ return {
140447
+ timeZone: PLATFORM_TIMEZONE,
140448
+ source: "platform_default",
140449
+ observedAt: null
140450
+ };
140451
+ }
140452
+ async function buildMockUserResponse(db2, user, gameId, observedTimeZone) {
140319
140453
  const timeback3 = user.timebackId ? await getMockTimebackData(db2, user.timebackId, gameId) : undefined;
140454
+ const localDay = await buildLocalDayContext(db2, user, observedTimeZone);
140320
140455
  if (gameId) {
140321
140456
  return {
140322
140457
  id: user.id,
@@ -140324,6 +140459,7 @@ async function buildMockUserResponse(db2, user, gameId) {
140324
140459
  role: user.role,
140325
140460
  username: user.username,
140326
140461
  email: user.email,
140462
+ localDay,
140327
140463
  timeback: timeback3
140328
140464
  };
140329
140465
  }
@@ -140342,15 +140478,19 @@ async function buildMockUserResponse(db2, user, gameId) {
140342
140478
  createdAt: user.createdAt,
140343
140479
  updatedAt: user.updatedAt,
140344
140480
  hasTimebackAccount: Boolean(timebackAccount),
140481
+ localDay,
140345
140482
  timeback: timeback3
140346
140483
  };
140347
140484
  }
140485
+ var LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS2;
140348
140486
  var init_timeback6 = __esm(() => {
140349
140487
  init_drizzle_orm();
140350
140488
  init_utils11();
140489
+ init_src();
140351
140490
  init_tables_index();
140352
140491
  init_src2();
140353
140492
  init_config();
140493
+ LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS2 = 24 * 60 * 60 * 1000;
140354
140494
  });
140355
140495
 
140356
140496
  // src/routes/platform/users.ts
@@ -140360,6 +140500,7 @@ var init_users = __esm(async () => {
140360
140500
  init_dist7();
140361
140501
  init_controllers();
140362
140502
  init_errors();
140503
+ init_src();
140363
140504
  init_tables_index();
140364
140505
  init_error_handler();
140365
140506
  init_timeback6();
@@ -140381,7 +140522,7 @@ var init_users = __esm(async () => {
140381
140522
  const error89 = ApiError.notFound("User not found");
140382
140523
  return c2.json(createErrorResponse(error89), error89.status);
140383
140524
  }
140384
- const response = await buildMockUserResponse(db2, userData, gameId);
140525
+ const response = await buildMockUserResponse(db2, userData, gameId, c2.req.header(PLAYCADEMY_BROWSER_TIME_ZONE_HEADER));
140385
140526
  return c2.json(response);
140386
140527
  }
140387
140528
  return handle2(users2.getMe)(c2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.6.0",
3
+ "version": "0.6.1-beta.2",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {