feat(ST-1137): mobile Product Backlog-pagina (T-328/T-329/T-330)

- app/(mobile)/m/products/[id]/page.tsx — hergebruikt BacklogHydrationWrapper +
  BacklogSplitPane + PbiList/StoryPanel/TaskPanel (1:1 zelfde data-fetch als
  desktop-page; demo blijft read-only via PbiList/StoryPanel)
- Cookie-key gescheiden: `backlog-${id}-mobile` (beslissing C in
  docs/plans/PBI-11-mobile-shell.md) — tab-mode-gebruikers vervuilen de
  desktop-split-percentages niet
- closePath en redirect-targets blijven onder /m/products/
- Tab-mode rendert automatisch op <1024px via SplitPane (uit ST-1116)
- Tests: regressie-vangnet op cookie-key, /m/-paden, hergebruik

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 10:14:20 +02:00
parent 0a3dc401b7
commit 5b42740461
2 changed files with 182 additions and 0 deletions

View file

@ -0,0 +1,38 @@
// Lichte regressie-tests voor de mobile backlog-page. Server-component render
// vereist te veel mocking; we asserten op statische source-eigenschappen die
// kritisch zijn voor de mobile-shell (cookie-key gescheiden, /m/-paden).
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/page.tsx')
const src = readFileSync(PAGE, 'utf-8')
describe('mobile backlog page (ST-1137)', () => {
it('gebruikt gescheiden cookie-key (backlog-{id}-mobile)', () => {
// Beslissing C: tab-mode-gebruikers vervuilen desktop-split niet.
expect(src).toMatch(/cookieKey=\{`backlog-\$\{id\}-mobile`\}/)
})
it('closePath en TaskDialog redirect blijven onder /m/products/', () => {
expect(src).toContain('const closePath = `/m/products/${id}`')
})
it('hergebruikt BacklogHydrationWrapper + BacklogSplitPane (geen content-componenten dupliceren)', () => {
expect(src).toContain('BacklogHydrationWrapper')
expect(src).toContain('BacklogSplitPane')
expect(src).toContain('PbiList')
expect(src).toContain('StoryPanel')
expect(src).toContain('TaskPanel')
})
it('auth via requireSession() (gedeelde guard)', () => {
expect(src).toContain("from '@/lib/auth-guard'")
expect(src).toContain('requireSession()')
})
it('rendert TaskDialog op ?newTask en EditTaskLoader op ?editTask', () => {
expect(src).toContain('{newTask &&')
expect(src).toContain('{editTask && !newTask &&')
})
})

View file

@ -0,0 +1,144 @@
// PBI-11 / ST-1137: Mobile Product Backlog. Wraps de 3-paneel-backlog in de
// mobile-shell. BacklogSplitPane rendert automatisch tab-mode op <1024px
// (uit ST-1116). Cookie-key gescheiden van desktop zodat tab-mode-gebruikers
// de desktop-split niet vervuilen (beslissing C in docs/plans/PBI-11-mobile-shell.md).
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { pbiStatusToApi } from '@/lib/task-status'
import { requireSession } from '@/lib/auth-guard'
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 { 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'
interface Props {
params: Promise<{ id: string }>
searchParams: Promise<{ newTask?: string; storyId?: string; editTask?: string }>
}
export default async function MobileProductBacklogPage({ params, searchParams }: Props) {
const { id } = await params
const { newTask, storyId: storyIdParam, editTask } = await searchParams
const closePath = `/m/products/${id}`
const session = await requireSession()
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
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,
status: true,
pbi_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' }],
}),
])
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)
}
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">
<BacklogHydrationWrapper
productId={id}
initialData={{
pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })),
storiesByPbi,
tasksByStory,
}}
>
<BacklogSplitPane
cookieKey={`backlog-${id}-mobile`}
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>
{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>
)
}