* feat(PBI-76): extend UserSettings schema with layout Adds layout.splitPanePositions and layout.activeSprints. These will hold values currently kept in client-side and server-side cookies (Phase 2). Two new tests cover the shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): migrate SplitPane positions to user-settings store Outside of a drag the store is the source of truth (cross-tab updates flow in for free). During a drag we keep splits in local state so mousemove does not round-trip through the store. On mouseup we persist the final splits via setPref. Removes document.cookie reads/writes — cookieKey is reused as the store-key for backwards compat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): resolveActiveSprint reads from User.settings lib/active-sprint: - New helpers: getActiveSprintIdFromSettings, setActiveSprintInSettings, clearActiveSprintInSettings — all read/write user.settings.layout.activeSprints. - resolveActiveSprint(productId, userId) — userId now required, falls back to first OPEN, then most recent CLOSED sprint. - Cookie helpers (getActiveSprintIdFromCookie/setActiveSprintCookie/ clearActiveSprintCookie) removed. Callers updated to pass session.userId. The cookie-based fallback path is gone — `actions/active-sprint.ts` and `actions/sprints.ts` will be updated in the next commit (T-917). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): rewrite setActiveSprint callers to use settings setActiveSprintAction, syncActiveSprintCookieAction, and the two sprint-creation paths in actions/sprints.ts now write through setActiveSprintInSettings (which also emits pg_notify for cross-tab sync) instead of dropping a cookie. The action names keep the 'cookie' suffix in the user-visible API for now — clean rename can come later. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): migration helper v2 — handle legacy cookies Bumps marker version to 'v2'. buildMigrationPatch now also scans document.cookie for `sp:*` (split-pane positions) and `active_sprint_*` (active sprint per product) and lifts them into layout.splitPanePositions / layout.activeSprints. clearLegacyStorage replaces clearLegacyLocalStorage and clears both keys and cookies. clearLegacyLocalStorage stays as a deprecated alias so the bridge upgrade is a single rename. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(PBI-76): align tests with new SplitPane and active-sprint flow - split-pane.test.tsx: seed positions via Zustand store instead of document.cookie; mock @/actions/user-settings so the prisma client is not transitively initialised in jsdom. - backlog-split-pane.test.tsx: same action mock. - sprint-dates.test.ts: add user.findUnique/update + $executeRaw mocks because createSprintAction now writes to user-settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
210 lines
7.2 KiB
TypeScript
210 lines
7.2 KiB
TypeScript
import { Suspense } from 'react'
|
|
import { notFound, redirect } from 'next/navigation'
|
|
import { getSession } from '@/lib/auth'
|
|
import { getAccessibleProduct } from '@/lib/product-access'
|
|
import { prisma } from '@/lib/prisma'
|
|
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'
|
|
import type { Story } from '@/components/backlog/story-panel'
|
|
import { TaskPanel } from '@/components/backlog/task-panel'
|
|
import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper'
|
|
import { UrlTaskSync } from '@/components/backlog/url-task-sync'
|
|
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
|
|
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
|
|
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
|
|
import { StartSprintButton } from '@/components/sprint/start-sprint-button'
|
|
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
|
import { EditProductButton } from '@/components/products/edit-product-button'
|
|
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
|
import Link from 'next/link'
|
|
|
|
interface Props {
|
|
params: Promise<{ id: string }>
|
|
searchParams: Promise<{ newTask?: string; storyId?: string; editTask?: string }>
|
|
}
|
|
|
|
export default async function ProductBacklogPage({ params, searchParams }: Props) {
|
|
const { id } = await params
|
|
const { newTask, storyId: storyIdParam, editTask } = await searchParams
|
|
const closePath = `/products/${id}`
|
|
const session = await getSession()
|
|
if (!session.userId) redirect('/login')
|
|
|
|
const product = await getAccessibleProduct(id, session.userId)
|
|
if (!product) notFound()
|
|
|
|
const [user, switcherData] = await Promise.all([
|
|
prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }),
|
|
getSprintSwitcherData(id, { userId: session.userId }),
|
|
])
|
|
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({
|
|
where: { product_id: id },
|
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
|
})
|
|
|
|
const [stories, tasks] = await Promise.all([
|
|
prisma.story.findMany({
|
|
where: { product_id: id },
|
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
|
select: {
|
|
id: true,
|
|
code: true,
|
|
title: true,
|
|
description: true,
|
|
acceptance_criteria: true,
|
|
priority: true,
|
|
sort_order: true,
|
|
status: true,
|
|
pbi_id: true,
|
|
sprint_id: true,
|
|
created_at: true,
|
|
},
|
|
}),
|
|
prisma.task.findMany({
|
|
where: { story: { pbi: { product_id: id } } },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
description: true,
|
|
priority: true,
|
|
status: true,
|
|
sort_order: true,
|
|
story_id: true,
|
|
created_at: true,
|
|
},
|
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
|
}),
|
|
])
|
|
|
|
// Group stories by PBI id (status uit DB blijft UPPER_SNAKE in dit hydratie-pad)
|
|
const storiesByPbi: Record<string, Story[]> = {}
|
|
for (const story of stories) {
|
|
if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = []
|
|
storiesByPbi[story.pbi_id].push(story)
|
|
}
|
|
|
|
// Group tasks by story id
|
|
const tasksByStory: Record<string, typeof tasks> = {}
|
|
for (const task of tasks) {
|
|
if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = []
|
|
tasksByStory[task.story_id].push(task)
|
|
}
|
|
|
|
const isDemo = session.isDemo ?? false
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Product header — sprint-switcher gecentreerd, actions rechts */}
|
|
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center gap-3">
|
|
<div className="flex-1" />
|
|
<div className="flex items-center justify-center">
|
|
{isActiveProduct && (
|
|
<SprintSwitcher
|
|
productId={id}
|
|
sprints={sprintItems}
|
|
activeSprint={activeSprintItem}
|
|
buildingSprintIds={buildingSprintIds}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 flex items-center gap-3 justify-end">
|
|
{!isActiveProduct && (
|
|
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
|
|
)}
|
|
{hasOpenSprint ? (
|
|
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
|
|
Sprint actief →
|
|
</Link>
|
|
) : (
|
|
!isDemo && <StartSprintButton productId={id} />
|
|
)}
|
|
{!isDemo && product.user_id === session.userId && (
|
|
<EditProductButton
|
|
product={{
|
|
id: product.id,
|
|
name: product.name,
|
|
code: product.code,
|
|
description: product.description,
|
|
repo_url: product.repo_url,
|
|
definition_of_done: product.definition_of_done,
|
|
auto_pr: product.auto_pr,
|
|
}}
|
|
/>
|
|
)}
|
|
<Link
|
|
href={`/products/${id}/settings`}
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
Instellingen
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Split pane */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<BacklogHydrationWrapper
|
|
productId={id}
|
|
productName={product.name}
|
|
initialData={{
|
|
pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })),
|
|
storiesByPbi,
|
|
tasksByStory,
|
|
}}
|
|
>
|
|
<UrlTaskSync />
|
|
<BacklogSplitPane
|
|
cookieKey={`backlog-${id}`}
|
|
defaultSplit={[20, 45, 35]}
|
|
tabLabels={['PBI\'s', 'Stories', 'Taken']}
|
|
panes={[
|
|
<PbiList
|
|
key="pbi"
|
|
productId={id}
|
|
isDemo={isDemo}
|
|
/>,
|
|
<StoryPanel
|
|
key="story"
|
|
productId={id}
|
|
isDemo={isDemo}
|
|
/>,
|
|
<TaskPanel
|
|
key="tasks"
|
|
productId={id}
|
|
isDemo={isDemo}
|
|
closePath={closePath}
|
|
/>,
|
|
]}
|
|
/>
|
|
</BacklogHydrationWrapper>
|
|
</div>
|
|
|
|
{newTask && (
|
|
<TaskDialog
|
|
storyId={storyIdParam}
|
|
productId={id}
|
|
closePath={closePath}
|
|
isDemo={isDemo}
|
|
/>
|
|
)}
|
|
|
|
{editTask && !newTask && (
|
|
<Suspense fallback={<TaskDialogSkeleton />}>
|
|
<EditTaskLoader
|
|
taskId={editTask}
|
|
userId={session.userId}
|
|
productId={id}
|
|
closePath={closePath}
|
|
isDemo={isDemo}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|