diff --git a/actions/active-sprint.ts b/actions/active-sprint.ts
index b0fbb75..e774376 100644
--- a/actions/active-sprint.ts
+++ b/actions/active-sprint.ts
@@ -9,6 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access'
import {
clearActiveSprintInSettings,
+ setActiveSelectionInSettings,
setActiveSprintInSettings,
} from '@/lib/active-sprint'
@@ -67,6 +68,80 @@ export async function clearActiveSprintAction(productId: string) {
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) {
const session = await getSession()
if (!session.userId) return
diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx
index 4288882..1b52c7a 100644
--- a/app/(app)/products/[id]/page.tsx
+++ b/app/(app)/products/[id]/page.tsx
@@ -18,6 +18,7 @@ import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner'
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 { EditProductButton } from '@/components/products/edit-product-button'
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
@@ -166,6 +167,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
}}
>
+
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
+}
diff --git a/components/shared/sprint-switcher.tsx b/components/shared/sprint-switcher.tsx
index d0ada8d..d809fa1 100644
--- a/components/shared/sprint-switcher.tsx
+++ b/components/shared/sprint-switcher.tsx
@@ -13,7 +13,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
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 { debugProps } from '@/lib/debug'
@@ -54,11 +58,25 @@ export function SprintSwitcher({
function handleSwitchSprint(sprintId: string) {
if (sprintId === activeSprint?.id) return
startTransition(async () => {
- const result = await setActiveSprintAction(productId, sprintId)
- if (result?.error) {
- toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
+ const result = await switchActiveSprintAction(productId, sprintId)
+ if ('error' in result) {
+ toast.error(
+ typeof result.error === 'string' ? result.error : 'Wisselen mislukt',
+ )
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')) {
router.push(`/products/${productId}/sprint/${sprintId}`)
} else {
diff --git a/lib/active-sprint.ts b/lib/active-sprint.ts
index 4ca3234..a5e6033 100644
--- a/lib/active-sprint.ts
+++ b/lib/active-sprint.ts
@@ -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 {
+ const current = await readSettings(userId)
+ const nextActiveSprints: Record = {
+ ...(current.layout?.activeSprints ?? {}),
+ [productId]: selection.sprintId,
+ }
+ const nextActivePbis: Record = {
+ ...(current.layout?.activePbis ?? {}),
+ }
+ if (selection.pbiId !== undefined) {
+ nextActivePbis[productId] = selection.pbiId
+ }
+ const nextActiveStories: Record = {
+ ...(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(
productId: string,
userId: string,
diff --git a/lib/user-settings.ts b/lib/user-settings.ts
index 5139261..0bcb92f 100644
--- a/lib/user-settings.ts
+++ b/lib/user-settings.ts
@@ -46,6 +46,8 @@ const DevToolsPrefs = z.object({
const LayoutPrefs = z.object({
splitPanePositions: z.record(z.string(), z.array(z.number())).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()
const PbiIntent = z.enum(['all', 'none'])