From b793019ba7cb0e8cbafcbbc29a401cc573e7936f Mon Sep 17 00:00:00 2001
From: TO
Date: Thu, 6 Nov 2025 23:39:05 +0100
Subject: [PATCH] Refactor calendar settings service to utilize a new
storedSignal implementation for localStorage persistence and restore the
stored-signal utility
---
src/app/services/calendar-settings-service.ts | 25 +++---
src/app/shared/utils/stored-signal..ts | 18 ----
src/app/shared/utils/stored-signal.ts | 90 +++++++++++++++++++
3 files changed, 102 insertions(+), 31 deletions(-)
delete mode 100644 src/app/shared/utils/stored-signal..ts
create mode 100644 src/app/shared/utils/stored-signal.ts
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;
+}