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>
This commit is contained in:
Janpeter Visser 2026-05-11 18:31:04 +02:00
parent e4252cad3e
commit 2a4ee6aded
5 changed files with 114 additions and 97 deletions

View file

@ -96,9 +96,8 @@ describe('useUserSettingsStore', () => {
expect(updateAction).not.toHaveBeenCalled() expect(updateAction).not.toHaveBeenCalled()
}) })
it('setPendingSprintDraft persists draft optimistically + calls action', async () => { it('setPendingSprintDraft persists draft lokaal (session-only, geen server-call)', async () => {
useUserSettingsStore.getState().hydrate({}, false) useUserSettingsStore.getState().hydrate({}, false)
setDraftAction.mockResolvedValueOnce({ success: true })
const draft: PendingSprintDraft = { const draft: PendingSprintDraft = {
goal: 'Sprint 1', goal: 'Sprint 1',
@ -113,33 +112,16 @@ describe('useUserSettingsStore', () => {
expect( expect(
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
).toMatchObject({ goal: 'Sprint 1' }) ).toMatchObject({ goal: 'Sprint 1' })
expect(setDraftAction).toHaveBeenCalledWith('product-1', draft) expect(setDraftAction).not.toHaveBeenCalled()
}) })
it('setPendingSprintDraft rolls back on server error', async () => { it('hydrate strips workflow.pendingSprintDraft uit legacy server-state', () => {
useUserSettingsStore.getState().hydrate({}, false)
setDraftAction.mockResolvedValueOnce({ error: 'boom' })
const draft: PendingSprintDraft = {
goal: 'Sprint X',
pbiIntent: {},
storyOverrides: {},
}
await useUserSettingsStore
.getState()
.setPendingSprintDraft('product-1', draft)
const s = useUserSettingsStore.getState()
expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined()
})
it('clearPendingSprintDraft removes key optimistically', async () => {
useUserSettingsStore.getState().hydrate( useUserSettingsStore.getState().hydrate(
{ {
workflow: { workflow: {
pendingSprintDraft: { pendingSprintDraft: {
'product-1': { 'product-1': {
goal: 'Old', goal: 'Legacy draft',
pbiIntent: {}, pbiIntent: {},
storyOverrides: {}, storyOverrides: {},
}, },
@ -148,7 +130,18 @@ describe('useUserSettingsStore', () => {
}, },
false, false,
) )
clearDraftAction.mockResolvedValueOnce({ success: true })
const s = useUserSettingsStore.getState()
expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined()
})
it('clearPendingSprintDraft verwijdert de key lokaal zonder server-call', async () => {
useUserSettingsStore.getState().hydrate({}, false)
await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
goal: 'Old',
pbiIntent: {},
storyOverrides: {},
})
await useUserSettingsStore await useUserSettingsStore
.getState() .getState()
@ -158,28 +151,19 @@ describe('useUserSettingsStore', () => {
expect( expect(
s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], s.entities.settings.workflow?.pendingSprintDraft?.['product-1'],
).toBeUndefined() ).toBeUndefined()
expect(clearDraftAction).toHaveBeenCalledWith('product-1') expect(clearDraftAction).not.toHaveBeenCalled()
}) })
it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => { it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => {
useUserSettingsStore.getState().hydrate( useUserSettingsStore.getState().hydrate({}, false)
{ await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
workflow: { goal: 'g',
pendingSprintDraft: { pbiIntent: { pbiA: 'none' },
'product-1': { storyOverrides: {
goal: 'g', pbiA: { add: ['s-1'], remove: [] },
pbiIntent: { pbiA: 'none' }, pbiB: { add: [], remove: ['s-2'] },
storyOverrides: {
pbiA: { add: ['s-1'], remove: [] },
pbiB: { add: [], remove: ['s-2'] },
},
},
},
},
}, },
false, })
)
setDraftAction.mockResolvedValue({ success: true })
await useUserSettingsStore await useUserSettingsStore
.getState() .getState()
@ -194,23 +178,14 @@ describe('useUserSettingsStore', () => {
}) })
it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => { it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => {
useUserSettingsStore.getState().hydrate( useUserSettingsStore.getState().hydrate({}, false)
{ await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
workflow: { goal: 'g',
pendingSprintDraft: { pbiIntent: {},
'product-1': { storyOverrides: {
goal: 'g', pbiA: { add: [], remove: ['story-1'] },
pbiIntent: {},
storyOverrides: {
pbiA: { add: [], remove: ['story-1'] },
},
},
},
},
}, },
false, })
)
setDraftAction.mockResolvedValue({ success: true })
await useUserSettingsStore await useUserSettingsStore
.getState() .getState()
@ -226,23 +201,14 @@ describe('useUserSettingsStore', () => {
}) })
it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => { it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => {
useUserSettingsStore.getState().hydrate( useUserSettingsStore.getState().hydrate({}, false)
{ await useUserSettingsStore.getState().setPendingSprintDraft('product-1', {
workflow: { goal: 'g',
pendingSprintDraft: { pbiIntent: {},
'product-1': { storyOverrides: {
goal: 'g', pbiA: { add: ['story-1'], remove: [] },
pbiIntent: {},
storyOverrides: {
pbiA: { add: ['story-1'], remove: [] },
},
},
},
},
}, },
false, })
)
setDraftAction.mockResolvedValue({ success: true })
await useUserSettingsStore await useUserSettingsStore
.getState() .getState()

View file

@ -17,6 +17,7 @@ import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner' import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner'
import { SprintDraftLeaveGuard } from '@/components/backlog/sprint-draft-leave-guard'
import { SaveSprintButton } from '@/components/backlog/save-sprint-button' import { SaveSprintButton } from '@/components/backlog/save-sprint-button'
import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator' import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator'
import { ActivateProductButton } from '@/components/shared/activate-product-button' import { ActivateProductButton } from '@/components/shared/activate-product-button'
@ -152,8 +153,9 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
</div> </div>
</div> </div>
{/* Sprint definition banner (state A) */} {/* Sprint definition banner (state A) + beforeunload-guard */}
<SprintDraftBanner productId={id} /> <SprintDraftBanner productId={id} />
<SprintDraftLeaveGuard productId={id} />
{/* Split pane */} {/* Split pane */}
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">

View file

@ -0,0 +1,37 @@
'use client'
import { useEffect } from 'react'
import { useUserSettingsStore } from '@/stores/user-settings/store'
interface SprintDraftLeaveGuardProps {
productId: string
}
/**
* PBI-79: window.beforeunload-waarschuwing zolang er een pendingSprintDraft
* loopt voor dit product. De draft is session-only en gaat verloren bij
* refresh/close deze guard zorgt dat de gebruiker dat eerst bevestigt.
* Voor in-app route-changes (klikken op een andere product) doet Next.js
* geen onbeforeunload; daar vangen we het op via de banner-Annuleren-flow.
*/
export function SprintDraftLeaveGuard({
productId,
}: SprintDraftLeaveGuardProps) {
const hasDraft = useUserSettingsStore(
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
useEffect(() => {
if (!hasDraft) return
function handler(e: BeforeUnloadEvent) {
e.preventDefault()
// Moderne browsers tonen een eigen vertaalde tekst; returnValue is
// alleen nodig voor legacy compat.
e.returnValue = ''
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [hasDraft])
return null
}

View file

@ -18,6 +18,7 @@ import {
switchActiveSprintAction, switchActiveSprintAction,
} from '@/actions/active-sprint' } from '@/actions/active-sprint'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import type { SprintStatusApi } from '@/lib/task-status' import type { SprintStatusApi } from '@/lib/task-status'
import { debugProps } from '@/lib/debug' import { debugProps } from '@/lib/debug'
@ -49,6 +50,13 @@ export function SprintSwitcher({
const [showClosed, setShowClosed] = useState(false) const [showClosed, setShowClosed] = useState(false)
const buildingSet = new Set(buildingSprintIds) const buildingSet = new Set(buildingSprintIds)
// PBI-79: zolang er een sprint-draft loopt tonen we 'Concept — [goal]'
// bovenaan de dropdown. De draft staat alleen in deze session-store; bij
// page-refresh/leave is hij weg.
const draftGoal = useUserSettingsStore(
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal ?? null,
)
const visibleSprints = sprints.filter(s => { const visibleSprints = sprints.filter(s => {
if (showClosed) return true if (showClosed) return true
if (s.id === activeSprint?.id) return true if (s.id === activeSprint?.id) return true
@ -161,6 +169,19 @@ export function SprintSwitcher({
Toon afgeronde sprints Toon afgeronde sprints
</button> </button>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{draftGoal && (
<>
<DropdownMenuItem
disabled
className="italic text-tertiary opacity-90 cursor-default"
data-debug-id="sprint-switcher__concept"
>
<span className="shrink-0"> Concept </span>
<span className="truncate">{draftGoal}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={handleClearActiveSprint} onClick={handleClearActiveSprint}
disabled={!activeSprint || isPending} disabled={!activeSprint || isPending}

View file

@ -76,7 +76,15 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
hydrate: (initial, isDemo) => { hydrate: (initial, isDemo) => {
set((draft) => { set((draft) => {
draft.entities.settings = initial as UserSettings // 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.hydrated = true
draft.context.isDemo = isDemo draft.context.isDemo = isDemo
}) })
@ -92,7 +100,10 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
}, },
setPendingSprintDraft: async (productId, draft) => { setPendingSprintDraft: async (productId, draft) => {
const prev = get().entities.settings as UserSettings // 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) => { set((s) => {
if (!s.entities.settings.workflow) s.entities.settings.workflow = {} if (!s.entities.settings.workflow) s.entities.settings.workflow = {}
if (!s.entities.settings.workflow.pendingSprintDraft) { if (!s.entities.settings.workflow.pendingSprintDraft) {
@ -100,34 +111,14 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
} }
s.entities.settings.workflow.pendingSprintDraft[productId] = draft 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) => { clearPendingSprintDraft: async (productId) => {
const prev = get().entities.settings as UserSettings // PBI-79 scope-aanpassing: session-only — lokale delete is voldoende.
set((s) => { set((s) => {
const map = s.entities.settings.workflow?.pendingSprintDraft const map = s.entities.settings.workflow?.pendingSprintDraft
if (map) delete map[productId] 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) => { upsertPbiIntent: async (productId, pbiId, intent) => {