feat(PBI-71): UX-fix 'lege sprint' + sprint-switch data-refresh (#175)
- 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
71319e629d
14 changed files with 72 additions and 14 deletions
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store'
|
||||
import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime'
|
||||
|
||||
|
|
@ -16,13 +16,28 @@ interface BacklogHydrationWrapperProps {
|
|||
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) {
|
||||
const setInitialData = useBacklogStore((s) => s.setInitialData)
|
||||
const lastFingerprint = useRef<string>('')
|
||||
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export interface Story {
|
|||
priority: number
|
||||
status: string
|
||||
pbi_id: string
|
||||
sprint_id: string | null
|
||||
created_at: Date
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<HTMLFormElement>(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<ActionResult | undefined, FormData>(
|
||||
async (_prev, fd) => {
|
||||
|
|
@ -96,6 +104,26 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt
|
|||
<input type="hidden" name="productId" value={productId} />
|
||||
{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">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Sprint Goal <span className="text-error">*</span>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue