From 3842c05ae93fb98ae6ee9950a21a4c273ff00553 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Fri, 8 May 2026 02:32:50 +0200 Subject: [PATCH] feat: sprint-switcher overal + PBI auto-toevoeging + cleanups (#163) * refactor: verplaats sprint-switcher van NavBar naar product-header Sprint-pulldown zit nu in de bestaande balk op de product backlog (naast Sprint starten / Instellingen) i.p.v. in het midden van de NavBar. Alleen zichtbaar wanneer het product ook het actieve product van de gebruiker is. Co-Authored-By: Claude Opus 4.7 (1M context) * chore: sync package-lock.json version naar 1.2.0 Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: centreer sprint-switcher en verwijder badges uit dropdown items Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: vervang sprint-status badge door subtle tekst Co-Authored-By: Claude Opus 4.7 (1M context) * feat: toon code + titel + status in sprint-switcher dropdown items Co-Authored-By: Claude Opus 4.7 (1M context) * fix: cookie-write uit Server Component (Next.js 16 verbiedt dit) setActiveSprintCookie werd direct aangeroepen in app/(app)/products/[id]/sprint/[sprintId]/page.tsx, wat in Next.js 16 een runtime-error oplevert ('Cookies can only be modified in a Server Action or Route Handler'). Vervangen door een client-side bridge die syncActiveSprintCookieAction aanroept na mount, zodat de active-sprint cookie nog steeds gesynced blijft met de URL. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: filter 'toon afgeronde sprints' in sprint-switcher dropdown Default verbergt de switcher gesloten/gearchiveerde/mislukte sprints (toont alleen open + de huidige actieve sprint). Toggle bovenaan de lijst om alle sprints te tonen. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: nieuwe sprint wordt direct geselecteerd zonder redirect createSprintAction zet nu de active-sprint cookie naar de zojuist aangemaakte sprint, en de StartSprintButton refresht de huidige pagina i.p.v. te redirecten naar /sprint. Resultaat: gebruiker blijft op de product backlog en ziet de nieuwe sprint direct geselecteerd in de sprint-pulldown. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: verplaats Manual en Admin naar user-menu dropdown Co-Authored-By: Claude Opus 4.7 (1M context) * feat: voeg geselecteerde PBI automatisch toe aan nieuwe sprint Bij sprint-aanmaak wordt de pbi_id uit de selection-store als hidden form-field meegestuurd. Server-side worden alle stories van die PBI (zonder sprint) en hun taken aan de nieuwe sprint gekoppeld; stories krijgen status IN_SPRINT met incrementele sort_order. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: sprint-switcher op solo- en sprint-board pagina's Sprint-switcher is nu beschikbaar op de drie hoofdpagina's: product backlog, solo board en sprint board. Allen renderen 'm in een gecentreerde balk net onder de NavBar. Sprint-data via gedeelde helper getSprintSwitcherData. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/actions/sprint-dates.test.ts | 2 +- actions/active-sprint.ts | 21 +++++ actions/sprints.ts | 31 +++++++ app/(app)/products/[id]/page.tsx | 50 +++-------- app/(app)/products/[id]/solo/page.tsx | 39 ++++++-- .../products/[id]/sprint/[sprintId]/page.tsx | 15 +++- components/shared/nav-bar.tsx | 2 - components/shared/sprint-switcher.tsx | 90 ++++++++++++------- components/shared/user-menu.tsx | 16 +++- components/sprint/start-sprint-button.tsx | 5 +- .../sprint/sync-active-sprint-cookie.tsx | 16 ++++ lib/schemas/sprint.ts | 5 ++ lib/sprint-switcher-data.ts | 62 +++++++++++++ 13 files changed, 265 insertions(+), 89 deletions(-) create mode 100644 components/sprint/sync-active-sprint-cookie.tsx create mode 100644 lib/sprint-switcher-data.ts diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts index 875ab1d..6adb153 100644 --- a/__tests__/actions/sprint-dates.test.ts +++ b/__tests__/actions/sprint-dates.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({ set: vi.fn(), get: vi.fn(), delete: vi.fn() }) })) vi.mock('iron-session', () => ({ getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), })) diff --git a/actions/active-sprint.ts b/actions/active-sprint.ts index b391d0a..9451190 100644 --- a/actions/active-sprint.ts +++ b/actions/active-sprint.ts @@ -40,3 +40,24 @@ export async function setActiveSprintAction(productId: string, sprintId: string) revalidatePath('/', 'layout') return { success: true, sprintId: parsed.data.sprintId } } + +export async function syncActiveSprintCookieAction(productId: string, sprintId: string) { + const session = await getSession() + if (!session.userId) return + if (session.isDemo) return + + const parsed = setSchema.safeParse({ productId, sprintId }) + if (!parsed.success) return + + 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 + + await setActiveSprintCookie(parsed.data.productId, parsed.data.sprintId) +} diff --git a/actions/sprints.ts b/actions/sprints.ts index dc5d55e..de096d3 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -40,6 +40,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData sprint_goal: formData.get('sprint_goal'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), + pbi_id: formData.get('pbi_id'), }) if (!parsed.success) { return { @@ -72,6 +73,36 @@ export async function createSprintAction(_prevState: unknown, formData: FormData }), ) + if (parsed.data.pbi_id) { + const pbi = await prisma.pbi.findFirst({ + where: { id: parsed.data.pbi_id, product_id: parsed.data.productId }, + select: { id: true }, + }) + if (pbi) { + const stories = await prisma.story.findMany({ + where: { pbi_id: pbi.id, sprint_id: null }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + select: { id: true }, + }) + if (stories.length > 0) { + const storyIds = stories.map(s => s.id) + await prisma.$transaction([ + ...stories.map((s, i) => + prisma.story.update({ + where: { id: s.id }, + data: { sprint_id: sprint.id, status: 'IN_SPRINT', sort_order: i + 1 }, + }), + ), + prisma.task.updateMany({ + where: { story_id: { in: storyIds }, sprint_id: null }, + data: { sprint_id: sprint.id }, + }), + ]) + } + } + } + + await setActiveSprintCookie(parsed.data.productId, sprint.id) revalidatePath(`/products/${parsed.data.productId}`) return { success: true, sprintId: sprint.id } } diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index d81c678..a8e79e5 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -3,8 +3,8 @@ import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' -import { pbiStatusToApi, sprintStatusToApi } from '@/lib/task-status' -import { resolveActiveSprint } from '@/lib/active-sprint' +import { pbiStatusToApi } from '@/lib/task-status' +import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' import { PbiList } from '@/components/backlog/pbi-list' import { StoryPanel } from '@/components/backlog/story-panel' @@ -35,41 +35,12 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const [allSprints, user, resolvedActiveSprint] = await Promise.all([ - prisma.sprint.findMany({ - where: { product_id: id }, - orderBy: { created_at: 'desc' }, - select: { id: true, code: true, status: true }, - }), + const [user, switcherData] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }), - resolveActiveSprint(id), + getSprintSwitcherData(id), ]) - const hasOpenSprint = allSprints.some(s => s.status === 'OPEN') - - let buildingSprintIds: string[] = [] - if (allSprints.length > 0) { - const runs = await prisma.sprintRun.findMany({ - where: { - sprint_id: { in: allSprints.map(s => s.id) }, - status: { in: ['QUEUED', 'RUNNING'] }, - }, - select: { sprint_id: true }, - }) - buildingSprintIds = Array.from(new Set(runs.map(r => r.sprint_id))) - } - - const sprintItems = allSprints.map(s => ({ - id: s.id, - code: s.code, - status: sprintStatusToApi(s.status), - })) - const activeSprintItem = resolvedActiveSprint - ? { - id: resolvedActiveSprint.id, - code: resolvedActiveSprint.code, - status: sprintStatusToApi(resolvedActiveSprint.status), - } - : null + const { sprintItems, buildingSprintIds, activeSprintItem } = switcherData + const hasOpenSprint = sprintItems.some(s => s.status === 'open') const isActiveProduct = user?.active_product_id === id const pbis = await prisma.pbi.findMany({ @@ -127,9 +98,10 @@ export default async function ProductBacklogPage({ params, searchParams }: Props return (
- {/* Product header — sprint-switcher links, actions rechts */} -
-
+ {/* Product header — sprint-switcher gecentreerd, actions rechts */} +
+
+
{isActiveProduct && ( )}
-
+
{!isActiveProduct && ( )} diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 72aa65b..8af037d 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -2,8 +2,10 @@ import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' +import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' import { SoloBoard } from '@/components/solo/solo-board' import { NoActiveSprint } from '@/components/solo/no-active-sprint' +import { SprintSwitcher } from '@/components/shared/sprint-switcher' import type { SoloTask } from '@/components/solo/solo-board' import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet' @@ -23,9 +25,23 @@ export default async function SoloProductPage({ params }: Props) { where: { product_id: id, status: 'OPEN' }, }) + const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint?.id ?? null }) + + const switcherBar = ( +
+ +
+ ) + if (!sprint) { return (
+ {switcherBar}
) @@ -106,14 +122,19 @@ export default async function SoloProductPage({ params }: Props) { })) return ( - +
+ {switcherBar} +
+ +
+
) } diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx index bdac0d5..a04849d 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx @@ -4,8 +4,10 @@ import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' import { pbiStatusToApi } from '@/lib/task-status' -import { setActiveSprintCookie } from '@/lib/active-sprint' import { SprintBoardClient } from '@/components/sprint/sprint-board-client' +import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie' +import { SprintSwitcher } from '@/components/shared/sprint-switcher' +import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' import { SprintHeader } from '@/components/sprint/sprint-header' import { SprintRunControls } from '@/components/sprint/sprint-run-controls' import { parsePauseContext } from '@/lib/pause-context' @@ -48,7 +50,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { }) if (!sprint) notFound() - await setActiveSprintCookie(id, sprint.id) + const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint.id }) const activeSprintRun = await prisma.sprintRun.findFirst({ where: { @@ -158,6 +160,15 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { return (
+ +
+ +
diff --git a/components/shared/sprint-switcher.tsx b/components/shared/sprint-switcher.tsx index 1419a51..28edcd6 100644 --- a/components/shared/sprint-switcher.tsx +++ b/components/shared/sprint-switcher.tsx @@ -1,22 +1,22 @@ 'use client' import { usePathname, useRouter } from 'next/navigation' -import { useTransition } from 'react' -import { ChevronDown } from 'lucide-react' +import { useState, useTransition } from 'react' +import { Check, ChevronDown } from 'lucide-react' import { toast } from 'sonner' -import { Badge } from '@/components/ui/badge' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' import { setActiveSprintAction } from '@/actions/active-sprint' import type { SprintStatusApi } from '@/lib/task-status' -type SprintItem = { id: string; code: string; status: SprintStatusApi } +type SprintItem = { id: string; code: string; sprint_goal: string; status: SprintStatusApi } interface SprintSwitcherProps { productId: string @@ -32,13 +32,6 @@ const SPRINT_STATUS_LABEL: Record = { failed: 'Mislukt', } -const SPRINT_STATUS_BADGE: Record = { - open: 'bg-status-in-progress text-foreground', - closed: 'bg-status-done text-foreground', - archived: 'bg-surface-container text-muted-foreground', - failed: 'bg-status-failed text-foreground', -} - export function SprintSwitcher({ productId, sprints, @@ -48,8 +41,15 @@ export function SprintSwitcher({ const pathname = usePathname() const router = useRouter() const [isPending, startTransition] = useTransition() + const [showClosed, setShowClosed] = useState(false) const buildingSet = new Set(buildingSprintIds) + const visibleSprints = sprints.filter(s => { + if (showClosed) return true + if (s.id === activeSprint?.id) return true + return s.status === 'open' + }) + function handleSwitchSprint(sprintId: string) { if (sprintId === activeSprint?.id) return startTransition(async () => { @@ -92,42 +92,64 @@ export function SprintSwitcher({ {activeSprint ? activeSprint.code : 'Selecteer sprint'} {activeSprint && ( - {buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]} - + )} - - {sprints.map(s => ( - handleSwitchSprint(s.id)} + + + + {visibleSprints.length === 0 ? ( +
+ Geen open sprints +
+ ) : ( + visibleSprints.map(s => ( + handleSwitchSprint(s.id)} className={cn( - 'text-[10px] px-1.5 py-0 shrink-0', - buildingSet.has(s.id) - ? 'bg-warning text-warning-foreground' - : SPRINT_STATUS_BADGE[s.status], + 'flex items-center gap-2', + s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium', )} > - {buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]} - - - ))} + {s.code} + {s.sprint_goal} + + {buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]} + +
+ )) + )}
) diff --git a/components/shared/user-menu.tsx b/components/shared/user-menu.tsx index 12e9b54..7283222 100644 --- a/components/shared/user-menu.tsx +++ b/components/shared/user-menu.tsx @@ -2,7 +2,7 @@ import { useTransition } from 'react' import Link from 'next/link' -import { Settings, Sun, Globe, LogOut } from 'lucide-react' +import { Settings, Sun, Globe, LogOut, BookOpen, Shield } from 'lucide-react' import { logoutAction } from '@/actions/auth' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' @@ -112,6 +112,20 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) { + }> + + Manual + + + {roles.includes('ADMIN') && ( + }> + + Admin + + )} + + + (null) const router = useRouter() + const selectedPbiId = useSelectionStore((s) => s.selectedPbiId) const [state, formAction, pending] = useActionState( async (_prev, fd) => { @@ -51,7 +53,7 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt if (result?.success) { setOpen(false) setDirty(false) - router.push(`/products/${productId}/sprint`) + router.refresh() } else if (result?.code !== 422 && result?.error) { // Toast handled by caller; here we just keep the form open } @@ -92,6 +94,7 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt className="flex-1 overflow-y-auto px-6 py-6 space-y-6" > + {selectedPbiId && }