import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' import { DEFAULT_USER_SETTINGS, mergeSettings, type PbiIntent, type PendingSprintDraft, 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 setPendingSprintDraft: ( productId: string, draft: PendingSprintDraft, ) => Promise clearPendingSprintDraft: (productId: string) => Promise upsertPbiIntent: ( productId: string, pbiId: string, intent: PbiIntent, ) => Promise upsertStoryOverride: ( productId: string, pbiId: string, storyId: string, kind: 'add' | 'remove' | 'clear', ) => Promise } 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) => { // PBI-79 scope-aanpassing: pendingSprintDraft is session-only; // eventuele legacy DB-entries van vóór deze aanpassing worden bij // hydratatie weggegooid zodat de draft niet 'spookt'. const stripped: UserSettings = { ...initial } if (stripped.workflow?.pendingSprintDraft) { stripped.workflow = { ...stripped.workflow } delete stripped.workflow.pendingSprintDraft } draft.entities.settings = stripped draft.context.hydrated = true draft.context.isDemo = isDemo }) }, applyServerPatch: (patch) => { set((draft) => { draft.entities.settings = mergeSettings( draft.entities.settings as UserSettings, patch, ) as UserSettings }) }, setPendingSprintDraft: async (productId, draft) => { // PBI-79 scope-aanpassing: session-only. Geen server-roundtrip; // de draft leeft uitsluitend in deze store-instantie en is bij // page-refresh/leave weg (zie SprintDraftLeaveGuard voor de // beforeunload-warning). set((s) => { if (!s.entities.settings.workflow) s.entities.settings.workflow = {} if (!s.entities.settings.workflow.pendingSprintDraft) { s.entities.settings.workflow.pendingSprintDraft = {} } s.entities.settings.workflow.pendingSprintDraft[productId] = draft }) }, clearPendingSprintDraft: async (productId) => { // PBI-79 scope-aanpassing: session-only — lokale delete is voldoende. set((s) => { const map = s.entities.settings.workflow?.pendingSprintDraft if (map) delete map[productId] }) }, upsertPbiIntent: async (productId, pbiId, intent) => { const current = get().entities.settings.workflow?.pendingSprintDraft?.[productId] if (!current) return const nextOverrides = { ...current.storyOverrides } delete nextOverrides[pbiId] const next: PendingSprintDraft = { ...current, pbiIntent: { ...current.pbiIntent, [pbiId]: intent }, storyOverrides: nextOverrides, } await get().setPendingSprintDraft(productId, next) }, upsertStoryOverride: async (productId, pbiId, storyId, kind) => { const current = get().entities.settings.workflow?.pendingSprintDraft?.[productId] if (!current) return const existing = current.storyOverrides[pbiId] ?? { add: [], remove: [] } const dropFrom = (arr: string[]) => arr.filter((id) => id !== storyId) let nextEntry: { add: string[]; remove: string[] } switch (kind) { case 'add': nextEntry = { add: existing.add.includes(storyId) ? existing.add : [...existing.add, storyId], remove: dropFrom(existing.remove), } break case 'remove': nextEntry = { add: dropFrom(existing.add), remove: existing.remove.includes(storyId) ? existing.remove : [...existing.remove, storyId], } break case 'clear': default: nextEntry = { add: dropFrom(existing.add), remove: dropFrom(existing.remove) } break } const nextOverrides = { ...current.storyOverrides } if (nextEntry.add.length === 0 && nextEntry.remove.length === 0) { delete nextOverrides[pbiId] } else { nextOverrides[pbiId] = nextEntry } const next: PendingSprintDraft = { ...current, storyOverrides: nextOverrides } await get().setPendingSprintDraft(productId, next) }, 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 } }) }, })), )