From 095462c80da335339f4f723e028dfb3af47ff139 Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Wed, 17 Dec 2025 22:18:29 -0800 Subject: [PATCH] tracking manager: add localStorage persistence --- src/lib/tracking.svelte.ts | 104 +++++++++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 15 deletions(-) diff --git a/src/lib/tracking.svelte.ts b/src/lib/tracking.svelte.ts index 4a8d0a2..3e30ca8 100644 --- a/src/lib/tracking.svelte.ts +++ b/src/lib/tracking.svelte.ts @@ -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. @@ -36,17 +38,69 @@ type InternalService = Service & { * 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> = {}; 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; @@ -78,7 +132,7 @@ export class TrackingManager { /** * 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) { if (this._consent === value) return; @@ -95,6 +149,7 @@ export class TrackingManager { this._changeCallbacks.forEach((cb) => { 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. - * 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); - } + 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); }); @@ -192,19 +254,31 @@ export class TrackingManager { } } -const trackingManagerKey = Symbol(); +const [getTrackingContext, setTrackingContext] = createContext(); /** * 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. */ export const getTrackingManager = (): TrackingManager => { - const saved = getContext(trackingManagerKey); - if (saved) return saved; + 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(); + } - console.debug('initializing a new TrackingManager'); - const manager = new TrackingManager(); - setContext(trackingManagerKey, manager); return manager; };