add tracking manager

This commit is contained in:
Elijah Duffy
2025-12-16 17:21:27 -08:00
parent 12c43b6eb0
commit 086326326c

210
src/lib/tracking.svelte.ts Normal file
View File

@@ -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<T> = (context?: T) => boolean;
/**
* Definition of a tracking service with consent and revoke callbacks.
*/
export type Service<T> = {
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<T>;
/**
* Called when the user revokes consent to tracking. Return true to mark
* the service as unmounted.
*/
onRevoke: ServiceCallback<T>;
};
type InternalService<T> = Service<T> & {
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<string, InternalService<unknown>> = {};
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<T>(service: Service<T>): string {
const s: InternalService<T> = {
...service,
mounted: false,
sid: crypto.randomUUID()
};
if (this._consent) {
s.mounted = s.onConsent();
}
this._services[s.sid] = s as InternalService<unknown>;
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<T>(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<TrackingManager>(trackingManagerKey);
if (saved) return saved;
console.debug('initializing a new TrackingManager');
const manager = new TrackingManager();
setContext(trackingManagerKey, manager);
return manager;
};