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:
Janpeter Visser 2026-05-09 16:27:24 +02:00 committed by GitHub
parent 35e37dac09
commit 71319e629d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 72 additions and 14 deletions

View file

@ -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

View file

@ -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>