feat(PBI-79): sprint-switch auto-select PBI/story + user-settings persist

Bij sprint-switch wordt de sprint-content server-side opgevraagd. Wanneer
de sprint precies één PBI (en die PBI exact één story binnen de sprint)
heeft, worden PBI en story automatisch geselecteerd. Alle drie keuzes
(sprint, pbi, story) worden atomair in user-settings opgeslagen zodat ze
cross-device blijven hangen.

- lib/user-settings.ts: layout krijgt nullable activePbis +
  activeStories per product.
- lib/active-sprint.ts: setActiveSelectionInSettings schrijft de drie
  keys atomair + notify pg_notify.
- actions/active-sprint.ts: switchActiveSprintAction(productId, sprintId)
  doet de server-side auto-select-resolutie (single PBI → single story)
  en returnt { sprintId, pbiId, storyId }.
- components/shared/sprint-switcher.tsx: handleSwitchSprint roept de
  nieuwe action aan en synchroniseert de workspace-store gelijk zodat
  de UI geen flash krijgt voor de SSR-refresh.
- components/backlog/active-selection-hydrator.tsx (nieuw): client-side
  effect dat user-settings.activePbis/activeStories naar workspace-store
  spiegelt; wint van de localStorage hint-restore.
- app/(app)/products/[id]/page.tsx: ActiveSelectionHydrator gemount
  binnen BacklogHydrationWrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-11 18:13:20 +02:00
parent 35c6404b14
commit d7d11124e3
6 changed files with 205 additions and 4 deletions

View file

@ -9,6 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access' import { productAccessFilter } from '@/lib/product-access'
import { import {
clearActiveSprintInSettings, clearActiveSprintInSettings,
setActiveSelectionInSettings,
setActiveSprintInSettings, setActiveSprintInSettings,
} from '@/lib/active-sprint' } from '@/lib/active-sprint'
@ -67,6 +68,80 @@ export async function clearActiveSprintAction(productId: string) {
return { success: true } return { success: true }
} }
const selectionSchema = z.object({
productId: z.string().min(1),
sprintId: z.string().min(1),
})
/**
* PBI-79: kies een sprint en auto-select zijn enige PBI/story (indien
* singleton). Resultaat wordt server-side bepaald + atomair in user-settings
* weggeschreven (sprint+pbi+story) zodat cross-device-restore klopt.
*/
export async function switchActiveSprintAction(
productId: string,
sprintId: string,
): Promise<
| {
success: true
sprintId: string
pbiId: string | null
storyId: string | null
}
| { error: string }
> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = selectionSchema.safeParse({ productId, sprintId })
if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' }
const sprint = await prisma.sprint.findFirst({
where: {
id: parsed.data.sprintId,
product_id: parsed.data.productId,
product: productAccessFilter(session.userId),
},
select: { id: true },
})
if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' }
// Auto-select: alleen wanneer sprint exact één PBI heeft. Story-auto-select
// alleen wanneer die PBI exact één story binnen deze sprint heeft.
const sprintStories = await prisma.story.findMany({
where: {
sprint_id: parsed.data.sprintId,
product_id: parsed.data.productId,
},
select: { id: true, pbi_id: true },
})
const uniquePbiIds = Array.from(new Set(sprintStories.map((s) => s.pbi_id)))
let autoPbiId: string | null = null
let autoStoryId: string | null = null
if (uniquePbiIds.length === 1) {
autoPbiId = uniquePbiIds[0]
const storiesForPbi = sprintStories.filter((s) => s.pbi_id === autoPbiId)
if (storiesForPbi.length === 1) {
autoStoryId = storiesForPbi[0].id
}
}
await setActiveSelectionInSettings(session.userId, parsed.data.productId, {
sprintId: parsed.data.sprintId,
pbiId: autoPbiId,
storyId: autoStoryId,
})
revalidatePath('/', 'layout')
return {
success: true,
sprintId: parsed.data.sprintId,
pbiId: autoPbiId,
storyId: autoStoryId,
}
}
export async function syncActiveSprintCookieAction(productId: string, sprintId: string) { export async function syncActiveSprintCookieAction(productId: string, sprintId: string) {
const session = await getSession() const session = await getSession()
if (!session.userId) return if (!session.userId) return

View file

@ -18,6 +18,7 @@ import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner' import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner'
import { SaveSprintButton } from '@/components/backlog/save-sprint-button' import { SaveSprintButton } from '@/components/backlog/save-sprint-button'
import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator'
import { ActivateProductButton } from '@/components/shared/activate-product-button' import { ActivateProductButton } from '@/components/shared/activate-product-button'
import { EditProductButton } from '@/components/products/edit-product-button' import { EditProductButton } from '@/components/products/edit-product-button'
import { SprintSwitcher } from '@/components/shared/sprint-switcher' import { SprintSwitcher } from '@/components/shared/sprint-switcher'
@ -166,6 +167,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
}} }}
> >
<UrlTaskSync /> <UrlTaskSync />
<ActiveSelectionHydrator productId={id} />
<BacklogSplitPane <BacklogSplitPane
cookieKey={`backlog-${id}`} cookieKey={`backlog-${id}`}
defaultSplit={[20, 45, 35]} defaultSplit={[20, 45, 35]}

View file

