@playcademy/sandbox 0.11.1-beta.1 → 0.11.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/README.md CHANGED
@@ -199,17 +199,18 @@ bunx @playcademy/sandbox \
199
199
  --timeback-student-id your-student-sourcedId
200
200
  ```
201
201
 
202
- | Option | Description |
203
- | :------------------------------- | :----------------------------------------------------- |
204
- | `--timeback-local` | Enable local TimeBack mode (Docker) |
205
- | `--timeback-oneroster-url <url>` | OneRoster API URL |
206
- | `--timeback-caliper-url <url>` | Caliper API URL |
207
- | `--timeback-course-id <id>` | Course ID for enrollments |
208
- | `--timeback-student-id <id>` | Student ID to link to demo user |
209
- | `--timeback-role <role>` | User role (student, parent, teacher, administrator) |
210
- | `--timeback-org-id <id>` | Organization ID |
211
- | `--timeback-org-name <name>` | Organization display name |
212
- | `--timeback-org-type <type>` | Organization type (school, district, department, etc.) |
202
+ | Option | Description |
203
+ | :-------------------------------------- | :-------------------------------------------------------------------------------- |
204
+ | `--timeback-local` | Enable local TimeBack mode (Docker) |
205
+ | `--timeback-oneroster-url <url>` | OneRoster API URL |
206
+ | `--timeback-caliper-url <url>` | Caliper API URL |
207
+ | `--timeback-course-id <id>` | Course ID for enrollments |
208
+ | `--timeback-student-id <id>` | Student ID to link to demo user |
209
+ | `--timeback-role <role>` | User role (student, parent, teacher, administrator) |
210
+ | `--timeback-org-id <id>` | Organization ID |
211
+ | `--timeback-org-name <name>` | Organization display name |
212
+ | `--timeback-org-type <type>` | Organization type (school, district, department, etc.) |
213
+ | `--timeback-excluded-courses <courses>` | Comma-separated courses to exclude from enrollment (e.g. `FastMath:3,FastMath:4`) |
213
214
 
214
215
  ### Environment Variables
215
216
 
@@ -262,15 +263,34 @@ The sandbox provides these TimeBack endpoints (matching production platform):
262
263
  **Runtime:**
263
264
 
264
265
  - `POST /api/timeback/end-activity` - Submit activity results
265
-
266
- **XP Queries:**
267
-
268
- - `GET /api/timeback/xp/today` - Get today's XP
269
- - `PUT /api/timeback/xp/today` - Update today's XP
270
- - `GET /api/timeback/xp/total` - Get total XP
271
- - `GET /api/timeback/xp/history` - Get XP history
272
-
273
- These use the same api-core handlers as the production platform, ensuring consistency.
266
+ - `POST /api/timeback/heartbeat` - Record incremental activity heartbeat
267
+ - `POST /api/timeback/advance-course` - Advance a student to the next-grade course
268
+ - `POST /api/timeback/unenroll-course` - Unenroll a student from their active course
269
+ - `POST /api/timeback/populate-student` - No-op in the sandbox (always returns `no_record`)
270
+
271
+ **Assessments:**
272
+
273
+ - `POST /api/timeback/assessments/prepare` - Prepare the next assessment attempt
274
+ - `POST /api/timeback/assessments/start` - Start a new assessment attempt
275
+ - `GET /api/timeback/assessments/latest` - Get the user's latest attempt
276
+ - `GET /api/timeback/assessments/:attemptId` - Get a specific attempt
277
+ - `POST /api/timeback/assessments/:attemptId/resume` - Resume an attempt
278
+ - `POST /api/timeback/assessments/:attemptId/progress` - Record mastery progress
279
+ - `POST /api/timeback/assessments/:attemptId/save` - Checkpoint an in-progress attempt
280
+ - `POST /api/timeback/assessments/:attemptId/submit-item` - Score a single item
281
+ - `POST /api/timeback/assessments/:attemptId/submit` - Submit an attempt
282
+ - `POST /api/timeback/assessments/:attemptId/finalize` - Record XP and publish completion
283
+
284
+ **Student Data:**
285
+
286
+ - `GET /api/timeback/user` - Get the current user's TimeBack data
287
+ - `GET /api/timeback/user/enrollments` - Get the current user's enrollments
288
+ - `GET /api/timeback/user/:timebackId` - Get a student's TimeBack data
289
+ - `GET /api/timeback/student-xp/:timebackId` - Get a student's XP
290
+ - `GET /api/timeback/student-mastery/:timebackId` - Get a student's mastery
291
+ - `GET /api/timeback/student-highest-grade-mastered/:timebackId` - Get a student's highest grade mastered
292
+
293
+ These use the same api-core handlers as the production platform, ensuring consistency. The student data routes return mock data when TimeBack runs in mock mode.
274
294
 
275
295
  When properly configured, your game can call `client.timeback.endActivity()` during local development and see real events appear in your TimeBack dashboard.
276
296
 
package/dist/cli.js CHANGED
@@ -1142,7 +1142,7 @@ var package_default;
1142
1142
  var init_package = __esm(() => {
1143
1143
  package_default = {
1144
1144
  name: "@playcademy/sandbox",
1145
- version: "0.11.1-beta.1",
1145
+ version: "0.11.1-beta.2",
1146
1146
  description: "Local development server for Playcademy game development",
1147
1147
  type: "module",
1148
1148
  exports: {
@@ -110367,12 +110367,13 @@ class LeaderboardService {
110367
110367
  eq(gameScores.gameId, gameId),
110368
110368
  eq(users.isAnonymous, isAnonymousUser)
110369
110369
  ];
110370
- const bestScoresSubquery = db2.select({
110370
+ const bestScoresSubquery = db2.selectDistinctOn([gameScores.userId, gameScores.gameId], {
110371
110371
  userId: gameScores.userId,
110372
110372
  gameId: gameScores.gameId,
110373
- score: sql`MAX(${gameScores.score})`.as("max_score"),
110374
- achievedAt: sql`MAX(${gameScores.achievedAt})`.as("max_achieved_at")
110375
- }).from(gameScores).innerJoin(users, eq(gameScores.userId, users.id)).where(and(...conditions2)).groupBy(gameScores.userId, gameScores.gameId).as("best_scores");
110373
+ score: gameScores.score,
110374
+ achievedAt: gameScores.achievedAt,
110375
+ metadata: gameScores.metadata
110376
+ }).from(gameScores).innerJoin(users, eq(gameScores.userId, users.id)).where(and(...conditions2)).orderBy(gameScores.userId, gameScores.gameId, desc(gameScores.score), asc(gameScores.achievedAt)).as("best_scores");
110376
110377
  const scores = await db2.select({
110377
110378
  userId: users.id,
110378
110379
  username: users.username,
@@ -110380,11 +110381,11 @@ class LeaderboardService {
110380
110381
  userImage: users.image,
110381
110382
  score: bestScoresSubquery.score,
110382
110383
  achievedAt: bestScoresSubquery.achievedAt,
110383
- metadata: gameScores.metadata,
110384
+ metadata: bestScoresSubquery.metadata,
110384
110385
  gameId: games.id,
110385
110386
  gameTitle: games.displayName,
110386
110387
  gameSlug: games.slug
110387
- }).from(bestScoresSubquery).innerJoin(users, eq(bestScoresSubquery.userId, users.id)).innerJoin(games, eq(bestScoresSubquery.gameId, games.id)).innerJoin(gameScores, and(eq(gameScores.userId, bestScoresSubquery.userId), eq(gameScores.gameId, bestScoresSubquery.gameId), eq(gameScores.score, bestScoresSubquery.score))).orderBy(desc(bestScoresSubquery.score)).limit(limit).offset(offset);
110388
+ }).from(bestScoresSubquery).innerJoin(users, eq(bestScoresSubquery.userId, users.id)).innerJoin(games, eq(bestScoresSubquery.gameId, games.id)).orderBy(desc(bestScoresSubquery.score), asc(bestScoresSubquery.achievedAt), asc(bestScoresSubquery.userId)).limit(limit).offset(offset);
110388
110389
  setAttributes({
110389
110390
  "app.leaderboard.result_count": scores.length,
110390
110391
  "app.leaderboard.no_result": scores.length === 0
@@ -110624,7 +110625,7 @@ class UserService {
110624
110625
  "app.user.timeback_enriched": false
110625
110626
  });
110626
110627
  const localDay = await this.resolveLocalDay(userData, observedTimeZone);
110627
- const timeback2 = userData.timebackId ? await this.fetchTimebackData(userData.timebackId, gameId) : undefined;
110628
+ const timeback2 = userData.timebackId ? await this.fetchTimebackProfile(userData.timebackId) : undefined;
110628
110629
  setAttribute("app.user.timeback_enriched", Boolean(timeback2));
110629
110630
  if (gameId) {
110630
110631
  return {
@@ -110729,18 +110730,10 @@ class UserService {
110729
110730
  observedAt: null
110730
110731
  };
110731
110732
  }
110732
- async fetchTimebackData(timebackId, gameId) {
110733
- const [{ role, organizations: allOrganizations }, allEnrollments] = await Promise.all([
110734
- withSpan("timeback.fetch_profile", () => this.fetchStudentProfile(timebackId)),
110735
- withSpan("timeback.fetch_enrollments", () => this.fetchEnrollments(timebackId))
110736
- ]);
110737
- const enrollments = gameId ? filterEnrollmentsByGame(allEnrollments, gameId) : allEnrollments;
110738
- const organizations = gameId ? this.filterOrganizationsByEnrollments(allOrganizations, enrollments) : allOrganizations;
110739
- setAttributes({
110740
- "app.timeback.enrollment_count": enrollments.length,
110741
- "app.timeback.organization_count": organizations.length
110742
- });
110743
- return { id: timebackId, role, enrollments, organizations };
110733
+ async fetchTimebackProfile(timebackId) {
110734
+ const { role, organizations } = await withSpan("timeback.fetch_profile", () => this.fetchStudentProfile(timebackId));
110735
+ setAttribute("app.timeback.organization_count", organizations.length);
110736
+ return { id: timebackId, role, organizations };
110744
110737
  }
110745
110738
  async fetchStudentProfile(timebackId) {
110746
110739
  if (!this.deps.timeback) {
@@ -110779,46 +110772,14 @@ class UserService {
110779
110772
  return { role: "student", organizations: [] };
110780
110773
  }
110781
110774
  }
110782
- async fetchEnrollments(timebackId) {
110783
- if (!this.deps.timeback) {
110784
- setAttribute("app.timeback.client", "unavailable");
110785
- return [];
110786
- }
110787
- try {
110788
- const enrollments = await this.deps.timeback.roster.getEnrollments(timebackId);
110789
- const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
110790
- if (courseIds.length === 0) {
110791
- return [];
110792
- }
110793
- const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
110794
- where: and(inArray(gameTimebackIntegrations.courseId, courseIds), isActiveGameTimebackIntegrationStatus())
110795
- });
110796
- return mapEnrollmentsToUserEnrollments(enrollments, integrations);
110797
- } catch (error88) {
110798
- addEvent("timeback.enrollment_fetch_failed", {
110799
- "exception.type": errorType(error88),
110800
- "app.error.message": errorMessage(error88)
110801
- });
110802
- return [];
110803
- }
110804
- }
110805
- filterOrganizationsByEnrollments(organizations, enrollments) {
110806
- const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
110807
- if (enrollmentOrgIds.size === 0) {
110808
- return [];
110809
- }
110810
- return organizations.filter((o) => enrollmentOrgIds.has(o.id));
110811
- }
110812
110775
  }
110813
110776
  var LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS;
110814
110777
  var init_user_service = __esm(() => {
110815
110778
  init_drizzle_orm();
110816
110779
  init_src();
110817
- init_helpers_index();
110818
110780
  init_tables_index();
110819
110781
  init_spans();
110820
110782
  init_errors2();
110821
- init_timeback_util();
110822
110783
  LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
110823
110784
  });
110824
110785
 
@@ -169347,8 +169308,12 @@ async function buildLocalDayContext(db2, user, observedTimeZone) {
169347
169308
  observedAt: null
169348
169309
  };
169349
169310
  }
169311
+ function getMockTimebackProfile(timebackId) {
169312
+ const { role, organizations } = getMockStudentProfile();
169313
+ return { id: timebackId, role, organizations };
169314
+ }
169350
169315
  async function buildMockUserResponse(db2, user, gameId, observedTimeZone) {
169351
- const timeback3 = user.timebackId ? await getMockTimebackData(db2, user.timebackId, gameId) : undefined;
169316
+ const timeback3 = user.timebackId ? getMockTimebackProfile(user.timebackId) : undefined;
169352
169317
  const localDay = await buildLocalDayContext(db2, user, observedTimeZone);
169353
169318
  if (gameId) {
169354
169319
  return {
@@ -32,7 +32,7 @@ export declare function getMockTimebackData(db: DatabaseInstance, timebackId: st
32
32
  export declare function getMockTimebackUser(db: DatabaseInstance, gameId?: string): Promise<UserTimebackData>;
33
33
  export declare function getMockHighestGradeMastered(enrollments: UserEnrollment[], subject: string): number | null;
34
34
  /**
35
- * Build a complete user response with mock timeback data.
35
+ * Build a complete user response with mock timeback identity.
36
36
  * Used to bypass api-core when in mock mode (avoids real API calls).
37
37
  */
38
38
  export declare function buildMockUserResponse(db: DatabaseInstance, user: User, gameId?: string, observedTimeZone?: string | null): Promise<AuthenticatedUser | GameUser>;
package/dist/server.js CHANGED
@@ -1141,7 +1141,7 @@ var package_default;
1141
1141
  var init_package = __esm(() => {
1142
1142
  package_default = {
1143
1143
  name: "@playcademy/sandbox",
1144
- version: "0.11.1-beta.1",
1144
+ version: "0.11.1-beta.2",
1145
1145
  description: "Local development server for Playcademy game development",
1146
1146
  type: "module",
1147
1147
  exports: {
@@ -110366,12 +110366,13 @@ class LeaderboardService {
110366
110366
  eq(gameScores.gameId, gameId),
110367
110367
  eq(users.isAnonymous, isAnonymousUser)
110368
110368
  ];
110369
- const bestScoresSubquery = db2.select({
110369
+ const bestScoresSubquery = db2.selectDistinctOn([gameScores.userId, gameScores.gameId], {
110370
110370
  userId: gameScores.userId,
110371
110371
  gameId: gameScores.gameId,
110372
- score: sql`MAX(${gameScores.score})`.as("max_score"),
110373
- achievedAt: sql`MAX(${gameScores.achievedAt})`.as("max_achieved_at")
110374
- }).from(gameScores).innerJoin(users, eq(gameScores.userId, users.id)).where(and(...conditions2)).groupBy(gameScores.userId, gameScores.gameId).as("best_scores");
110372
+ score: gameScores.score,
110373
+ achievedAt: gameScores.achievedAt,
110374
+ metadata: gameScores.metadata
110375
+ }).from(gameScores).innerJoin(users, eq(gameScores.userId, users.id)).where(and(...conditions2)).orderBy(gameScores.userId, gameScores.gameId, desc(gameScores.score), asc(gameScores.achievedAt)).as("best_scores");
110375
110376
  const scores = await db2.select({
110376
110377
  userId: users.id,
110377
110378
  username: users.username,
@@ -110379,11 +110380,11 @@ class LeaderboardService {
110379
110380
  userImage: users.image,
110380
110381
  score: bestScoresSubquery.score,
110381
110382
  achievedAt: bestScoresSubquery.achievedAt,
110382
- metadata: gameScores.metadata,
110383
+ metadata: bestScoresSubquery.metadata,
110383
110384
  gameId: games.id,
110384
110385
  gameTitle: games.displayName,
110385
110386
  gameSlug: games.slug
110386
- }).from(bestScoresSubquery).innerJoin(users, eq(bestScoresSubquery.userId, users.id)).innerJoin(games, eq(bestScoresSubquery.gameId, games.id)).innerJoin(gameScores, and(eq(gameScores.userId, bestScoresSubquery.userId), eq(gameScores.gameId, bestScoresSubquery.gameId), eq(gameScores.score, bestScoresSubquery.score))).orderBy(desc(bestScoresSubquery.score)).limit(limit).offset(offset);
110387
+ }).from(bestScoresSubquery).innerJoin(users, eq(bestScoresSubquery.userId, users.id)).innerJoin(games, eq(bestScoresSubquery.gameId, games.id)).orderBy(desc(bestScoresSubquery.score), asc(bestScoresSubquery.achievedAt), asc(bestScoresSubquery.userId)).limit(limit).offset(offset);
110387
110388
  setAttributes({
110388
110389
  "app.leaderboard.result_count": scores.length,
110389
110390
  "app.leaderboard.no_result": scores.length === 0
@@ -110623,7 +110624,7 @@ class UserService {
110623
110624
  "app.user.timeback_enriched": false
110624
110625
  });
110625
110626
  const localDay = await this.resolveLocalDay(userData, observedTimeZone);
110626
- const timeback2 = userData.timebackId ? await this.fetchTimebackData(userData.timebackId, gameId) : undefined;
110627
+ const timeback2 = userData.timebackId ? await this.fetchTimebackProfile(userData.timebackId) : undefined;
110627
110628
  setAttribute("app.user.timeback_enriched", Boolean(timeback2));
110628
110629
  if (gameId) {
110629
110630
  return {
@@ -110728,18 +110729,10 @@ class UserService {
110728
110729
  observedAt: null
110729
110730
  };
110730
110731
  }
110731
- async fetchTimebackData(timebackId, gameId) {
110732
- const [{ role, organizations: allOrganizations }, allEnrollments] = await Promise.all([
110733
- withSpan("timeback.fetch_profile", () => this.fetchStudentProfile(timebackId)),
110734
- withSpan("timeback.fetch_enrollments", () => this.fetchEnrollments(timebackId))
110735
- ]);
110736
- const enrollments = gameId ? filterEnrollmentsByGame(allEnrollments, gameId) : allEnrollments;
110737
- const organizations = gameId ? this.filterOrganizationsByEnrollments(allOrganizations, enrollments) : allOrganizations;
110738
- setAttributes({
110739
- "app.timeback.enrollment_count": enrollments.length,
110740
- "app.timeback.organization_count": organizations.length
110741
- });
110742
- return { id: timebackId, role, enrollments, organizations };
110732
+ async fetchTimebackProfile(timebackId) {
110733
+ const { role, organizations } = await withSpan("timeback.fetch_profile", () => this.fetchStudentProfile(timebackId));
110734
+ setAttribute("app.timeback.organization_count", organizations.length);
110735
+ return { id: timebackId, role, organizations };
110743
110736
  }
110744
110737
  async fetchStudentProfile(timebackId) {
110745
110738
  if (!this.deps.timeback) {
@@ -110778,46 +110771,14 @@ class UserService {
110778
110771
  return { role: "student", organizations: [] };
110779
110772
  }
110780
110773
  }
110781
- async fetchEnrollments(timebackId) {
110782
- if (!this.deps.timeback) {
110783
- setAttribute("app.timeback.client", "unavailable");
110784
- return [];
110785
- }
110786
- try {
110787
- const enrollments = await this.deps.timeback.roster.getEnrollments(timebackId);
110788
- const courseIds = enrollments.map((e) => e.courseId).filter((id) => Boolean(id));
110789
- if (courseIds.length === 0) {
110790
- return [];
110791
- }
110792
- const integrations = await this.deps.db.query.gameTimebackIntegrations.findMany({
110793
- where: and(inArray(gameTimebackIntegrations.courseId, courseIds), isActiveGameTimebackIntegrationStatus())
110794
- });
110795
- return mapEnrollmentsToUserEnrollments(enrollments, integrations);
110796
- } catch (error88) {
110797
- addEvent("timeback.enrollment_fetch_failed", {
110798
- "exception.type": errorType(error88),
110799
- "app.error.message": errorMessage(error88)
110800
- });
110801
- return [];
110802
- }
110803
- }
110804
- filterOrganizationsByEnrollments(organizations, enrollments) {
110805
- const enrollmentOrgIds = new Set(enrollments.map((e) => e.orgId).filter(Boolean));
110806
- if (enrollmentOrgIds.size === 0) {
110807
- return [];
110808
- }
110809
- return organizations.filter((o) => enrollmentOrgIds.has(o.id));
110810
- }
110811
110774
  }
110812
110775
  var LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS;
110813
110776
  var init_user_service = __esm(() => {
110814
110777
  init_drizzle_orm();
110815
110778
  init_src();
110816
- init_helpers_index();
110817
110779
  init_tables_index();
110818
110780
  init_spans();
110819
110781
  init_errors2();
110820
- init_timeback_util();
110821
110782
  LEARNING_TIME_ZONE_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
110822
110783
  });
110823
110784
 
@@ -169346,8 +169307,12 @@ async function buildLocalDayContext(db2, user, observedTimeZone) {
169346
169307
  observedAt: null
169347
169308
  };
169348
169309
  }
169310
+ function getMockTimebackProfile(timebackId) {
169311
+ const { role, organizations } = getMockStudentProfile();
169312
+ return { id: timebackId, role, organizations };
169313
+ }
169349
169314
  async function buildMockUserResponse(db2, user, gameId, observedTimeZone) {
169350
- const timeback3 = user.timebackId ? await getMockTimebackData(db2, user.timebackId, gameId) : undefined;
169315
+ const timeback3 = user.timebackId ? getMockTimebackProfile(user.timebackId) : undefined;
169351
169316
  const localDay = await buildLocalDayContext(db2, user, observedTimeZone);
169352
169317
  if (gameId) {
169353
169318
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sandbox",
3
- "version": "0.11.1-beta.1",
3
+ "version": "0.11.1-beta.2",
4
4
  "description": "Local development server for Playcademy game development",
5
5
  "type": "module",
6
6
  "exports": {