@playcademy/sdk 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/README.md CHANGED
@@ -240,6 +240,23 @@ client.timeback.startActivity({
240
240
  })
241
241
  // Auto-derived: activityName "Math Quiz Level 1"
242
242
  // Auto-filled by backend: appName, subject, sensorUrl
243
+ // Automatically pauses active time when the tab is hidden, the shell pauses,
244
+ // or the player has no visible keyboard/mouse activity for 10 minutes
245
+
246
+ // Customize inactivity handling
247
+ client.timeback.startActivity(
248
+ { activityId: 'math-quiz-level-1' },
249
+ {
250
+ inactivityTimeoutMs: 5 * 60 * 1000, // 5 minutes
251
+ heartbeatIntervalMs: 15_000,
252
+ },
253
+ )
254
+
255
+ // Disable keyboard/mouse inactivity tracking
256
+ client.timeback.startActivity(
257
+ { activityId: 'math-quiz-level-1' },
258
+ { inactivityTimeoutMs: Infinity },
259
+ )
243
260
 
244
261
  // ... player completes activity ...
245
262
 
@@ -323,12 +340,16 @@ const { token } = await client.realtime.token.get()
323
340
 
324
341
  - `role`: The user's TimeBack role (`'student'`, `'parent'`, `'teacher'`, or `'administrator'`)
325
342
  - `enrollments`: Array of course enrollments with `subject`, `grade`, and `courseId`
326
- - `startActivity(metadata)`: Start tracking an activity (stores start time and metadata)
343
+ - `startActivity(metadata, options?)`: Start tracking an activity (stores start time and metadata)
327
344
  - `metadata.activityId`: Unique activity identifier (required)
328
345
  - `metadata.activityName`: Human-readable activity name
329
346
  - `metadata.subject`: Subject area (Math, Reading, Science, etc.)
330
347
  - `metadata.appName`: Application name
331
348
  - `metadata.sensorUrl`: Sensor URL for tracking
349
+ - Automatically pauses active time when the tab is hidden, the shell pauses the game, or the player is idle while visible
350
+ - `options.pausedHeartbeatTimeoutMs`: Stop periodic heartbeats after a long automatic pause (`hidden` or `inactivity`); `Infinity` keeps them running
351
+ - `options.heartbeatIntervalMs`: Flush incremental heartbeats (`Infinity` disables periodic heartbeats)
352
+ - `options.inactivityTimeoutMs`: Keyboard/mouse idle timeout while visible (defaults to 10 minutes; `Infinity` disables)
332
353
  - `endActivity(scoreData)`: End activity and submit results
333
354
  - `scoreData.correctQuestions`: Number of correct answers
334
355
  - `scoreData.totalQuestions`: Total number of questions
