diff --git a/src/app/services/calendar-settings-service.ts b/src/app/services/calendar-settings-service.ts index 5b8b20e..8bf82c3 100644 --- a/src/app/services/calendar-settings-service.ts +++ b/src/app/services/calendar-settings-service.ts @@ -1,5 +1,5 @@ -import { storedSignal } from '../shared/utils/stored-signal.'; import { Injectable, WritableSignal } from '@angular/core'; +import { storedSignal } from '../shared/utils/stored-signal'; interface SettingDefinition { defaultValue: boolean; @@ -29,30 +29,29 @@ export class CalendarSettingsService { private readonly signals = new Map>(); + readonly settingsArray: ReadonlyArray<{ + id: string; + signal: WritableSignal; + description: string; + }>; + constructor() { for (const [id, def] of this.definitions) { const storageKey = `calendarSettings.${id}`; this.signals.set(id, storedSignal(storageKey, def.defaultValue)); } - } - get(id: string): WritableSignal { - const signal = this.signals.get(id); - if (signal) { - return signal; - } - // Return a default signal if the ID is invalid - return storedSignal(`calendarSettings.${id}`, false); - } - - get settingsArray() { - return Array.from(this.definitions.entries()).map(([id, def]) => ({ + this.settingsArray = Array.from(this.definitions.entries()).map(([id, def]) => ({ id, signal: this.signals.get(id)!, description: def.description, })); } + get(id: string): WritableSignal { + return this.signals.get(id)!; + } + getDescription(id: string): string { return this.definitions.get(id)?.description ?? ''; } diff --git a/src/app/shared/utils/stored-signal..ts b/src/app/shared/utils/stored-signal..ts deleted file mode 100644 index e0a568e..0000000 --- a/src/app/shared/utils/stored-signal..ts +++ /dev/null @@ -1,18 +0,0 @@ -import { signal, effect, WritableSignal } from '@angular/core'; - -export function storedSignal(key: string, initialValue: T): WritableSignal { - // Try to restore the value from localStorage - const storedValue = localStorage.getItem(key); - const parsedValue = storedValue ? (JSON.parse(storedValue) as T) : initialValue; - - // Create the actual signal - const s = signal(parsedValue); - - // Keep localStorage in sync whenever the signal changes - effect(() => { - const value = s(); - localStorage.setItem(key, JSON.stringify(value)); - }); - - return s; -} diff --git a/src/app/shared/utils/stored-signal.ts b/src/app/shared/utils/stored-signal.ts new file mode 100644 index 0000000..a0c2573 --- /dev/null +++ b/src/app/shared/utils/stored-signal.ts @@ -0,0 +1,90 @@ +import { signal, effect, WritableSignal } from '@angular/core'; + +/** + * Checks if localStorage is available in the current environment. + * @returns true if localStorage is accessible, false otherwise + */ +function isLocalStorageAvailable(): boolean { + try { + return typeof localStorage !== 'undefined' && localStorage !== null; + } catch { + return false; + } +} + +/** + * Creates a signal that automatically persists its value to localStorage. + * + * The signal will: + * - Initialize with the stored value from localStorage if available + * - Fall back to the provided initial value if no stored value exists or parsing fails + * - Automatically sync changes to localStorage with debouncing (300ms delay) + * - Work safely in SSR and environments where localStorage is unavailable + * + * @template T - The type of value stored in the signal (must be JSON-serializable) + * @param key - The localStorage key used to store the value + * @param initialValue - The default value used when no stored value exists + * @param debounceMs - Optional debounce delay in milliseconds (default: 300ms) + * @returns A writable signal that persists to localStorage + * + * @example + * ```typescript + * const darkMode = storedSignal('theme.darkMode', false); + * darkMode.set(true); // Automatically saved to localStorage after 300ms + * console.log(darkMode()); // true + * ``` + * + * @remarks + * - Must be called within an injection context (uses Angular's `effect`) + * - Values must be JSON-serializable (no functions, circular references, etc.) + * - In SSR or when localStorage is unavailable, the signal works normally but won't persist + * - Corrupted localStorage data is automatically cleared and reset to initial value + * - localStorage writes are debounced to improve performance with frequent updates + */ +export function storedSignal(key: string, initialValue: T, debounceMs = 300): WritableSignal { + let parsedValue = initialValue; + + if (isLocalStorageAvailable()) { + try { + const storedValue = localStorage.getItem(key); + if (storedValue) { + parsedValue = JSON.parse(storedValue) as T; + } + } catch (error) { + console.warn( + `Failed to parse stored value for key "${key}". Using initial value.`, + error, + ); + try { + localStorage.removeItem(key); + } catch {} + } + } + + const s = signal(parsedValue); + + let timeoutId: ReturnType | null = null; + + effect(() => { + const value = s(); + + // Clear any pending timeout + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + + // Debounce the localStorage write + timeoutId = setTimeout(() => { + if (isLocalStorageAvailable()) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.warn(`Failed to save value for key "${key}" to localStorage.`, error); + } + } + timeoutId = null; + }, debounceMs); + }); + + return s; +}