@ -0,0 +1,53 @@
'use client'
import { useEffect } from 'react'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
interface ActiveSelectionHydratorProps {
productId: string
}
/**
* PBI-79: hydrateert de workspace-store met de actieve PBI/story die in
* user-settings staan opgeslagen. Loopt na elke (re)hydratatie en bij
* mutaties van de user-settings (bv. na sprint-switch). Wint van de
* localStorage hint-restore user-settings is de cross-device source of
* truth.
*/
export function ActiveSelectionHydrator({ productId }: ActiveSelectionHydratorProps) {
const hydrated = useUserSettingsStore((s) => s.context.hydrated)
const persistedPbiId = useUserSettingsStore(
(s) => s.entities.settings.layout?.activePbis?.[productId] ?? undefined,
)
const persistedStoryId = useUserSettingsStore(
(s) => s.entities.settings.layout?.activeStories?.[productId] ?? undefined,
)
useEffect(() => {
if (!hydrated) return
const store = useProductWorkspaceStore.getState()
// Schrijf alleen wanneer user-settings expliciet iets gekozen heeft
// (key aanwezig met string-waarde). null-key betekent 'bewust leeg' →
// we wissen lokale state. undefined-key (geen voorkeur) → niets doen.
if (persistedPbiId === undefined && persistedStoryId === undefined) return
if (persistedPbiId === null) {
store.setActivePbi(null)
return
}
if (persistedPbiId && store.context.activePbiId !== persistedPbiId) {
store.setActivePbi(persistedPbiId)
}
if (persistedStoryId && store.context.activeStoryId !== persistedStoryId) {
// setActivePbi triggert async cascade-restore die de oude hint kan
// herstellen; de daarop volgende setActiveStory bumpt activeRequestId
// en ongeldigt de cascade.
store.setActiveStory(persistedStoryId)
} else if (persistedStoryId === null) {
store.setActiveStory(null)
}
}, [hydrated, persistedPbiId, persistedStoryId])
return null
}

View file

@ -13,7 +13,11 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { clearActiveSprintAction, setActiveSprintAction } from '@/actions/active-sprint' import {
clearActiveSprintAction,
switchActiveSprintAction,
} from '@/actions/active-sprint'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import type { SprintStatusApi } from '@/lib/task-status' import type { SprintStatusApi } from '@/lib/task-status'
import { debugProps } from '@/lib/debug' import { debugProps } from '@/lib/debug'
@ -54,11 +58,25 @@ export function SprintSwitcher({
function handleSwitchSprint(sprintId: string) { function handleSwitchSprint(sprintId: string) {
if (sprintId === activeSprint?.id) return if (sprintId === activeSprint?.id) return
startTransition(async () => { startTransition(async () => {
const result = await setActiveSprintAction(productId, sprintId) const result = await switchActiveSprintAction(productId, sprintId)
if (result?.error) { if ('error' in result) {
toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt') toast.error(
typeof result.error === 'string' ? result.error : 'Wisselen mislukt',
)
return return
} }
// Synchroniseer de client-side workspace-store met de auto-select die
// server-side is bepaald — voorkomt korte flash van vorige selectie
// voordat router.refresh de SSR-render binnenhaalt.
const store = useProductWorkspaceStore.getState()
if (result.pbiId) {
store.setActivePbi(result.pbiId)
if (result.storyId) {
store.setActiveStory(result.storyId)
}
} else {
store.setActivePbi(null)
}
if (pathname.includes('/sprint')) { if (pathname.includes('/sprint')) {
router.push(`/products/${productId}/sprint/${sprintId}`) router.push(`/products/${productId}/sprint/${sprintId}`)
} else { } else {

View file

@ -93,6 +93,57 @@ export async function clearActiveSprintInSettings(
}) })
} }
/**
* PBI-79: persisteer sprint-keuze + bijbehorende PBI/story-selectie atomair.
* Sprintkeuze blijft 'sleutel met null = bewust geen sprint'-contract trouw;
* activePbi/activeStory volgen dezelfde semantiek (null = expliciet leeg).
*/
export async function setActiveSelectionInSettings(
userId: string,
productId: string,
selection: {
sprintId: string | null
pbiId?: string | null
storyId?: string | null
},
): Promise<void> {
const current = await readSettings(userId)
const nextActiveSprints: Record<string, string | null> = {
...(current.layout?.activeSprints ?? {}),
[productId]: selection.sprintId,
}
const nextActivePbis: Record<string, string | null> = {
...(current.layout?.activePbis ?? {}),
}
if (selection.pbiId !== undefined) {
nextActivePbis[productId] = selection.pbiId
}
const nextActiveStories: Record<string, string | null> = {
...(current.layout?.activeStories ?? {}),
}
if (selection.storyId !== undefined) {
nextActiveStories[productId] = selection.storyId
}
const next: UserSettings = {
...current,
layout: {
...current.layout,
activeSprints: nextActiveSprints,
activePbis: nextActivePbis,
activeStories: nextActiveStories,
},
}
await writeSettings(userId, next)
await notifyUserSettings(userId, {
layout: {
activeSprints: nextActiveSprints,
activePbis: nextActivePbis,
activeStories: nextActiveStories,
},
})
}
export async function resolveActiveSprint( export async function resolveActiveSprint(
productId: string, productId: string,
userId: string, userId: string,

View file

@ -46,6 +46,8 @@ const DevToolsPrefs = z.object({
const LayoutPrefs = z.object({ const LayoutPrefs = z.object({
splitPanePositions: z.record(z.string(), z.array(z.number())).optional(), splitPanePositions: z.record(z.string(), z.array(z.number())).optional(),
activeSprints: z.record(z.string(), z.string().nullable()).optional(), activeSprints: z.record(z.string(), z.string().nullable()).optional(),
activePbis: z.record(z.string(), z.string().nullable()).optional(),
activeStories: z.record(z.string(), z.string().nullable()).optional(),
}).strict() }).strict()
const PbiIntent = z.enum(['all', 'none']) const PbiIntent = z.enum(['all', 'none'])