diff --git a/__tests__/app/m-products-page.test.ts b/__tests__/app/m-products-page.test.ts new file mode 100644 index 0000000..8fd22c5 --- /dev/null +++ b/__tests__/app/m-products-page.test.ts @@ -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 &&') + }) +}) diff --git a/app/(mobile)/m/products/[id]/page.tsx b/app/(mobile)/m/products/[id]/page.tsx new file mode 100644 index 0000000..136188d --- /dev/null +++ b/app/(mobile)/m/products/[id]/page.tsx @@ -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 = {} + for (const story of stories) { + if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] + storiesByPbi[story.pbi_id].push(story) + } + + const tasksByStory: Record = {} + 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 ( +
+ ({ 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, + }} + > + , + , + , + ]} + /> + + + {newTask && ( + + )} + + {editTask && !newTask && ( + }> + + + )} +
+ ) +}