package/dist/index.d.ts CHANGED
@@ -1681,16 +1681,34 @@ declare abstract class PlaycademyBaseClient {
1681
1681
  */
1682
1682
  interface StartActivityOptions {
1683
1683
  /**
1684
- * How long the tab can stay hidden before the timing window resets on return.
1685
- * Defaults to 10 minutes. Set to `Infinity` to disable.
1684
+ * How long heartbeats continue after the activity is automatically paused
1685
+ * because the tab is hidden or the player is inactive while visible.
1686
+ * Defaults to 10 minutes. Set to `Infinity` to keep heartbeats running
1687
+ * indefinitely during automatic pauses. Invalid values fall back to the
1688
+ * 10-minute default.
1689
+ */
1690
+ pausedHeartbeatTimeoutMs?: number;
1691
+ /**
1692
+ * @deprecated Use `pausedHeartbeatTimeoutMs` instead.
1693
+ *
1694
+ * Backward-compatible alias for callers that still use the old option
1695
+ * name from earlier SDK releases.
1686
1696
  */
1687
1697
  hiddenTimeoutMs?: number;
1688
1698
  /**
1689
1699
  * How often to flush periodic heartbeats with accumulated time data.
1690
1700
  * Defaults to 15 seconds. Set to `Infinity` to disable the interval;
1691
- * final unload/endActivity flushes still run.
1701
+ * final unload/endActivity flushes still run. Values must be greater than
1702
+ * 0 or `Infinity`; invalid values fall back to the 15-second default.
1692
1703
  */
1693
1704
  heartbeatIntervalMs?: number;
1705
+ /**
1706
+ * How long the tab can remain visible without keyboard or mouse activity
1707
+ * before the activity is marked inactive. Defaults to 10 minutes. Set to
1708
+ * `Infinity` to disable keyboard/mouse inactivity tracking. Invalid values
1709
+ * fall back to the 10-minute default.
1710
+ */
1711
+ inactivityTimeoutMs?: number;
1694
1712
  /**
1695
1713
  * Stable identifier for this activity run. When provided, it is used on
1696
1714
  * every heartbeat and on endActivity instead of a freshly-generated UUID.
@@ -1772,7 +1790,9 @@ declare class PlaycademyClient extends PlaycademyBaseClient {
1772
1790
  * - `user.fetch()` - Refresh user context from server
1773
1791
  *
1774
1792
  * Activity tracking:
1775
- * - `startActivity(metadata)` - Begin tracking an activity
1793
+ * - `startActivity(metadata)` - Begin tracking an activity with automatic
1794
+ * hidden-tab and visible-tab inactivity handling, plus configurable
1795
+ * paused-heartbeat timeout behavior
1776
1796
  * - `pauseActivity()` / `resumeActivity()` - Pause/resume timer
1777
1797
  * - `endActivity(scoreData)` - Submit activity results to TimeBack
1778
1798
  */
package/dist/index.js CHANGED
@@ -1451,9 +1451,21 @@ function isValidUUID(value) {
1451
1451
  }
1452
1452
 
1453
1453
  // src/core/activity-tracker.ts
1454
- var DEFAULT_HIDDEN_TIMEOUT_MS = 10 * 60 * 1000;
1454
+ var DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000;
1455
1455
  var DEFAULT_HEARTBEAT_INTERVAL_MS = 15000;
1456
+ var DEFAULT_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
1456
1457
  var HEARTBEAT_RETRY_POLICY = { retryableMethods: ["POST"] };
1458
+ var USER_ACTIVITY_EVENTS = ["keydown", "pointerdown", "pointermove", "wheel"];
1459
+ var USER_ACTIVITY_LISTENER_OPTIONS = { capture: true };
1460
+ function normalizeDelayMs(value, defaultValue, allowZero = true) {
1461
+ if (value === Infinity) {
1462
+ return Infinity;
1463
+ }
1464
+ if (typeof value === "number" && Number.isFinite(value) && (allowZero ? value >= 0 : value > 0)) {
1465
+ return value;
1466
+ }
1467
+ return defaultValue;
1468
+ }
1457
1469
  function getCurrentPausedTotal(activity, now = Date.now()) {
1458
1470
  if (activity.pauseStartTime === null) {
1459
1471
  return activity.pausedTime;
@@ -1503,9 +1515,40 @@ function stopHeartbeatInterval(activity) {
1503
1515
  clearInterval(activity.heartbeatIntervalId);
1504
1516
  activity.heartbeatIntervalId = null;
1505
1517
  }
1518
+ function clearInactivityTimeout(activity) {
1519
+ if (activity.inactivityTimeoutId !== null) {
1520
+ clearTimeout(activity.inactivityTimeoutId);
1521
+ activity.inactivityTimeoutId = null;
1522
+ }
1523
+ activity.inactivityTimerStartedAt = null;
1524
+ }
1525
+ function clearPausedHeartbeatTimeout(activity) {
1526
+ if (activity.pausedHeartbeatTimeoutId !== null) {
1527
+ clearTimeout(activity.pausedHeartbeatTimeoutId);
1528
+ activity.pausedHeartbeatTimeoutId = null;
1529
+ }
1530
+ }
1531
+ function hasBlockingPauseReason(activity) {
1532
+ for (const reason of activity.pauseReasons) {
1533
+ if (reason !== "inactivity") {
1534
+ return true;
1535
+ }
1536
+ }
1537
+ return false;
1538
+ }
1539
+ function isDocumentHidden() {
1540
+ return typeof document !== "undefined" && document.visibilityState === "hidden";
1541
+ }
1542
+ function isAutoPauseReason(reason) {
1543
+ return reason === "hidden" || reason === "inactivity";
1544
+ }
1545
+ function hasAutoPauseReason(activity) {
1546
+ return activity.pauseReasons.has("hidden") || activity.pauseReasons.has("inactivity");
1547
+ }
1506
1548
  function createTimebackActivityTracker(client) {
1507
1549
  let currentActivity = null;
1508
1550
  let boundVisibilityHandler = null;
1551
+ let boundUserInteractionHandler = null;
1509
1552
  let boundShellPauseHandler = null;
1510
1553
  let boundShellResumeHandler = null;
1511
1554
  let boundPageHideHandler = null;
@@ -1517,25 +1560,134 @@ function createTimebackActivityTracker(client) {
1517
1560
  flushHeartbeat();
1518
1561
  }, activity.heartbeatIntervalMs);
1519
1562
  }
1563
+ function resetPausedHeartbeatWindow(activity) {
1564
+ const now = Date.now();
1565
+ const pausedAtReset = getCurrentPausedTotal(activity, now);
1566
+ activity.pausedHeartbeatTimedOut = false;
1567
+ activity.windowStartTime = now;
1568
+ activity.windowPausedAtStart = pausedAtReset;
1569
+ startHeartbeatInterval(activity);
1570
+ }
1571
+ function armPausedHeartbeatTimeout(activity) {
1572
+ if (activity.pausedHeartbeatTimeoutMs === Infinity) {
1573
+ return;
1574
+ }
1575
+ clearPausedHeartbeatTimeout(activity);
1576
+ const trackedActivity = activity;
1577
+ activity.pausedHeartbeatTimeoutId = setTimeout(() => {
1578
+ if (currentActivity !== trackedActivity) {
1579
+ return;
1580
+ }
1581
+ trackedActivity.pausedHeartbeatTimeoutId = null;
1582
+ trackedActivity.pausedHeartbeatTimedOut = true;
1583
+ stopHeartbeatInterval(trackedActivity);
1584
+ }, activity.pausedHeartbeatTimeoutMs);
1585
+ }
1520
1586
  function addPauseReason(reason) {
1521
1587
  if (!currentActivity) {
1522
1588
  return;
1523
1589
  }
1524
1590
  const wasPaused = currentActivity.pauseReasons.size > 0;
1591
+ const wasAutoPaused = hasAutoPauseReason(currentActivity);
1592
+ const alreadyHadReason = currentActivity.pauseReasons.has(reason);
1525
1593
  currentActivity.pauseReasons.add(reason);
1526
1594
  if (!wasPaused && currentActivity.pauseReasons.size > 0) {
1527
1595
  currentActivity.pauseStartTime = Date.now();
1528
1596
  }
1597
+ if (isAutoPauseReason(reason) && !alreadyHadReason && !wasAutoPaused) {
1598
+ armPausedHeartbeatTimeout(currentActivity);
1599
+ }
1600
+ syncInactivityTracking();
1529
1601
  }
1530
1602
  function removePauseReason(reason) {
1531
1603
  if (!currentActivity) {
1532
1604
  return;
1533
1605
  }
1606
+ const hadReason = currentActivity.pauseReasons.has(reason);
1607
+ const wasAutoPaused = hasAutoPauseReason(currentActivity);
1534
1608
  currentActivity.pauseReasons.delete(reason);
1535
1609
  if (currentActivity.pauseReasons.size === 0 && currentActivity.pauseStartTime !== null) {
1536
1610
  currentActivity.pausedTime += Date.now() - currentActivity.pauseStartTime;
1537
1611
  currentActivity.pauseStartTime = null;
1538
1612
  }
1613
+ if (isAutoPauseReason(reason) && hadReason && wasAutoPaused && !hasAutoPauseReason(currentActivity)) {
1614
+ clearPausedHeartbeatTimeout(currentActivity);
1615
+ if (currentActivity.pausedHeartbeatTimedOut) {
1616
+ resetPausedHeartbeatWindow(currentActivity);
1617
+ }
1618
+ }
1619
+ syncInactivityTracking();
1620
+ }
1621
+ function captureRemainingInactivityMs(activity, now = Date.now()) {
1622
+ if (activity.inactivityTimerStartedAt === null) {
1623
+ return;
1624
+ }
1625
+ const elapsedMs = Math.max(0, now - activity.inactivityTimerStartedAt);
1626
+ activity.remainingInactivityMs = Math.max(0, activity.remainingInactivityMs - elapsedMs);
1627
+ clearInactivityTimeout(activity);
1628
+ }
1629
+ function shouldRunInactivityCountdown(activity) {
1630
+ if (activity.inactivityTimeoutMs === Infinity) {
1631
+ return false;
1632
+ }
1633
+ if (isDocumentHidden()) {
1634
+ return false;
1635
+ }
1636
+ if (activity.pauseReasons.has("inactivity")) {
1637
+ return false;
1638
+ }
1639
+ return !hasBlockingPauseReason(activity);
1640
+ }
1641
+ function armInactivityTimeout(activity) {
1642
+ if (!shouldRunInactivityCountdown(activity)) {
1643
+ return;
1644
+ }
1645
+ if (activity.remainingInactivityMs <= 0) {
1646
+ addPauseReason("inactivity");
1647
+ return;
1648
+ }
1649
+ clearInactivityTimeout(activity);
1650
+ const trackedActivity = activity;
1651
+ activity.inactivityTimerStartedAt = Date.now();
1652
+ activity.inactivityTimeoutId = setTimeout(() => {
1653
+ if (currentActivity !== trackedActivity) {
1654
+ return;
1655
+ }
1656
+ trackedActivity.remainingInactivityMs = 0;
1657
+ clearInactivityTimeout(trackedActivity);
1658
+ addPauseReason("inactivity");
1659
+ }, trackedActivity.remainingInactivityMs);
1660
+ }
1661
+ function syncInactivityTracking() {
1662
+ const activity = currentActivity;
1663
+ if (!activity) {
1664
+ return;
1665
+ }
1666
+ if (shouldRunInactivityCountdown(activity)) {
1667
+ if (activity.inactivityTimeoutId === null) {
1668
+ armInactivityTimeout(activity);
1669
+ }
1670
+ return;
1671
+ }
1672
+ if (activity.inactivityTimeoutId !== null) {
1673
+ captureRemainingInactivityMs(activity);
1674
+ }
1675
+ }
1676
+ function resetInactivityTracking(activity) {
1677
+ clearInactivityTimeout(activity);
1678
+ activity.remainingInactivityMs = activity.inactivityTimeoutMs;
1679
+ }
1680
+ function handleUserInteraction() {
1681
+ const activity = currentActivity;
1682
+ if (!activity || activity.inactivityTimeoutMs === Infinity || isDocumentHidden()) {
1683
+ return;
1684
+ }
1685
+ resetInactivityTracking(activity);
1686
+ if (activity.pauseReasons.has("inactivity")) {
1687
+ removePauseReason("inactivity");
1688
+ return;
1689
+ }
1690
+ syncInactivityTracking();
1539
1691
  }
1540
1692
  function handleVisibilityChange() {
1541
1693
  if (!currentActivity) {
@@ -1543,34 +1695,8 @@ function createTimebackActivityTracker(client) {
1543
1695
  }
1544
1696
  if (document.visibilityState === "hidden") {
1545
1697
  addPauseReason("hidden");
1546
- if (currentActivity.hiddenTimeoutMs !== Infinity) {
1547
- if (currentActivity.hiddenTimeoutId !== null) {
1548
- clearTimeout(currentActivity.hiddenTimeoutId);
1549
- }
1550
- const activity = currentActivity;
1551
- currentActivity.hiddenTimeoutId = setTimeout(() => {
1552
- if (currentActivity === activity) {
1553
- activity.hiddenTimeoutId = null;
1554
- activity.hiddenTimedOut = true;
1555
- stopHeartbeatInterval(activity);
1556
- }
1557
- }, activity.hiddenTimeoutMs);
1558
- }
1559
1698
  } else {
1560
- const shouldResetWindow = currentActivity.hiddenTimedOut;
1561
- if (currentActivity.hiddenTimeoutId !== null) {
1562
- clearTimeout(currentActivity.hiddenTimeoutId);
1563
- currentActivity.hiddenTimeoutId = null;
1564
- }
1565
1699
  removePauseReason("hidden");
1566
- if (shouldResetWindow) {
1567
- const now = Date.now();
1568
- const pausedAtReset = getCurrentPausedTotal(currentActivity, now);
1569
- currentActivity.hiddenTimedOut = false;
1570
- currentActivity.windowStartTime = now;
1571
- currentActivity.windowPausedAtStart = pausedAtReset;
1572
- startHeartbeatInterval(currentActivity);
1573
- }
1574
1700
  }
1575
1701
  }
1576
1702
  function handleShellPause() {
@@ -1645,6 +1771,12 @@ function createTimebackActivityTracker(client) {
1645
1771
  document.removeEventListener("visibilitychange", boundVisibilityHandler);
1646
1772
  boundVisibilityHandler = null;
1647
1773
  }
1774
+ if (boundUserInteractionHandler && typeof document !== "undefined") {
1775
+ for (const eventName of USER_ACTIVITY_EVENTS) {
1776
+ document.removeEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
1777
+ }
1778
+ boundUserInteractionHandler = null;
1779
+ }
1648
1780
  if (boundShellPauseHandler) {
1649
1781
  messaging.unlisten("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
1650
1782
  boundShellPauseHandler = null;
@@ -1657,8 +1789,9 @@ function createTimebackActivityTracker(client) {
1657
1789
  globalThis.window.removeEventListener("pagehide", boundPageHideHandler);
1658
1790
  boundPageHideHandler = null;
1659
1791
  }
1660
- if (currentActivity?.hiddenTimeoutId != null) {
1661
- clearTimeout(currentActivity.hiddenTimeoutId);
1792
+ if (currentActivity) {
1793
+ clearInactivityTimeout(currentActivity);
1794
+ clearPausedHeartbeatTimeout(currentActivity);
1662
1795
  }
1663
1796
  if (currentActivity?.heartbeatIntervalId != null) {
1664
1797
  stopHeartbeatInterval(currentActivity);
@@ -1671,7 +1804,9 @@ function createTimebackActivityTracker(client) {
1671
1804
  }
1672
1805
  cleanupListeners();
1673
1806
  const now = Date.now();
1674
- const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
1807
+ const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
1808
+ const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
1809
+ const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
1675
1810
  currentActivity = {
1676
1811
  runId: options?.runId ?? crypto.randomUUID(),
1677
1812
  resumeId: crypto.randomUUID(),
@@ -1680,13 +1815,17 @@ function createTimebackActivityTracker(client) {
1680
1815
  pausedTime: 0,
1681
1816
  pauseStartTime: null,
1682
1817
  pauseReasons: new Set,
1683
- hiddenTimeoutId: null,
1684
- hiddenTimedOut: false,
1685
- hiddenTimeoutMs: options?.hiddenTimeoutMs ?? DEFAULT_HIDDEN_TIMEOUT_MS,
1818
+ pausedHeartbeatTimeoutId: null,
1819
+ pausedHeartbeatTimedOut: false,
1820
+ pausedHeartbeatTimeoutMs,
1686
1821
  windowStartTime: now,
1687
1822
  windowPausedAtStart: 0,
1688
1823
  heartbeatIntervalId: null,
1689
1824
  heartbeatIntervalMs,
1825
+ inactivityTimeoutId: null,
1826
+ inactivityTimeoutMs,
1827
+ inactivityTimerStartedAt: null,
1828
+ remainingInactivityMs: inactivityTimeoutMs,
1690
1829
  flushInFlight: null,
1691
1830
  totalPersistedActiveMs: 0,
1692
1831
  totalPersistedPausedMs: 0
@@ -1694,6 +1833,10 @@ function createTimebackActivityTracker(client) {
1694
1833
  if (typeof document !== "undefined") {
1695
1834
  boundVisibilityHandler = handleVisibilityChange;
1696
1835
  document.addEventListener("visibilitychange", boundVisibilityHandler);
1836
+ boundUserInteractionHandler = handleUserInteraction;
1837
+ for (const eventName of USER_ACTIVITY_EVENTS) {
1838
+ document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
1839
+ }
1697
1840
  if (document.visibilityState === "hidden") {
1698
1841
  handleVisibilityChange();
1699
1842
  }
@@ -1707,6 +1850,7 @@ function createTimebackActivityTracker(client) {
1707
1850
  boundShellResumeHandler = handleShellResume;
1708
1851
  messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
1709
1852
  messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
1853
+ syncInactivityTracking();
1710
1854
  },
1711
1855
  pauseActivity() {
1712
1856
  if (!currentActivity) {
package/dist/internal.js CHANGED
@@ -1451,9 +1451,21 @@ function isValidUUID(value) {
1451
1451
  }
1452
1452
 
1453
1453
  // src/core/activity-tracker.ts
1454
- var DEFAULT_HIDDEN_TIMEOUT_MS = 10 * 60 * 1000;
1454
+ var DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS = 10 * 60 * 1000;
1455
1455
  var DEFAULT_HEARTBEAT_INTERVAL_MS = 15000;
1456
+ var DEFAULT_INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
1456
1457
  var HEARTBEAT_RETRY_POLICY = { retryableMethods: ["POST"] };
1458
+ var USER_ACTIVITY_EVENTS = ["keydown", "pointerdown", "pointermove", "wheel"];
1459
+ var USER_ACTIVITY_LISTENER_OPTIONS = { capture: true };
1460
+ function normalizeDelayMs(value, defaultValue, allowZero = true) {
1461
+ if (value === Infinity) {
1462
+ return Infinity;
1463
+ }
1464
+ if (typeof value === "number" && Number.isFinite(value) && (allowZero ? value >= 0 : value > 0)) {
1465
+ return value;
1466
+ }
1467
+ return defaultValue;
1468
+ }
1457
1469
  function getCurrentPausedTotal(activity, now = Date.now()) {
1458
1470
  if (activity.pauseStartTime === null) {
1459
1471
  return activity.pausedTime;
@@ -1503,9 +1515,40 @@ function stopHeartbeatInterval(activity) {
1503
1515
  clearInterval(activity.heartbeatIntervalId);
1504
1516
  activity.heartbeatIntervalId = null;
1505
1517
  }
1518
+ function clearInactivityTimeout(activity) {
1519
+ if (activity.inactivityTimeoutId !== null) {
1520
+ clearTimeout(activity.inactivityTimeoutId);
1521
+ activity.inactivityTimeoutId = null;
1522
+ }
1523
+ activity.inactivityTimerStartedAt = null;
1524
+ }
1525
+ function clearPausedHeartbeatTimeout(activity) {
1526
+ if (activity.pausedHeartbeatTimeoutId !== null) {
1527
+ clearTimeout(activity.pausedHeartbeatTimeoutId);
1528
+ activity.pausedHeartbeatTimeoutId = null;
1529
+ }
1530
+ }
1531
+ function hasBlockingPauseReason(activity) {
1532
+ for (const reason of activity.pauseReasons) {
1533
+ if (reason !== "inactivity") {
1534
+ return true;
1535
+ }
1536
+ }
1537
+ return false;
1538
+ }
1539
+ function isDocumentHidden() {
1540
+ return typeof document !== "undefined" && document.visibilityState === "hidden";
1541
+ }
1542
+ function isAutoPauseReason(reason) {
1543
+ return reason === "hidden" || reason === "inactivity";
1544
+ }
1545
+ function hasAutoPauseReason(activity) {
1546
+ return activity.pauseReasons.has("hidden") || activity.pauseReasons.has("inactivity");
1547
+ }
1506
1548
  function createTimebackActivityTracker(client) {
1507
1549
  let currentActivity = null;
1508
1550
  let boundVisibilityHandler = null;
1551
+ let boundUserInteractionHandler = null;
1509
1552
  let boundShellPauseHandler = null;
1510
1553
  let boundShellResumeHandler = null;
1511
1554
  let boundPageHideHandler = null;
@@ -1517,25 +1560,134 @@ function createTimebackActivityTracker(client) {
1517
1560
  flushHeartbeat();
1518
1561
  }, activity.heartbeatIntervalMs);
1519
1562
  }
1563
+ function resetPausedHeartbeatWindow(activity) {
1564
+ const now = Date.now();
1565
+ const pausedAtReset = getCurrentPausedTotal(activity, now);
1566
+ activity.pausedHeartbeatTimedOut = false;
1567
+ activity.windowStartTime = now;
1568
+ activity.windowPausedAtStart = pausedAtReset;
1569
+ startHeartbeatInterval(activity);
1570
+ }
1571
+ function armPausedHeartbeatTimeout(activity) {
1572
+ if (activity.pausedHeartbeatTimeoutMs === Infinity) {
1573
+ return;
1574
+ }
1575
+ clearPausedHeartbeatTimeout(activity);
1576
+ const trackedActivity = activity;
1577
+ activity.pausedHeartbeatTimeoutId = setTimeout(() => {
1578
+ if (currentActivity !== trackedActivity) {
1579
+ return;
1580
+ }
1581
+ trackedActivity.pausedHeartbeatTimeoutId = null;
1582
+ trackedActivity.pausedHeartbeatTimedOut = true;
1583
+ stopHeartbeatInterval(trackedActivity);
1584
+ }, activity.pausedHeartbeatTimeoutMs);
1585
+ }
1520
1586
  function addPauseReason(reason) {
1521
1587
  if (!currentActivity) {
1522
1588
  return;
1523
1589
  }
1524
1590
  const wasPaused = currentActivity.pauseReasons.size > 0;
1591
+ const wasAutoPaused = hasAutoPauseReason(currentActivity);
1592
+ const alreadyHadReason = currentActivity.pauseReasons.has(reason);
1525
1593
  currentActivity.pauseReasons.add(reason);
1526
1594
  if (!wasPaused && currentActivity.pauseReasons.size > 0) {
1527
1595
  currentActivity.pauseStartTime = Date.now();
1528
1596
  }
1597
+ if (isAutoPauseReason(reason) && !alreadyHadReason && !wasAutoPaused) {
1598
+ armPausedHeartbeatTimeout(currentActivity);
1599
+ }
1600
+ syncInactivityTracking();
1529
1601
  }
1530
1602
  function removePauseReason(reason) {
1531
1603
  if (!currentActivity) {
1532
1604
  return;
1533
1605
  }
1606
+ const hadReason = currentActivity.pauseReasons.has(reason);
1607
+ const wasAutoPaused = hasAutoPauseReason(currentActivity);
1534
1608
  currentActivity.pauseReasons.delete(reason);
1535
1609
  if (currentActivity.pauseReasons.size === 0 && currentActivity.pauseStartTime !== null) {
1536
1610
  currentActivity.pausedTime += Date.now() - currentActivity.pauseStartTime;
1537
1611
  currentActivity.pauseStartTime = null;
1538
1612
  }
1613
+ if (isAutoPauseReason(reason) && hadReason && wasAutoPaused && !hasAutoPauseReason(currentActivity)) {
1614
+ clearPausedHeartbeatTimeout(currentActivity);
1615
+ if (currentActivity.pausedHeartbeatTimedOut) {
1616
+ resetPausedHeartbeatWindow(currentActivity);
1617
+ }
1618
+ }
1619
+ syncInactivityTracking();
1620
+ }
1621
+ function captureRemainingInactivityMs(activity, now = Date.now()) {
1622
+ if (activity.inactivityTimerStartedAt === null) {
1623
+ return;
1624
+ }
1625
+ const elapsedMs = Math.max(0, now - activity.inactivityTimerStartedAt);
1626
+ activity.remainingInactivityMs = Math.max(0, activity.remainingInactivityMs - elapsedMs);
1627
+ clearInactivityTimeout(activity);
1628
+ }
1629
+ function shouldRunInactivityCountdown(activity) {
1630
+ if (activity.inactivityTimeoutMs === Infinity) {
1631
+ return false;
1632
+ }
1633
+ if (isDocumentHidden()) {
1634
+ return false;
1635
+ }
1636
+ if (activity.pauseReasons.has("inactivity")) {
1637
+ return false;
1638
+ }
1639
+ return !hasBlockingPauseReason(activity);
1640
+ }
1641
+ function armInactivityTimeout(activity) {
1642
+ if (!shouldRunInactivityCountdown(activity)) {
1643
+ return;
1644
+ }
1645
+ if (activity.remainingInactivityMs <= 0) {
1646
+ addPauseReason("inactivity");
1647
+ return;
1648
+ }
1649
+ clearInactivityTimeout(activity);
1650
+ const trackedActivity = activity;
1651
+ activity.inactivityTimerStartedAt = Date.now();
1652
+ activity.inactivityTimeoutId = setTimeout(() => {
1653
+ if (currentActivity !== trackedActivity) {
1654
+ return;
1655
+ }
1656
+ trackedActivity.remainingInactivityMs = 0;
1657
+ clearInactivityTimeout(trackedActivity);
1658
+ addPauseReason("inactivity");
1659
+ }, trackedActivity.remainingInactivityMs);
1660
+ }
1661
+ function syncInactivityTracking() {
1662
+ const activity = currentActivity;
1663
+ if (!activity) {
1664
+ return;
1665
+ }
1666
+ if (shouldRunInactivityCountdown(activity)) {
1667
+ if (activity.inactivityTimeoutId === null) {
1668
+ armInactivityTimeout(activity);
1669
+ }
1670
+ return;
1671
+ }
1672
+ if (activity.inactivityTimeoutId !== null) {
1673
+ captureRemainingInactivityMs(activity);
1674
+ }
1675
+ }
1676
+ function resetInactivityTracking(activity) {
1677
+ clearInactivityTimeout(activity);
1678
+ activity.remainingInactivityMs = activity.inactivityTimeoutMs;
1679
+ }
1680
+ function handleUserInteraction() {
1681
+ const activity = currentActivity;
1682
+ if (!activity || activity.inactivityTimeoutMs === Infinity || isDocumentHidden()) {
1683
+ return;
1684
+ }
1685
+ resetInactivityTracking(activity);
1686
+ if (activity.pauseReasons.has("inactivity")) {
1687
+ removePauseReason("inactivity");
1688
+ return;
1689
+ }
1690
+ syncInactivityTracking();
1539
1691
  }
1540
1692
  function handleVisibilityChange() {
1541
1693
  if (!currentActivity) {
@@ -1543,34 +1695,8 @@ function createTimebackActivityTracker(client) {
1543
1695
  }
1544
1696
  if (document.visibilityState === "hidden") {
1545
1697
  addPauseReason("hidden");
1546
- if (currentActivity.hiddenTimeoutMs !== Infinity) {
1547
- if (currentActivity.hiddenTimeoutId !== null) {
1548
- clearTimeout(currentActivity.hiddenTimeoutId);
1549
- }
1550
- const activity = currentActivity;
1551
- currentActivity.hiddenTimeoutId = setTimeout(() => {
1552
- if (currentActivity === activity) {
1553
- activity.hiddenTimeoutId = null;
1554
- activity.hiddenTimedOut = true;
1555
- stopHeartbeatInterval(activity);
1556
- }
1557
- }, activity.hiddenTimeoutMs);
1558
- }
1559
1698
  } else {
1560
- const shouldResetWindow = currentActivity.hiddenTimedOut;
1561
- if (currentActivity.hiddenTimeoutId !== null) {
1562
- clearTimeout(currentActivity.hiddenTimeoutId);
1563
- currentActivity.hiddenTimeoutId = null;
1564
- }
1565
1699
  removePauseReason("hidden");
1566
- if (shouldResetWindow) {
1567
- const now = Date.now();
1568
- const pausedAtReset = getCurrentPausedTotal(currentActivity, now);
1569
- currentActivity.hiddenTimedOut = false;
1570
- currentActivity.windowStartTime = now;
1571
- currentActivity.windowPausedAtStart = pausedAtReset;
1572
- startHeartbeatInterval(currentActivity);
1573
- }
1574
1700
  }
1575
1701
  }
1576
1702
  function handleShellPause() {
@@ -1645,6 +1771,12 @@ function createTimebackActivityTracker(client) {
1645
1771
  document.removeEventListener("visibilitychange", boundVisibilityHandler);
1646
1772
  boundVisibilityHandler = null;
1647
1773
  }
1774
+ if (boundUserInteractionHandler && typeof document !== "undefined") {
1775
+ for (const eventName of USER_ACTIVITY_EVENTS) {
1776
+ document.removeEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
1777
+ }
1778
+ boundUserInteractionHandler = null;
1779
+ }
1648
1780
  if (boundShellPauseHandler) {
1649
1781
  messaging.unlisten("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
1650
1782
  boundShellPauseHandler = null;
@@ -1657,8 +1789,9 @@ function createTimebackActivityTracker(client) {
1657
1789
  globalThis.window.removeEventListener("pagehide", boundPageHideHandler);
1658
1790
  boundPageHideHandler = null;
1659
1791
  }
1660
- if (currentActivity?.hiddenTimeoutId != null) {
1661
- clearTimeout(currentActivity.hiddenTimeoutId);
1792
+ if (currentActivity) {
1793
+ clearInactivityTimeout(currentActivity);
1794
+ clearPausedHeartbeatTimeout(currentActivity);
1662
1795
  }
1663
1796
  if (currentActivity?.heartbeatIntervalId != null) {
1664
1797
  stopHeartbeatInterval(currentActivity);
@@ -1671,7 +1804,9 @@ function createTimebackActivityTracker(client) {
1671
1804
  }
1672
1805
  cleanupListeners();
1673
1806
  const now = Date.now();
1674
- const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
1807
+ const heartbeatIntervalMs = normalizeDelayMs(options?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, false);
1808
+ const pausedHeartbeatTimeoutMs = normalizeDelayMs(options?.pausedHeartbeatTimeoutMs ?? options?.hiddenTimeoutMs, DEFAULT_PAUSED_HEARTBEAT_TIMEOUT_MS, false);
1809
+ const inactivityTimeoutMs = normalizeDelayMs(options?.inactivityTimeoutMs, DEFAULT_INACTIVITY_TIMEOUT_MS, false);
1675
1810
  currentActivity = {
1676
1811
  runId: options?.runId ?? crypto.randomUUID(),
1677
1812
  resumeId: crypto.randomUUID(),
@@ -1680,13 +1815,17 @@ function createTimebackActivityTracker(client) {
1680
1815
  pausedTime: 0,
1681
1816
  pauseStartTime: null,
1682
1817
  pauseReasons: new Set,
1683
- hiddenTimeoutId: null,
1684
- hiddenTimedOut: false,
1685
- hiddenTimeoutMs: options?.hiddenTimeoutMs ?? DEFAULT_HIDDEN_TIMEOUT_MS,
1818
+ pausedHeartbeatTimeoutId: null,
1819
+ pausedHeartbeatTimedOut: false,
1820
+ pausedHeartbeatTimeoutMs,
1686
1821
  windowStartTime: now,
1687
1822
  windowPausedAtStart: 0,
1688
1823
  heartbeatIntervalId: null,
1689
1824
  heartbeatIntervalMs,
1825
+ inactivityTimeoutId: null,
1826
+ inactivityTimeoutMs,
1827
+ inactivityTimerStartedAt: null,
1828
+ remainingInactivityMs: inactivityTimeoutMs,
1690
1829
  flushInFlight: null,
1691
1830
  totalPersistedActiveMs: 0,
1692
1831
  totalPersistedPausedMs: 0
@@ -1694,6 +1833,10 @@ function createTimebackActivityTracker(client) {
1694
1833
  if (typeof document !== "undefined") {
1695
1834
  boundVisibilityHandler = handleVisibilityChange;
1696
1835
  document.addEventListener("visibilitychange", boundVisibilityHandler);
1836
+ boundUserInteractionHandler = handleUserInteraction;
1837
+ for (const eventName of USER_ACTIVITY_EVENTS) {
1838
+ document.addEventListener(eventName, boundUserInteractionHandler, USER_ACTIVITY_LISTENER_OPTIONS);
1839
+ }
1697
1840
  if (document.visibilityState === "hidden") {
1698
1841
  handleVisibilityChange();
1699
1842
  }
@@ -1707,6 +1850,7 @@ function createTimebackActivityTracker(client) {
1707
1850
  boundShellResumeHandler = handleShellResume;
1708
1851
  messaging.listen("PLAYCADEMY_PAUSE" /* PAUSE */, boundShellPauseHandler);
1709
1852
  messaging.listen("PLAYCADEMY_RESUME" /* RESUME */, boundShellResumeHandler);
1853
+ syncInactivityTracking();
1710
1854
  },
1711
1855
  pauseActivity() {
1712
1856
  if (!currentActivity) {
package/dist/types.d.ts CHANGED
@@ -5174,16 +5174,34 @@ declare abstract class PlaycademyBaseClient {
5174
5174
  */
5175
5175
  interface StartActivityOptions {
5176
5176
  /**
5177
- * How long the tab can stay hidden before the timing window resets on return.
5178
- * Defaults to 10 minutes. Set to `Infinity` to disable.
5177
+ * How long heartbeats continue after the activity is automatically paused
5178
+ * because the tab is hidden or the player is inactive while visible.
5179
+ * Defaults to 10 minutes. Set to `Infinity` to keep heartbeats running
5180
+ * indefinitely during automatic pauses. Invalid values fall back to the
5181
+ * 10-minute default.
5182
+ */
5183
+ pausedHeartbeatTimeoutMs?: number;
5184
+ /**
5185
+ * @deprecated Use `pausedHeartbeatTimeoutMs` instead.
5186
+ *
5187
+ * Backward-compatible alias for callers that still use the old option
5188
+ * name from earlier SDK releases.
5179
5189
  */
5180
5190
  hiddenTimeoutMs?: number;
5181
5191
  /**
5182
5192
  * How often to flush periodic heartbeats with accumulated time data.
5183
5193
  * Defaults to 15 seconds. Set to `Infinity` to disable the interval;
5184
- * final unload/endActivity flushes still run.
5194
+ * final unload/endActivity flushes still run. Values must be greater than
5195
+ * 0 or `Infinity`; invalid values fall back to the 15-second default.
5185
5196
  */
5186
5197
  heartbeatIntervalMs?: number;
5198
+ /**
5199
+ * How long the tab can remain visible without keyboard or mouse activity
5200
+ * before the activity is marked inactive. Defaults to 10 minutes. Set to
5201
+ * `Infinity` to disable keyboard/mouse inactivity tracking. Invalid values
5202
+ * fall back to the 10-minute default.
5203
+ */
5204
+ inactivityTimeoutMs?: number;
5187
5205
  /**
5188
5206
  * Stable identifier for this activity run. When provided, it is used on
5189
5207
  * every heartbeat and on endActivity instead of a freshly-generated UUID.
@@ -5265,7 +5283,9 @@ declare class PlaycademyClient extends PlaycademyBaseClient {
5265
5283
  * - `user.fetch()` - Refresh user context from server
5266
5284
  *
5267
5285
  * Activity tracking:
5268
- * - `startActivity(metadata)` - Begin tracking an activity
5286
+ * - `startActivity(metadata)` - Begin tracking an activity with automatic
5287
+ * hidden-tab and visible-tab inactivity handling, plus configurable
5288
+ * paused-heartbeat timeout behavior
5269
5289
  * - `pauseActivity()` / `resumeActivity()` - Pause/resume timer
5270
5290
  * - `endActivity(scoreData)` - Submit activity results to TimeBack
5271
5291
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcademy/sdk",
3
- "version": "0.6.0",
3
+ "version": "0.6.1-beta.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {