From 2a4ee6adedd4f51c9b699adccc3143d4472a340e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 11 May 2026 18:31:04 +0200 Subject: [PATCH] feat(PBI-79): pendingSprintDraft session-only + concept-entry + leave-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- __tests__/stores/user-settings.test.ts | 112 ++++++------------ app/(app)/products/[id]/page.tsx | 4 +- .../backlog/sprint-draft-leave-guard.tsx | 37 ++++++ components/shared/sprint-switcher.tsx | 21 ++++ stores/user-settings/store.ts | 37 +++--- 5 files changed, 114 insertions(+), 97 deletions(-) create mode 100644 components/backlog/sprint-draft-leave-guard.tsx diff --git a/__tests__/stores/user-settings.test.ts b/__tests__/stores/user-settings.test.ts index e504ac5..e159bf8 100644 --- a/__tests__/stores/user-settings.test.ts +++ b/__tests__/stores/user-settings.test.ts @@ -96,9 +96,8 @@ describe('useUserSettingsStore', () => { 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) - setDraftAction.mockResolvedValueOnce({ success: true }) const draft: PendingSprintDraft = { goal: 'Sprint 1', @@ -113,33 +112,16 @@ describe('useUserSettingsStore', () => { expect( s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], ).toMatchObject({ goal: 'Sprint 1' }) - expect(setDraftAction).toHaveBeenCalledWith('product-1', draft) + expect(setDraftAction).not.toHaveBeenCalled() }) - it('setPendingSprintDraft rolls back on server error', async () => { - 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 () => { + it('hydrate strips workflow.pendingSprintDraft uit legacy server-state', () => { useUserSettingsStore.getState().hydrate( { workflow: { pendingSprintDraft: { 'product-1': { - goal: 'Old', + goal: 'Legacy draft', pbiIntent: {}, storyOverrides: {}, }, @@ -148,7 +130,18 @@ describe('useUserSettingsStore', () => { }, 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 .getState() @@ -158,28 +151,19 @@ describe('useUserSettingsStore', () => { expect( s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], ).toBeUndefined() - expect(clearDraftAction).toHaveBeenCalledWith('product-1') + expect(clearDraftAction).not.toHaveBeenCalled() }) it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => { - useUserSettingsStore.getState().hydrate( - { - workflow: { - pendingSprintDraft: { - 'product-1': { - goal: 'g', - pbiIntent: { pbiA: 'none' }, - storyOverrides: { - pbiA: { add: ['s-1'], remove: [] }, - pbiB: { add: [], remove: ['s-2'] }, - }, - }, - }, - }, + useUserSettingsStore.getState().hydrate({}, false) + await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { + goal: 'g', + pbiIntent: { pbiA: 'none' }, + storyOverrides: { + pbiA: { add: ['s-1'], remove: [] }, + pbiB: { add: [], remove: ['s-2'] }, }, - false, - ) - setDraftAction.mockResolvedValue({ success: true }) + }) await useUserSettingsStore .getState() @@ -194,23 +178,14 @@ describe('useUserSettingsStore', () => { }) it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => { - useUserSettingsStore.getState().hydrate( - { - workflow: { - pendingSprintDraft: { - 'product-1': { - goal: 'g', - pbiIntent: {}, - storyOverrides: { - pbiA: { add: [], remove: ['story-1'] }, - }, - }, - }, - }, + useUserSettingsStore.getState().hydrate({}, false) + await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { + goal: 'g', + pbiIntent: {}, + storyOverrides: { + pbiA: { add: [], remove: ['story-1'] }, }, - false, - ) - setDraftAction.mockResolvedValue({ success: true }) + }) await useUserSettingsStore .getState() @@ -226,23 +201,14 @@ describe('useUserSettingsStore', () => { }) it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => { - useUserSettingsStore.getState().hydrate( - { - workflow: { - pendingSprintDraft: { - 'product-1': { - goal: 'g', - pbiIntent: {}, - storyOverrides: { - pbiA: { add: ['story-1'], remove: [] }, - }, - }, - }, - }, + useUserSettingsStore.getState().hydrate({}, false) + await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { + goal: 'g', + pbiIntent: {}, + storyOverrides: { + pbiA: { add: ['story-1'], remove: [] }, }, - false, - ) - setDraftAction.mockResolvedValue({ success: true }) + }) await useUserSettingsStore .getState() diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 1b52c7a..1b645bf 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -17,6 +17,7 @@ import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' 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 { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator' import { ActivateProductButton } from '@/components/shared/activate-product-button' @@ -152,8 +153,9 @@ export default async function ProductBacklogPage({ params, searchParams }: Props - {/* Sprint definition banner (state A′) */} + {/* Sprint definition banner (state A′) + beforeunload-guard */} + {/* Split pane */}
diff --git a/components/backlog/sprint-draft-leave-guard.tsx b/components/backlog/sprint-draft-leave-guard.tsx new file mode 100644 index 0000000..5daf9db --- /dev/null +++ b/components/backlog/sprint-draft-leave-guard.tsx @@ -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 +} diff --git a/components/shared/sprint-switcher.tsx b/components/shared/sprint-switcher.tsx index d809fa1..e726ce5 100644 --- a/components/shared/sprint-switcher.tsx +++ b/components/shared/sprint-switcher.tsx @@ -18,6 +18,7 @@ import { switchActiveSprintAction, } from '@/actions/active-sprint' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useUserSettingsStore } from '@/stores/user-settings/store' import type { SprintStatusApi } from '@/lib/task-status' import { debugProps } from '@/lib/debug' @@ -49,6 +50,13 @@ export function SprintSwitcher({ const [showClosed, setShowClosed] = useState(false) 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 => { if (showClosed) return true if (s.id === activeSprint?.id) return true @@ -161,6 +169,19 @@ export function SprintSwitcher({ Toon afgeronde sprints + {draftGoal && ( + <> + + ⚙ Concept — + {draftGoal} + + + + )} { 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.isDemo = isDemo }) @@ -92,7 +100,10 @@ export const useUserSettingsStore = create { - 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) => { if (!s.entities.settings.workflow) s.entities.settings.workflow = {} if (!s.entities.settings.workflow.pendingSprintDraft) { @@ -100,34 +111,14 @@ export const useUserSettingsStore = create { - s.entities.settings = prev as UserSettings - }) - } }, clearPendingSprintDraft: async (productId) => { - const prev = get().entities.settings as UserSettings + // PBI-79 scope-aanpassing: session-only — lokale delete is voldoende. 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) => {