Files
spectator/src/lib/tracking.svelte.ts
2025-12-17 22:18:29 -08:00

285 lines
7.5 KiB
TypeScript

import { browser } from '$app/environment';
import log from 'loglevel';
import { onDestroy, onMount, createContext } 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 {
/** tracking consent, persisted to localStorage by saveOpts */
private _consent: boolean | null = $state(null);
private _services: Record<string, InternalService<unknown>> = {};
private _changeCallbacks: Array<(consent: boolean | null) => void> = [];
private _consentQueue: Array<() => void> = [];
/**
* Saves consent state to localStorage if browser storage is available.
* Automatically called after updating consent.
* @throws Error if storage is not available.
*/
saveOpts(): TrackingManager {
if (!browser || !window?.localStorage) {
throw new Error('Cannot access localStorage to save tracking state');
}
window.localStorage.setItem(
'trackingOpts',
JSON.stringify({ consent: this._consent } as TrackingManagerOpts)
);
return this;
}
/**
* Loads tracking options from localStorage if available. Suitable to call
* after initialization, e.g. in onMount after a TrackingManager is created.
* @throws Error if storage is not available.
*/
loadOpts(): TrackingManager {
if (!browser || !window?.localStorage) {
throw new Error('Cannot access localStorage to load tracking state');
}
const raw = window.localStorage.getItem('trackingOpts');
if (raw) {
const opts = JSON.parse(raw) as TrackingManagerOpts;
if (opts.consent !== undefined && opts.consent !== null) {
this.setConsent(opts.consent);
}
log.debug('[TrackingManager] Loaded tracking options from storage:', opts);
}
return this;
}
/**
* Creates a TrackingManager instance.
* @param opts Optional initial options.
*/
constructor(opts?: TrackingManagerOpts) {
if (opts) {
if (opts.consent !== undefined) this._consent = opts.consent;
}
}
/**
* Creates a TrackingManager instance from localStorage data.
* @throws Error if storage is not available.
*/
static fromLocalStorage(): TrackingManager {
return new TrackingManager().loadOpts();
}
/** 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 consented. If set to true, all queued callbacks
* will be executed. Automatically persists to localStorage if available.
*/
setConsent(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);
});
this.saveOpts();
}
/**
* 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.
* @param callback The function to run when consent is granted.
*/
runWithConsent(callback: () => void) {
if (this._consent) callback();
else this._consentQueue.push(callback);
}
/**
* Runs callback onMount if we have consent already or queues it for later.
* Removes the callback from the queue onDestroy.
* @param callback The function to run when consent is granted.
*/
lifecycleWithConsent(callback: () => void) {
onMount(() => {
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 [getTrackingContext, setTrackingContext] = createContext<TrackingManager>();
/**
* Gets the TrackingManager from context, or creates one if it doesn't exist.
* If called from the browser, attempts to load saved state from localStorage.
* @returns The TrackingManager instance.
*/
export const getTrackingManager = (): TrackingManager => {
try {
const saved = getTrackingContext();
if (saved) {
log.debug('[TrackingManager] Using existing instance from context');
return saved;
}
} catch {
// ignore missing context, we'll create a new one
}
log.debug('[TrackingManager] Creating new instance');
const manager = $state(new TrackingManager());
setTrackingContext(manager);
if (browser) {
manager.loadOpts();
}
return manager;
};