From b210a3b92485048538a8105c2fd0b1c3980af330 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sat, 9 May 2026 15:57:00 +0200 Subject: [PATCH] feat(PBI-71): UX-fix 'lege sprint' + sprint-switch data-refresh - StartSprintButton dialog toont 3-state banner: info met accurate vrije- stories count + PBI-context, of waarschuwing als geen PBI geselecteerd is, of waarschuwing als de geselecteerde PBI 0 vrije stories heeft - Voeg sprint_id toe aan BacklogStory/Story/SprintStory + select in PB- pagina's en sprint-board mappings, zodat de banner accuraat kan tellen - createSprintAction: revalidatePath met 'layout' flag voor consistency met createSprintWithPbisAction (top-nav 'Sprint' link ververst direct) Sprint-switch data-refresh op alle relevante pagina's: - BacklogHydrationWrapper: fingerprint-based re-hydratie zodat PB-data na router.refresh opnieuw uit nieuwe initialData komt (was: useEffect met lege deps draaide alleen 1x) - SprintBoardClient: key={sprint.id} forceert remount bij sprint-switch zodat lokale sprintStories/sprintStoryIds-state vers ge-init wordt - Solo (desktop + mobile): gebruik resolveActiveSprint(id) ipv eerste OPEN-sprint, plus key={sprint.id} op SoloBoard voor remount Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/api/backlog-realtime.test.ts | 4 +-- .../components/backlog/integration.test.tsx | 2 +- __tests__/realtime/payload-contract.test.ts | 1 + actions/sprints.ts | 2 +- app/(app)/products/[id]/page.tsx | 1 + app/(app)/products/[id]/solo/page.tsx | 9 ++++-- .../products/[id]/sprint/[sprintId]/page.tsx | 3 ++ app/(mobile)/m/products/[id]/page.tsx | 1 + app/(mobile)/m/products/[id]/solo/page.tsx | 9 ++++-- .../backlog/backlog-hydration-wrapper.tsx | 23 ++++++++++++--- components/backlog/story-panel.tsx | 1 + components/sprint/sprint-backlog.tsx | 1 + components/sprint/start-sprint-button.tsx | 28 +++++++++++++++++++ stores/backlog-store.ts | 1 + 14 files changed, 72 insertions(+), 14 deletions(-) diff --git a/__tests__/api/backlog-realtime.test.ts b/__tests__/api/backlog-realtime.test.ts index 4898cda..f9d0bfe 100644 --- a/__tests__/api/backlog-realtime.test.ts +++ b/__tests__/api/backlog-realtime.test.ts @@ -110,13 +110,13 @@ describe('shouldEmit scope filter (via backlog-store reducer)', () => { it('applyChange: story INSERT adds to storiesByPbi', () => { useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) - const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', sprint_id: null, created_at: new Date() } useBacklogStore.getState().applyChange('story', 'I', story) expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) }) it('applyChange: story DELETE removes from correct pbi bucket', () => { - const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', sprint_id: null, created_at: new Date() } useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} }) useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) diff --git a/__tests__/components/backlog/integration.test.tsx b/__tests__/components/backlog/integration.test.tsx index 928ccce..feab76c 100644 --- a/__tests__/components/backlog/integration.test.tsx +++ b/__tests__/components/backlog/integration.test.tsx @@ -62,7 +62,7 @@ const ALT_PBI_ID = 'pbi-2' const STORY_ID = 'story-1' const STORIES = [ - { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, created_at: new Date() }, + { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() }, ] const TASKS = [ { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, diff --git a/__tests__/realtime/payload-contract.test.ts b/__tests__/realtime/payload-contract.test.ts index b36bc09..3835903 100644 --- a/__tests__/realtime/payload-contract.test.ts +++ b/__tests__/realtime/payload-contract.test.ts @@ -21,6 +21,7 @@ const STORY: BacklogStory = { priority: 2, status: 'OPEN', pbi_id: 'pbi-1', + sprint_id: null, created_at: new Date('2024-01-01T00:00:00Z'), } diff --git a/actions/sprints.ts b/actions/sprints.ts index de096d3..1be5ef5 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -103,7 +103,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData } await setActiveSprintCookie(parsed.data.productId, sprint.id) - revalidatePath(`/products/${parsed.data.productId}`) + revalidatePath(`/products/${parsed.data.productId}`, 'layout') return { success: true, sprintId: sprint.id } } diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index a8e79e5..8731a53 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -61,6 +61,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props priority: true, status: true, pbi_id: true, + sprint_id: true, created_at: true, }, }), diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 8af037d..83a1720 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' +import { resolveActiveSprint } from '@/lib/active-sprint' import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' import { SoloBoard } from '@/components/solo/solo-board' import { NoActiveSprint } from '@/components/solo/no-active-sprint' @@ -21,9 +22,10 @@ export default async function SoloProductPage({ params }: Props) { const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const sprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'OPEN' }, - }) + const active = await resolveActiveSprint(id) + const sprint = active + ? await prisma.sprint.findFirst({ where: { id: active.id, product_id: id } }) + : null const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint?.id ?? null }) @@ -126,6 +128,7 @@ export default async function SoloProductPage({ params }: Props) { {switcherBar}
`${p.id}:${p.status}:${p.priority}`).join(',') + const storyPart = Object.entries(data.storiesByPbi) + .flatMap(([, list]) => list.map((s) => `${s.id}:${s.status}:${s.sprint_id ?? 'null'}`)) + .join(',') + const taskPart = Object.entries(data.tasksByStory) + .flatMap(([, list]) => list.map((t) => `${t.id}:${t.status}`)) + .join(',') + return `${pbiPart}|${storyPart}|${taskPart}` +} + export function BacklogHydrationWrapper({ initialData, productId, children }: BacklogHydrationWrapperProps) { const setInitialData = useBacklogStore((s) => s.setInitialData) + const lastFingerprint = useRef('') useEffect(() => { - setInitialData(initialData) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + const fp = fingerprint(initialData) + if (fp !== lastFingerprint.current) { + lastFingerprint.current = fp + setInitialData(initialData) + } + }, [initialData, setInitialData]) useBacklogRealtime(productId) diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 78fa2ad..9707a62 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -56,6 +56,7 @@ export interface Story { priority: number status: string pbi_id: string + sprint_id: string | null created_at: Date } diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index e65f363..30b98e2 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -41,6 +41,7 @@ export interface SprintStory { description: string | null acceptance_criteria: string | null pbi_id: string + sprint_id: string | null created_at: Date priority: number status: string diff --git a/components/sprint/start-sprint-button.tsx b/components/sprint/start-sprint-button.tsx index fcebf17..08dacad 100644 --- a/components/sprint/start-sprint-button.tsx +++ b/components/sprint/start-sprint-button.tsx @@ -22,6 +22,7 @@ import { } from '@/components/shared/entity-dialog-layout' import { createSprintAction } from '@/actions/sprints' import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore } from '@/stores/backlog-store' interface StartSprintButtonProps { productId: string @@ -46,6 +47,13 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt const formRef = useRef(null) const router = useRouter() const selectedPbiId = useSelectionStore((s) => s.selectedPbiId) + const selectedPbi = useBacklogStore((s) => + selectedPbiId ? s.pbis.find((p) => p.id === selectedPbiId) ?? null : null, + ) + const freeStoryCount = useBacklogStore((s) => { + if (!selectedPbiId) return 0 + return (s.storiesByPbi[selectedPbiId] ?? []).filter((story) => story.sprint_id === null).length + }) const [state, formAction, pending] = useActionState( async (_prev, fd) => { @@ -96,6 +104,26 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt {selectedPbiId && } + {!selectedPbi ? ( +
+ Geen PBI geselecteerd — de sprint wordt leeg aangemaakt. Je kunt later stories + toevoegen via slepen. +
+ ) : freeStoryCount === 0 ? ( +
+ PBI {selectedPbi.code ?? selectedPbi.id.slice(0, 8)} heeft geen + vrije stories (alle stories zitten al in een andere sprint of zijn afgerond) — de + sprint wordt leeg aangemaakt. +
+ ) : ( +
+ {freeStoryCount} {freeStoryCount === 1 ? 'story' : 'stories'} van + PBI {selectedPbi.code ?? selectedPbi.id.slice(0, 8)} + {selectedPbi.title ? ` (${selectedPbi.title})` : ''} worden toegevoegd aan deze + sprint. +
+ )} +