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'])