Scrum4Me/stores/user-settings/store.ts
Madhura68 56c55e1813 feat(PBI-79/ST-1334): user-settings pendingSprintDraft-slot
- lib/user-settings.ts: nieuw workflow.pendingSprintDraft veld met
  compacte intent-shape (pbiIntent + per-PBI storyOverrides).
- actions/sprint-draft.ts: setPendingSprintDraftAction +
  clearPendingSprintDraftAction met product-membership-check + Zod-validatie.
- stores/user-settings/store.ts: setPendingSprintDraft / clearPendingSprintDraft
  optimistic acties + fine-grained mutators upsertPbiIntent / upsertStoryOverride.
  Sprint-draft actions worden dynamisch geïmporteerd zodat jsdom-tests
  zonder DATABASE_URL niet falen.
- Tests: nieuwe sprint-draft.test.ts (action-laag), uitbreiding
  user-settings store-tests (5 nieuwe cases) en schema-tests (4 cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:43:32 +02:00

224 lines
6.9 KiB
TypeScript

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<number, PendingMutation>
}
interface UserSettingsActions {
hydrate: (initial: UserSettings, isDemo: boolean) => void
setPref: (path: SettingsPath, value: unknown) => Promise<void>
applyServerPatch: (patch: Partial<UserSettings>) => void
setPendingSprintDraft: (
productId: string,
draft: PendingSprintDraft,
) => Promise<void>
clearPendingSprintDraft: (productId: string) => Promise<void>
upsertPbiIntent: (
productId: string,
pbiId: string,
intent: PbiIntent,
) => Promise<void>
upsertStoryOverride: (
productId: string,
pbiId: string,
storyId: string,
kind: 'add' | 'remove' | 'clear',
) => Promise<void>
}
let nextMutationId = 1
function patchFromPath(path: SettingsPath, value: unknown): Partial<UserSettings> {
if (path.length === 0) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Partial<UserSettings>
}
return {}
}
const out: Record<string, unknown> = {}
let cursor: Record<string, unknown> = out
for (let i = 0; i < path.length - 1; i++) {
const key = String(path[i])
cursor[key] = {}
cursor = cursor[key] as Record<string, unknown>
}
cursor[String(path[path.length - 1])] = value
return out as Partial<UserSettings>
}
export const useUserSettingsStore = create<UserSettingsState & UserSettingsActions>()(
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
})
},
setPendingSprintDraft: async (productId, draft) => {
const prev = get().entities.settings as UserSettings
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
})
if (get().context.isDemo) return
const { setPendingSprintDraftAction } = await import(
'@/actions/sprint-draft'
)
const result = await setPendingSprintDraftAction(productId, draft)
if ('error' in result) {
set((s) => {
s.entities.settings = prev as UserSettings
})
}
},
clearPendingSprintDraft: async (productId) => {
const prev = get().entities.settings as UserSettings
set((s) => {
const map = s.entities.settings.workflow?.pendingSprintDraft
if (map) delete map[productId]
})
if (get().context.isDemo) return
const { clearPendingSprintDraftAction } = await import(
'@/actions/sprint-draft'
)
const result = await clearPendingSprintDraftAction(productId)
if ('error' in result) {
set((s) => {
s.entities.settings = prev as UserSettings
})
}
},
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
}
})
},
})),
)