From d7d11124e39cdb472f5cd8e2f8d0672f004bbd6e Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 11 May 2026 18:13:20 +0200 Subject: [PATCH] feat(PBI-79): sprint-switch auto-select PBI/story + user-settings persist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- actions/active-sprint.ts | 75 +++++++++++++++++++ app/(app)/products/[id]/page.tsx | 2 + .../backlog/active-selection-hydrator.tsx | 53 +++++++++++++ components/shared/sprint-switcher.tsx | 26 ++++++- lib/active-sprint.ts | 51 +++++++++++++ lib/user-settings.ts | 2 + 6 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 components/backlog/active-selection-hydrator.tsx 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'])