\n' +
+'
Allow2
\n' +
+'
✓
\n' +
+'
Device Paired Successfully
\n' +
+'
This device is now connected to your Allow2 account.
You can close this window.
\n' +
+'
\n' +
+'\n' +
+'';
+}
diff --git a/src/request.js b/src/request.js
new file mode 100644
index 0000000..8b1e0c4
--- /dev/null
+++ b/src/request.js
@@ -0,0 +1,123 @@
+/**
+ * Request More Time
+ *
+ * Lets a child request additional time for an activity.
+ * Creates the request via the Allow2 API, then polls for
+ * parent approval/denial. Emits events as the status changes.
+ */
+
+import { EventEmitter } from 'node:events';
+
+const DEFAULT_POLL_INTERVAL = 5000; // 5 seconds
+const DEFAULT_TIMEOUT = 300000; // 5 minutes
+
+export class RequestManager extends EventEmitter {
+
+ /**
+ * @param {object} options
+ * @param {import('./api.js').Allow2Api} options.api - Allow2Api instance
+ * @param {number} [options.pollInterval] - Polling interval in ms (default 5000)
+ * @param {number} [options.timeout] - Max wait time in ms (default 300000)
+ */
+ constructor(options) {
+ super();
+ this._api = options.api;
+ this._pollInterval = options.pollInterval || DEFAULT_POLL_INTERVAL;
+ this._timeout = options.timeout || DEFAULT_TIMEOUT;
+ this._pollTimer = null;
+ this._timeoutTimer = null;
+ }
+
+ /**
+ * Submit a "request more time" to the Allow2 API and begin polling.
+ *
+ * @param {object} params
+ * @param {number} params.userId
+ * @param {number} params.pairId
+ * @param {string} params.pairToken
+ * @param {number} params.childId
+ * @param {number} params.duration - Minutes requested
+ * @param {number} params.activity - Activity ID
+ * @param {string} [params.message] - Optional message to parent
+ * @returns {Promise<{ requestId: string, statusSecret: string }>}
+ */
+ async createRequest(params) {
+ try {
+ const response = await this._api.createRequest(params);
+ const requestId = response.requestId;
+ const statusSecret = response.statusSecret;
+
+ this.emit('request-created', { requestId: requestId });
+ this.startPolling(requestId, statusSecret);
+
+ return { requestId: requestId, statusSecret: statusSecret };
+ } catch (err) {
+ this.emit('request-error', err);
+ throw err;
+ }
+ }
+
+ /**
+ * Begin polling the request status endpoint.
+ *
+ * @param {string} requestId
+ * @param {string} statusSecret
+ */
+ startPolling(requestId, statusSecret) {
+ this.stopPolling();
+
+ // Timeout — give up after configured duration
+ this._timeoutTimer = setTimeout(() => {
+ this.stopPolling();
+ this.emit('request-timeout');
+ }, this._timeout);
+
+ this._poll(requestId, statusSecret);
+ }
+
+ /**
+ * Cancel any active polling.
+ */
+ stopPolling() {
+ if (this._pollTimer) {
+ clearTimeout(this._pollTimer);
+ this._pollTimer = null;
+ }
+ if (this._timeoutTimer) {
+ clearTimeout(this._timeoutTimer);
+ this._timeoutTimer = null;
+ }
+ }
+
+ // ── Internal ──────────────────────────────────────────────
+
+ _poll(requestId, statusSecret) {
+ this._pollTimer = setTimeout(async () => {
+ try {
+ const status = await this._api.getRequestStatus(requestId, statusSecret);
+
+ if (status.status === 'approved') {
+ this.stopPolling();
+ this.emit('request-approved', {
+ requestId: requestId,
+ extension: status.extension,
+ });
+ return;
+ }
+
+ if (status.status === 'denied') {
+ this.stopPolling();
+ this.emit('request-denied', { requestId: requestId });
+ return;
+ }
+
+ // Still pending — schedule next poll
+ this._poll(requestId, statusSecret);
+ } catch (err) {
+ this.emit('request-error', err);
+ // Keep polling despite transient errors
+ this._poll(requestId, statusSecret);
+ }
+ }, this._pollInterval);
+ }
+}
diff --git a/src/updates.js b/src/updates.js
new file mode 100644
index 0000000..88c98e5
--- /dev/null
+++ b/src/updates.js
@@ -0,0 +1,178 @@
+/**
+ * Update Poller
+ *
+ * Polls GET /api/getUpdates for changes since the last check.
+ * Emits granular events for extensions, day type changes, quota updates,
+ * bans, and children list refreshes.
+ */
+
+import { EventEmitter } from 'node:events';
+
+export class UpdatePoller extends EventEmitter {
+
+ /**
+ * @param {object} options
+ * @param {import('./api.js').Allow2Api} options.api
+ * @param {number} [options.pollInterval=30000] - Milliseconds between polls
+ */
+ constructor(options) {
+ super();
+ this._api = options.api;
+ this._pollInterval = options.pollInterval || 30000;
+
+ this._credentials = null;
+ this._lastTimestamp = null;
+ this._timer = null;
+ this._running = false;
+ }
+
+ /**
+ * Begin polling with the given credentials.
+ *
+ * @param {object} credentials
+ * @param {number|string} credentials.userId
+ * @param {number|string} credentials.pairId
+ * @param {string} credentials.pairToken
+ * @param {string} credentials.deviceToken
+ */
+ start(credentials) {
+ if (this._running) return;
+
+ this._credentials = credentials;
+ this._running = true;
+ this._poll();
+ }
+
+ /**
+ * Stop polling.
+ */
+ stop() {
+ this._running = false;
+ if (this._timer) {
+ clearTimeout(this._timer);
+ this._timer = null;
+ }
+ }
+
+ // ── Internal ──────────────────────────────────────────────
+
+ async _poll() {
+ if (!this._running) return;
+
+ try {
+ await this._fetchUpdates();
+ } catch (err) {
+ this._handleError(err);
+ }
+
+ if (this._running) {
+ this._timer = setTimeout(() => this._poll(), this._pollInterval);
+ }
+ }
+
+ async _fetchUpdates() {
+ const params = {
+ userId: this._credentials.userId,
+ pairId: this._credentials.pairId,
+ pairToken: this._credentials.pairToken,
+ deviceToken: this._credentials.deviceToken,
+ };
+
+ if (this._lastTimestamp) {
+ params.timestampMillis = this._lastTimestamp;
+ }
+
+ const result = await this._api.getUpdates(params);
+
+ // Advance the timestamp so the next poll only gets deltas
+ if (result && result.timestampMillis) {
+ this._lastTimestamp = result.timestampMillis;
+ }
+
+ this._processUpdates(result);
+ }
+
+ /**
+ * Parse the getUpdates response and emit appropriate events.
+ *
+ * Expected response shape:
+ * {
+ * timestampMillis: number,
+ * extensions: [{ childId, activity, additionalMinutes }],
+ * dayTypeChanges: [{ childId, dayType }],
+ * quotaUpdates: [{ childId, activity, newQuota }],
+ * bans: [{ childId, activity, banned }],
+ * children: [{ id, name, pin, ... }]
+ * }
+ */
+ _processUpdates(result) {
+ if (!result) return;
+
+ // Extensions — parent approved extra time
+ const extensions = result.extensions;
+ if (extensions && extensions.length > 0) {
+ for (let i = 0; i < extensions.length; i++) {
+ this.emit('extension', {
+ childId: extensions[i].childId,
+ activity: extensions[i].activity,
+ additionalMinutes: extensions[i].additionalMinutes,
+ });
+ }
+ }
+
+ // Day type changes — e.g. school day switched to holiday
+ const dayTypeChanges = result.dayTypeChanges;
+ if (dayTypeChanges && dayTypeChanges.length > 0) {
+ for (let j = 0; j < dayTypeChanges.length; j++) {
+ this.emit('day-type-changed', {
+ childId: dayTypeChanges[j].childId,
+ dayType: dayTypeChanges[j].dayType,
+ });
+ }
+ }
+
+ // Quota updates — daily limit changed
+ const quotaUpdates = result.quotaUpdates;
+ if (quotaUpdates && quotaUpdates.length > 0) {
+ for (let k = 0; k < quotaUpdates.length; k++) {
+ this.emit('quota-updated', {
+ childId: quotaUpdates[k].childId,
+ activity: quotaUpdates[k].activity,
+ newQuota: quotaUpdates[k].newQuota,
+ });
+ }
+ }
+
+ // Bans — activity banned/unbanned
+ const bans = result.bans;
+ if (bans && bans.length > 0) {
+ for (let m = 0; m < bans.length; m++) {
+ this.emit('ban', {
+ childId: bans[m].childId,
+ activity: bans[m].activity,
+ banned: bans[m].banned,
+ });
+ }
+ }
+
+ // Children list refresh — names, PINs, added/removed children
+ const children = result.children;
+ if (children && children.length > 0) {
+ this.emit('children-updated', children);
+ }
+ }
+
+ /**
+ * Handle errors from the polling loop.
+ * HTTP 401 indicates the device has been unpaired.
+ */
+ _handleError(err) {
+ if (err && err.status === 401) {
+ this.emit('unpaired', { error: err });
+ this.stop();
+ return;
+ }
+
+ this.emit('error', err);
+ }
+}
diff --git a/src/warnings.js b/src/warnings.js
new file mode 100644
index 0000000..2775b87
--- /dev/null
+++ b/src/warnings.js
@@ -0,0 +1,86 @@
+/**
+ * Warning Scheduler
+ *
+ * Tracks remaining time per activity and emits 'warning' events
+ * when configurable thresholds are crossed. Prevents duplicate
+ * warnings for the same level+activity combination.
+ */
+
+const DEFAULT_THRESHOLDS = [
+ { remaining: 15 * 60, level: 'info' },
+ { remaining: 5 * 60, level: 'urgent' },
+ { remaining: 60, level: 'final' },
+ { remaining: 30, level: 'countdown' },
+];
+
+export class WarningScheduler {
+
+ /**
+ * @param {object} options
+ * @param {Function} options.emit - The EventEmitter emit function to call
+ * @param {Array} [options.thresholds] - Warning thresholds sorted descending by remaining
+ */
+ constructor(options) {
+ this._emit = options.emit;
+ this._thresholds = (options.thresholds || DEFAULT_THRESHOLDS)
+ .slice()
+ .sort((a, b) => b.remaining - a.remaining);
+
+ // Map