From 086326326cead033edd40f0c9e9fe674a2c5341b Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Tue, 16 Dec 2025 17:21:27 -0800 Subject: [PATCH] add tracking manager --- src/lib/tracking.svelte.ts | 210 +++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 src/lib/tracking.svelte.ts diff --git a/src/lib/tracking.svelte.ts b/src/lib/tracking.svelte.ts new file mode 100644 index 0000000..fecd0ea --- /dev/null +++ b/src/lib/tracking.svelte.ts @@ -0,0 +1,210 @@ +import { setContext, getContext, onDestroy } from 'svelte'; + +/** + * Options for initializing the TrackingManager. + */ +export type TrackingManagerOpts = { + consent?: boolean | null; +}; + +/** Definition of a service callback function. */ +export type ServiceCallback = (context?: T) => boolean; + +/** + * Definition of a tracking service with consent and revoke callbacks. + */ +export type Service = { + context?: T; + /** + * Called when the user consents to tracking or immediately if tracking is + * already allowed. Return true to mark the service as mounted. + */ + onConsent: ServiceCallback; + /** + * Called when the user revokes consent to tracking. Return true to mark + * the service as unmounted. + */ + onRevoke: ServiceCallback; +}; + +type InternalService = Service & { + mounted: boolean; + sid: string; +}; + +/** + * Manages user tracking preferences and services that require consent. + */ +export class TrackingManager { + private _consent: boolean | null = $state(null); + private _services: Record> = {}; + private _changeCallbacks: Array<(consent: boolean | null) => void> = []; + private _consentQueue: Array<() => void> = []; + + constructor(opts?: TrackingManagerOpts) { + if (opts) { + if (opts.consent !== undefined) this._consent = opts.consent; + } + } + + /** Indicates whether tracking is currently allowed. */ + get consent() { + return this._consent; + } + /** Indicates whether tracking permission has been explicitly set. */ + haveUserDecision() { + return this._consent !== null; + } + /** + * Checks whether tracking consent has been explicitly granted. + * @returns True if consent is granted, false if revoked or not set. + */ + haveUserConsent() { + return this._consent === true; + } + + /** + * Checks whether tracking is currently allowed. + * @throws Error if the user has not made a decision yet or has revoked consent. + */ + requireConsent() { + if (this._consent === null) { + throw new Error('Tracking permission has not been set yet.'); + } + if (this._consent === false) { + throw new Error('Tracking permission has been revoked by the user.'); + } + } + + /** + * Sets whether tracking is allowed. If set to true, all queued callbacks + * will be executed. + */ + setAllowed(value: boolean) { + if (this._consent === value) return; + this._consent = value; + + if (this._consent) { + this.mountServices(); + this._consentQueue.forEach((cb) => cb()); + this._consentQueue = []; + } else { + this.destroyServices(); + } + + this._changeCallbacks.forEach((cb) => { + cb(this._consent); + }); + } + + /** + * Registers a callback to be notified when the tracking permission changes. + */ + notifyChange(callback: (consent: boolean | null) => void) { + this._changeCallbacks.push(callback); + } + + /** + * Unregisters a previously registered change callback. + */ + unnotifyChange(callback: () => void) { + this._changeCallbacks = this._changeCallbacks.filter((cb) => cb !== callback); + } + + /** + * Runs callback immediately if we have consent already or queues it for later. + * Removes callback from queue onDestroy. + * @param callback The function to run when consent is granted. + */ + runWithConsent(callback: () => void) { + if (this._consent) { + callback(); + } else { + this._consentQueue.push(callback); + } + + onDestroy(() => { + this._consentQueue = this._consentQueue.filter((cb) => cb !== callback); + }); + } + + /** + * Records a service to be executed once tracking is allowed. Use this to + * defer tracking-related actions until the user has granted permission. + * @param service The service definition. + * @return The unique identifier for the registered service. + */ + createService(service: Service): string { + const s: InternalService = { + ...service, + mounted: false, + sid: crypto.randomUUID() + }; + + if (this._consent) { + s.mounted = s.onConsent(); + } + + this._services[s.sid] = s as InternalService; + return s.sid; + } + + /** + * Gets the typed context for a registered service. + * @param sid The unique identifier of the service. + * @returns The context of the service, or undefined if not found. + */ + getServiceContext(sid: string): T | undefined { + const service = this._services[sid]; + if (service) { + return service.context as T; + } + return undefined; + } + + /** + * Removes a registered service by its unique identifier. Calls the + * destroy function if the service was mounted. + */ + removeService(sid: string) { + const service = this._services[sid]; + if (service) { + if (service.mounted) { + service.onRevoke(service.context); + } + delete this._services[sid]; + } + } + + private mountServices() { + Object.values(this._services).forEach((service) => { + if (!service.mounted) { + service.mounted = service.onConsent(service.context); + } + }); + } + private destroyServices() { + Object.values(this._services).forEach((service) => { + if (service.mounted) { + service.mounted = !service.onRevoke(service.context); + } + }); + } +} + +const trackingManagerKey = Symbol(); + +/** + * Gets the TrackingManager from context, or creates one if it doesn't exist. + * @param initializer Optional initializer function to customize the TrackingManager. + * @returns The TrackingManager instance. + */ +export const getTrackingManager = (): TrackingManager => { + const saved = getContext(trackingManagerKey); + if (saved) return saved; + + console.debug('initializing a new TrackingManager'); + const manager = new TrackingManager(); + setContext(trackingManagerKey, manager); + return manager; +};