Scrum4Me/stores/user-settings/store.ts
Madhura68 2a4ee6aded feat(PBI-79): pendingSprintDraft session-only + concept-entry + leave-guard
Scope-aanpassing uit plan-revisie: drafts persisten niet meer server-side.

Wijzigingen:
- stores/user-settings/store.ts:
  - hydrate() strip nu workflow.pendingSprintDraft uit serverstate
    (legacy DB-entries blijven harmless aanwezig maar worden niet
    gehydreerd → effectief unreachable voor de UI).
  - setPendingSprintDraft / clearPendingSprintDraft worden lokale-only;
    geen import van sprint-draft-actions, geen server-roundtrip.
  - upsertPbiIntent / upsertStoryOverride blijven via setPendingSprintDraft
    routeren → ook session-only.
- components/shared/sprint-switcher.tsx: leest draft-goal uit user-settings
  store en toont '⚙ Concept — [goal]' als niet-selecteerbare entry
  bovenaan de dropdown zolang er een draft loopt.
- components/backlog/sprint-draft-leave-guard.tsx (nieuw): registreert
  een beforeunload-listener zolang er een draft is. Browser-refresh,
  tab-close en back-navigatie tonen daarmee de standaard confirm. In-app
  route-changes blijven via de banner-Annuleren-knop lopen.
- app/(app)/products/[id]/page.tsx: SprintDraftLeaveGuard gemount naast
  de banner.
- Tests: user-settings store-tests aangepast (geen server-call assert
  meer, hydrate strip-assert toegevoegd; upsert-tests seed nu via
  setPendingSprintDraft i.p.v. legacy hydrate).

setPendingSprintDraftAction + clearPendingSprintDraftAction blijven bestaan
voor eventuele toekomstige opruim-flows, maar worden niet meer aangeroepen
vanuit de UI.

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

215 lines
6.8 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) => {
// 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
}
})
},
})),
)