diff --git a/stores/user-settings/selectors.ts b/stores/user-settings/selectors.ts new file mode 100644 index 0000000..24ddb80 --- /dev/null +++ b/stores/user-settings/selectors.ts @@ -0,0 +1,23 @@ +import type { UserSettings } from '@/lib/user-settings' + +interface StateLike { + entities: { settings: UserSettings } + context: { hydrated: boolean; isDemo: boolean } +} + +export const selectSprintBacklogPrefs = (s: StateLike) => + s.entities.settings.views?.sprintBacklog ?? {} + +export const selectPbiListPrefs = (s: StateLike) => + s.entities.settings.views?.pbiList ?? {} + +export const selectStoryPanelPrefs = (s: StateLike) => + s.entities.settings.views?.storyPanel ?? {} + +export const selectJobsColumnPrefs = (key: string) => (s: StateLike) => + s.entities.settings.views?.jobsColumns?.[key] ?? { kinds: [], statuses: [] } + +export const selectDevToolsPrefs = (s: StateLike) => + s.entities.settings.devTools ?? {} + +export const selectHydrated = (s: StateLike) => s.context.hydrated diff --git a/stores/user-settings/store.ts b/stores/user-settings/store.ts new file mode 100644 index 0000000..abdb038 --- /dev/null +++ b/stores/user-settings/store.ts @@ -0,0 +1,116 @@ +import { create } from 'zustand' +import { immer } from 'zustand/middleware/immer' + +import { + DEFAULT_USER_SETTINGS, + mergeSettings, + type UserSettings, +} from '@/lib/user-settings' +import { updateUserSettingsAction } from '@/actions/user-settings' + +type SettingsPath = readonly (string | number)[] + +interface PendingMutation { + id: number + prev: UserSettings +} + +interface UserSettingsState { + entities: { settings: UserSettings } + context: { + hydrated: boolean + isDemo: boolean + } + pendingMutations: Record +} + +interface UserSettingsActions { + hydrate: (initial: UserSettings, isDemo: boolean) => void + setPref: (path: SettingsPath, value: unknown) => Promise + applyServerPatch: (patch: Partial) => void +} + +let nextMutationId = 1 + +function patchFromPath(path: SettingsPath, value: unknown): Partial { + if (path.length === 0) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Partial + } + return {} + } + const out: Record = {} + let cursor: Record = out + for (let i = 0; i < path.length - 1; i++) { + const key = String(path[i]) + cursor[key] = {} + cursor = cursor[key] as Record + } + cursor[String(path[path.length - 1])] = value + return out as Partial +} + +export const useUserSettingsStore = create()( + immer((set, get) => ({ + entities: { settings: DEFAULT_USER_SETTINGS }, + context: { hydrated: false, isDemo: false }, + pendingMutations: {}, + + hydrate: (initial, isDemo) => { + set((draft) => { + draft.entities.settings = initial as UserSettings + draft.context.hydrated = true + draft.context.isDemo = isDemo + }) + }, + + applyServerPatch: (patch) => { + set((draft) => { + draft.entities.settings = mergeSettings( + draft.entities.settings as UserSettings, + patch, + ) as UserSettings + }) + }, + + setPref: async (path, value) => { + const patch = patchFromPath(path, value) + + // Demo: lokale merge zonder server-call. + if (get().context.isDemo) { + set((draft) => { + draft.entities.settings = mergeSettings( + draft.entities.settings as UserSettings, + patch, + ) as UserSettings + }) + return + } + + const mutationId = nextMutationId++ + const prev = get().entities.settings as UserSettings + + // Optimistic. + set((draft) => { + draft.entities.settings = mergeSettings( + draft.entities.settings as UserSettings, + patch, + ) as UserSettings + draft.pendingMutations[mutationId] = { id: mutationId, prev } + }) + + const result = await updateUserSettingsAction(patch) + + set((draft) => { + delete draft.pendingMutations[mutationId] + if ('error' in result) { + // Rollback alleen als geen latere mutatie de waarde alweer heeft overschreven. + draft.entities.settings = prev as UserSettings + } else { + // Settle: server-merge is autoritatief. + draft.entities.settings = result.settings as UserSettings + } + }) + }, + })), +)