Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/lib/settings/store.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,84 @@
'use client';

/**
* @module settings/store
*
* Zustand store for user application settings.
*
* ## Quick start
*
* ```tsx
* import { useSettingsStore } from '@/lib/settings/store';
*
* function MyComponent() {
* const theme = useSettingsStore((s) => s.settings.theme);
* const patchSettings = useSettingsStore((s) => s.patchSettings);
*
* return (
* <button onClick={() => patchSettings({ theme: 'dark' })}>
* Switch to dark mode
* </button>
* );
* }
* ```
*
* ## Persistence
*
* Settings are automatically persisted to `localStorage` under the key defined by
* `SETTINGS_STORAGE_KEY`. The store handles SSR gracefully by falling back to a no-op
* storage when `window` is unavailable.
*
* ## Sync
*
* Use `fetchRemoteSettings` / `pushRemoteSettings` from `@/lib/settings/sync` to
* synchronise settings with the server. `updatedAt` acts as a vector clock: the
* side with the larger value wins.
*
* ## Export / Import
*
* Use `buildExportEnvelope` / `parseExportedSettings` from `@/lib/settings/export-import`
* to serialise settings to a portable JSON file that users can download and re-import.
*/
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { SETTINGS_SCHEMA_VERSION, SETTINGS_STORAGE_KEY } from './constants';
import { type AppSettings, appSettingsSchema, createDefaultSettings } from './types';

interface SettingsStoreActions {
/**
* Merge a partial update into the current settings.
* Validates the merged result against `appSettingsSchema`; silently ignores invalid patches.
* `language` is trimmed and clamped to 24 chars; empty strings fall back to `'en'`.
* Automatically updates `updatedAt` to `Date.now()`.
*/
patchSettings: (partial: Partial<AppSettings>) => void;

/**
* Overwrite all settings with a validated `AppSettings` object (e.g. after a remote sync or import).
* Pass `markSynced = true` to set `lastSyncedAt` to `updatedAt` in the same write.
* Invalid settings objects are silently dropped.
*/
replaceSettings: (settings: AppSettings, updatedAt: number, markSynced?: boolean) => void;

/**
* Restore all settings to the application defaults (see `createDefaultSettings`).
* Resets `updatedAt` to `Date.now()` and clears `lastSyncedAt`.
*/
resetSettings: () => void;

/**
* Record the timestamp of the last successful remote sync.
* Pass `null` to clear the sync marker (e.g. after a reset).
*/
setLastSyncedAt: (t: number | null) => void;
}

interface SettingsSlice extends SettingsStoreActions {
/** Current user preferences. Always conforms to `appSettingsSchema`. */
settings: AppSettings;
/** Unix ms timestamp of the last local settings mutation. Used as a vector clock for sync. */
updatedAt: number;
/** Unix ms timestamp of the most recent successful remote sync, or `null` if never synced. */
lastSyncedAt: number | null;
}

Expand Down
32 changes: 32 additions & 0 deletions src/lib/settings/types.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { z } from 'zod';
import { SETTINGS_SCHEMA_VERSION } from './constants';

/** User-selectable colour scheme. `'system'` follows the OS preference. */
export const themePreferenceSchema = z.enum(['light', 'dark', 'system']);
export type ThemePreference = z.infer<typeof themePreferenceSchema>;

/**
* Validated schema for all user-configurable application settings.
*
* Fields:
* - `version` — Schema version; bumped when new fields are added (see `SETTINGS_SCHEMA_VERSION`).
* - `theme` — Colour scheme: `'light'`, `'dark'`, or `'system'` (follows OS preference).
* - `language` — BCP-47 locale tag (e.g. `'en'`, `'fr-CA'`), max 24 chars; defaults to `navigator.language`.
* - `notificationsEnabled` — Master toggle for in-app push/toast notifications.
* - `emailNotifications` — Whether transactional and digest emails should be sent.
* - `prefetchingEnabled` — Pre-fetches linked pages on hover for faster navigation; disable on slow connections.
* - `reducedMotion` — Suppresses non-essential animations for users who prefer reduced motion.
*/
export const appSettingsSchema = z.object({
version: z.literal(SETTINGS_SCHEMA_VERSION),
theme: themePreferenceSchema,
Expand All @@ -14,25 +27,44 @@ export const appSettingsSchema = z.object({
reducedMotion: z.boolean(),
});

/** Fully typed representation of all user settings. Inferred from `appSettingsSchema`. */
export type AppSettings = z.infer<typeof appSettingsSchema>;

/**
* Shape persisted by the Zustand store to `localStorage` (key: `SETTINGS_STORAGE_KEY`).
* Includes `updatedAt` and `lastSyncedAt` for conflict resolution during remote sync.
*/
export const settingsStoreStateSchema = z.object({
settings: appSettingsSchema,
/** Unix ms timestamp of the last local mutation. Used as a vector clock for sync. */
updatedAt: z.number(),
/** Unix ms timestamp of the last successful remote sync, or `null` if never synced. */
lastSyncedAt: z.number().nullable(),
});

export type SettingsStorePersistedShape = z.infer<typeof settingsStoreStateSchema>;

/**
* JSON envelope produced by `buildExportEnvelope` and consumed by `parseExportedSettings`.
* Including `exportedAt` and `version` allows future migrations to detect stale exports.
*/
export const exportedSettingsEnvelopeSchema = z.object({
version: z.literal(SETTINGS_SCHEMA_VERSION),
/** ISO-8601 UTC timestamp of when the file was exported. */
exportedAt: z.string(),
settings: appSettingsSchema,
updatedAt: z.number(),
});

export type ExportedSettingsEnvelope = z.infer<typeof exportedSettingsEnvelopeSchema>;

/**
* Returns an `AppSettings` object with safe, well-defined defaults.
*
* - `theme` defaults to `'system'` so the OS preference is respected out of the box.
* - `language` is read from `navigator.language` when available, falling back to `'en'`.
* - All notification and UX toggles default to their most permissive value.
*/
export function createDefaultSettings(): AppSettings {
return {
version: SETTINGS_SCHEMA_VERSION,
Expand Down
Loading