tracking manager: add localStorage persistence
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { setContext, getContext, onDestroy } from 'svelte';
|
import { browser } from '$app/environment';
|
||||||
|
import log from 'loglevel';
|
||||||
|
import { onDestroy, onMount, createContext } from 'svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for initializing the TrackingManager.
|
* Options for initializing the TrackingManager.
|
||||||
@@ -36,17 +38,69 @@ type InternalService<T> = Service<T> & {
|
|||||||
* Manages user tracking preferences and services that require consent.
|
* Manages user tracking preferences and services that require consent.
|
||||||
*/
|
*/
|
||||||
export class TrackingManager {
|
export class TrackingManager {
|
||||||
|
/** tracking consent, persisted to localStorage by saveOpts */
|
||||||
private _consent: boolean | null = $state(null);
|
private _consent: boolean | null = $state(null);
|
||||||
private _services: Record<string, InternalService<unknown>> = {};
|
private _services: Record<string, InternalService<unknown>> = {};
|
||||||
private _changeCallbacks: Array<(consent: boolean | null) => void> = [];
|
private _changeCallbacks: Array<(consent: boolean | null) => void> = [];
|
||||||
private _consentQueue: Array<() => 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) {
|
constructor(opts?: TrackingManagerOpts) {
|
||||||
if (opts) {
|
if (opts) {
|
||||||
if (opts.consent !== undefined) this._consent = opts.consent;
|
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. */
|
/** Indicates whether tracking is currently allowed. */
|
||||||
get consent() {
|
get consent() {
|
||||||
return this._consent;
|
return this._consent;
|
||||||
@@ -78,7 +132,7 @@ export class TrackingManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets whether tracking is consented. If set to true, all queued callbacks
|
* Sets whether tracking is consented. If set to true, all queued callbacks
|
||||||
* will be executed.
|
* will be executed. Automatically persists to localStorage if available.
|
||||||
*/
|
*/
|
||||||
setConsent(value: boolean) {
|
setConsent(value: boolean) {
|
||||||
if (this._consent === value) return;
|
if (this._consent === value) return;
|
||||||
@@ -95,6 +149,7 @@ export class TrackingManager {
|
|||||||
this._changeCallbacks.forEach((cb) => {
|
this._changeCallbacks.forEach((cb) => {
|
||||||
cb(this._consent);
|
cb(this._consent);
|
||||||
});
|
});
|
||||||
|
this.saveOpts();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,16 +168,23 @@ export class TrackingManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs callback immediately if we have consent already or queues it for later.
|
* 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.
|
* @param callback The function to run when consent is granted.
|
||||||
*/
|
*/
|
||||||
runWithConsent(callback: () => void) {
|
runWithConsent(callback: () => void) {
|
||||||
if (this._consent) {
|
if (this._consent) callback();
|
||||||
callback();
|
else this._consentQueue.push(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(() => {
|
onDestroy(() => {
|
||||||
this._consentQueue = this._consentQueue.filter((cb) => cb !== callback);
|
this._consentQueue = this._consentQueue.filter((cb) => cb !== callback);
|
||||||
});
|
});
|
||||||
@@ -192,19 +254,31 @@ export class TrackingManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackingManagerKey = Symbol();
|
const [getTrackingContext, setTrackingContext] = createContext<TrackingManager>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the TrackingManager from context, or creates one if it doesn't exist.
|
* Gets the TrackingManager from context, or creates one if it doesn't exist.
|
||||||
* @param initializer Optional initializer function to customize the TrackingManager.
|
* If called from the browser, attempts to load saved state from localStorage.
|
||||||
* @returns The TrackingManager instance.
|
* @returns The TrackingManager instance.
|
||||||
*/
|
*/
|
||||||
export const getTrackingManager = (): TrackingManager => {
|
export const getTrackingManager = (): TrackingManager => {
|
||||||
const saved = getContext<TrackingManager>(trackingManagerKey);
|
try {
|
||||||
if (saved) return saved;
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
console.debug('initializing a new TrackingManager');
|
|
||||||
const manager = new TrackingManager();
|
|
||||||
setContext(trackingManagerKey, manager);
|
|
||||||
return manager;
|
return manager;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user