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) <noreply@anthropic.com>
This commit is contained in:
parent
35e37dac09
commit
b210a3b924
14 changed files with 72 additions and 14 deletions
|
|
@ -110,13 +110,13 @@ describe('shouldEmit scope filter (via backlog-store reducer)', () => {
|
||||||
|
|
||||||
it('applyChange: story INSERT adds to storiesByPbi', () => {
|
it('applyChange: story INSERT adds to storiesByPbi', () => {
|
||||||
useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} })
|
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)
|
useBacklogStore.getState().applyChange('story', 'I', story)
|
||||||
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
|
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applyChange: story DELETE removes from correct pbi bucket', () => {
|
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.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} })
|
||||||
useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' })
|
useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' })
|
||||||
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0)
|
expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0)
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ const ALT_PBI_ID = 'pbi-2'
|
||||||
const STORY_ID = 'story-1'
|
const STORY_ID = 'story-1'
|
||||||
|
|
||||||
const STORIES = [
|
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 = [
|
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() },
|
{ id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() },
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ const STORY: BacklogStory = {
|
||||||
priority: 2,
|
priority: 2,
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
pbi_id: 'pbi-1',
|
pbi_id: 'pbi-1',
|
||||||
|
sprint_id: null,
|
||||||
created_at: new Date('2024-01-01T00:00:00Z'),
|
created_at: new Date('2024-01-01T00:00:00Z'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
||||||
}
|
}
|
||||||
|
|
||||||
await setActiveSprintCookie(parsed.data.productId, sprint.id)
|
await setActiveSprintCookie(parsed.data.productId, sprint.id)
|
||||||
revalidatePath(`/products/${parsed.data.productId}`)
|
revalidatePath(`/products/${parsed.data.productId}`, 'layout')
|
||||||
return { success: true, sprintId: sprint.id }
|
return { success: true, sprintId: sprint.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
priority: true,
|
priority: true,
|
||||||
status: true,
|
status: true,
|
||||||
pbi_id: true,
|
pbi_id: true,
|
||||||
|
sprint_id: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
import { getAccessibleProduct } from '@/lib/product-access'
|
import { getAccessibleProduct } from '@/lib/product-access'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { resolveActiveSprint } from '@/lib/active-sprint'
|
||||||
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
|
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
|
||||||
import { SoloBoard } from '@/components/solo/solo-board'
|
import { SoloBoard } from '@/components/solo/solo-board'
|
||||||
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
|
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)
|
const product = await getAccessibleProduct(id, session.userId)
|
||||||
if (!product) notFound()
|
if (!product) notFound()
|
||||||
|
|
||||||
const sprint = await prisma.sprint.findFirst({
|
const active = await resolveActiveSprint(id)
|
||||||
where: { product_id: id, status: 'OPEN' },
|
const sprint = active
|
||||||
})
|
? await prisma.sprint.findFirst({ where: { id: active.id, product_id: id } })
|
||||||
|
: null
|
||||||
|
|
||||||
const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint?.id ?? null })
|
const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint?.id ?? null })
|
||||||
|
|
||||||
|
|
@ -126,6 +128,7 @@ export default async function SoloProductPage({ params }: Props) {
|
||||||
{switcherBar}
|
{switcherBar}
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
<SoloBoard
|
<SoloBoard
|
||||||
|
key={sprint.id}
|
||||||
productId={id}
|
productId={id}
|
||||||
sprintGoal={sprint.sprint_goal}
|
sprintGoal={sprint.sprint_goal}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||||
description: s.description,
|
description: s.description,
|
||||||
acceptance_criteria: s.acceptance_criteria,
|
acceptance_criteria: s.acceptance_criteria,
|
||||||
pbi_id: s.pbi_id,
|
pbi_id: s.pbi_id,
|
||||||
|
sprint_id: s.sprint_id,
|
||||||
created_at: s.created_at,
|
created_at: s.created_at,
|
||||||
priority: s.priority,
|
priority: s.priority,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
|
|
@ -144,6 +145,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||||
description: s.description,
|
description: s.description,
|
||||||
acceptance_criteria: s.acceptance_criteria,
|
acceptance_criteria: s.acceptance_criteria,
|
||||||
pbi_id: s.pbi_id,
|
pbi_id: s.pbi_id,
|
||||||
|
sprint_id: s.sprint_id,
|
||||||
created_at: s.created_at,
|
created_at: s.created_at,
|
||||||
priority: s.priority,
|
priority: s.priority,
|
||||||
status: s.status,
|
status: s.status,
|
||||||
|
|
@ -191,6 +193,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<SprintBoardClient
|
<SprintBoardClient
|
||||||
|
key={sprint.id}
|
||||||
productId={id}
|
productId={id}
|
||||||
sprintId={sprint.id}
|
sprintId={sprint.id}
|
||||||
stories={sprintStoryItems}
|
stories={sprintStoryItems}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
|
||||||
priority: true,
|
priority: true,
|
||||||
status: true,
|
status: true,
|
||||||
pbi_id: true,
|
pbi_id: true,
|
||||||
|
sprint_id: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { notFound } from 'next/navigation'
|
||||||
import { getAccessibleProduct } from '@/lib/product-access'
|
import { getAccessibleProduct } from '@/lib/product-access'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { requireSession } from '@/lib/auth-guard'
|
import { requireSession } from '@/lib/auth-guard'
|
||||||
|
import { resolveActiveSprint } from '@/lib/active-sprint'
|
||||||
import { SoloBoard } from '@/components/solo/solo-board'
|
import { SoloBoard } from '@/components/solo/solo-board'
|
||||||
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
|
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
|
||||||
import type { SoloTask } from '@/components/solo/solo-board'
|
import type { SoloTask } from '@/components/solo/solo-board'
|
||||||
|
|
@ -24,9 +25,10 @@ export default async function MobileSoloProductPage({ params }: Props) {
|
||||||
const product = await getAccessibleProduct(id, session.userId)
|
const product = await getAccessibleProduct(id, session.userId)
|
||||||
if (!product) notFound()
|
if (!product) notFound()
|
||||||
|
|
||||||
const sprint = await prisma.sprint.findFirst({
|
const active = await resolveActiveSprint(id)
|
||||||
where: { product_id: id, status: 'OPEN' },
|
const sprint = active
|
||||||
})
|
? await prisma.sprint.findFirst({ where: { id: active.id, product_id: id } })
|
||||||
|
: null
|
||||||
|
|
||||||
if (!sprint) {
|
if (!sprint) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -112,6 +114,7 @@ export default async function MobileSoloProductPage({ params }: Props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SoloBoard
|
<SoloBoard
|
||||||
|
key={sprint.id}
|
||||||
productId={id}
|
productId={id}
|
||||||
sprintGoal={sprint.sprint_goal}
|
sprintGoal={sprint.sprint_goal}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store'
|
import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store'
|
||||||
import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime'
|
import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime'
|
||||||
|
|
||||||
|
|
@ -16,13 +16,28 @@ interface BacklogHydrationWrapperProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fingerprint(data: InitialData): string {
|
||||||
|
const pbiPart = data.pbis.map((p) => `${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) {
|
export function BacklogHydrationWrapper({ initialData, productId, children }: BacklogHydrationWrapperProps) {
|
||||||
const setInitialData = useBacklogStore((s) => s.setInitialData)
|
const setInitialData = useBacklogStore((s) => s.setInitialData)
|
||||||
|
const lastFingerprint = useRef<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInitialData(initialData)
|
const fp = fingerprint(initialData)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
if (fp !== lastFingerprint.current) {
|
||||||
}, [])
|
lastFingerprint.current = fp
|
||||||
|
setInitialData(initialData)
|
||||||
|
}
|
||||||
|
}, [initialData, setInitialData])
|
||||||
|
|
||||||
useBacklogRealtime(productId)
|
useBacklogRealtime(productId)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ export interface Story {
|
||||||
priority: number
|
priority: number
|
||||||
status: string
|
status: string
|
||||||
pbi_id: string
|
pbi_id: string
|
||||||
|
sprint_id: string | null
|
||||||
created_at: Date
|
created_at: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export interface SprintStory {
|
||||||
description: string | null
|
description: string | null
|
||||||
acceptance_criteria: string | null
|
acceptance_criteria: string | null
|
||||||
pbi_id: string
|
pbi_id: string
|
||||||
|
sprint_id: string | null
|
||||||
created_at: Date
|
created_at: Date
|
||||||
priority: number
|
priority: number
|
||||||
status: string
|
status: string
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
} from '@/components/shared/entity-dialog-layout'
|
} from '@/components/shared/entity-dialog-layout'
|
||||||
import { createSprintAction } from '@/actions/sprints'
|
import { createSprintAction } from '@/actions/sprints'
|
||||||
import { useSelectionStore } from '@/stores/selection-store'
|
import { useSelectionStore } from '@/stores/selection-store'
|
||||||
|
import { useBacklogStore } from '@/stores/backlog-store'
|
||||||
|
|
||||||
interface StartSprintButtonProps {
|
interface StartSprintButtonProps {
|
||||||
productId: string
|
productId: string
|
||||||
|
|
@ -46,6 +47,13 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt
|
||||||
const formRef = useRef<HTMLFormElement>(null)
|
const formRef = useRef<HTMLFormElement>(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const selectedPbiId = useSelectionStore((s) => s.selectedPbiId)
|
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<ActionResult | undefined, FormData>(
|
const [state, formAction, pending] = useActionState<ActionResult | undefined, FormData>(
|
||||||
async (_prev, fd) => {
|
async (_prev, fd) => {
|
||||||
|
|
@ -96,6 +104,26 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt
|
||||||
<input type="hidden" name="productId" value={productId} />
|
<input type="hidden" name="productId" value={productId} />
|
||||||
{selectedPbiId && <input type="hidden" name="pbi_id" value={selectedPbiId} />}
|
{selectedPbiId && <input type="hidden" name="pbi_id" value={selectedPbiId} />}
|
||||||
|
|
||||||
|
{!selectedPbi ? (
|
||||||
|
<div className="bg-warning-container text-warning-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-warning">
|
||||||
|
Geen PBI geselecteerd — de sprint wordt leeg aangemaakt. Je kunt later stories
|
||||||
|
toevoegen via slepen.
|
||||||
|
</div>
|
||||||
|
) : freeStoryCount === 0 ? (
|
||||||
|
<div className="bg-warning-container text-warning-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-warning">
|
||||||
|
PBI <strong>{selectedPbi.code ?? selectedPbi.id.slice(0, 8)}</strong> heeft geen
|
||||||
|
vrije stories (alle stories zitten al in een andere sprint of zijn afgerond) — de
|
||||||
|
sprint wordt leeg aangemaakt.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-primary-container text-primary-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-primary">
|
||||||
|
<strong>{freeStoryCount}</strong> {freeStoryCount === 1 ? 'story' : 'stories'} van
|
||||||
|
PBI <strong>{selectedPbi.code ?? selectedPbi.id.slice(0, 8)}</strong>
|
||||||
|
{selectedPbi.title ? ` (${selectedPbi.title})` : ''} worden toegevoegd aan deze
|
||||||
|
sprint.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-sm font-medium text-foreground">
|
<label className="text-sm font-medium text-foreground">
|
||||||
Sprint Goal <span className="text-error">*</span>
|
Sprint Goal <span className="text-error">*</span>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export interface BacklogStory {
|
||||||
priority: number
|
priority: number
|
||||||
status: string
|
status: string
|
||||||
pbi_id: string
|
pbi_id: string
|
||||||
|
sprint_id: string | null
|
||||||
created_at: Date
|
created_at: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue