feat(ST-1138): mobile Solo-pagina + verify TaskDetailDialog (T-331/T-332/T-333)

- app/(mobile)/m/products/[id]/solo/page.tsx — hergebruikt SoloBoard 1:1 met
  desktop. 3-koloms-kanban blijft, NoActiveSprint-fallback ongewijzigd
- T-332 verify-only: TaskDetailDialog regel 383 gebruikt
  entityDialogContentClasses → mobile-fullscreen erft automatisch uit ST-1133
- Tests: regressie-vangnet op SoloBoard-hergebruik, requireSession,
  NoActiveSprint, en op TaskDetailDialog-className-wiring (geen override)

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

View file

@ -0,0 +1,35 @@
// ST-1138: regressie-vangnet voor mobile solo-page (server component).
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]/solo/page.tsx')
const TASK_DETAIL = resolve(process.cwd(), 'components/solo/task-detail-dialog.tsx')
describe('mobile solo page (ST-1138)', () => {
const src = readFileSync(PAGE, 'utf-8')
it('hergebruikt SoloBoard zonder content-aanpassingen', () => {
expect(src).toContain('SoloBoard')
expect(src).toContain("from '@/components/solo/solo-board'")
})
it('auth via gedeelde requireSession()', () => {
expect(src).toContain("from '@/lib/auth-guard'")
expect(src).toContain('requireSession()')
})
it('geeft NoActiveSprint terug als geen actieve sprint (zelfde gedrag als desktop)', () => {
expect(src).toContain('NoActiveSprint')
})
})
describe('TaskDetailDialog erft mobile-fullscreen (ST-1138 T-332 verify-only)', () => {
// Beslissing A: TaskDetailDialog gebruikt entityDialogContentClasses; mobile-classes
// komen automatisch door uit T-317. Dit test bewijst de wiring blijft staan.
const src = readFileSync(TASK_DETAIL, 'utf-8')
it('rendert DialogContent met entityDialogContentClasses (geen eigen className-override)', () => {
expect(src).toContain('className={entityDialogContentClasses}')
})
})

View file

@ -0,0 +1,120 @@
// PBI-11 / ST-1138: Mobile Solo Paneel — wraps de bestaande SoloBoard zonder
// content-aanpassingen. 3-koloms-kanban blijft (overflow-x scrollt zijwaarts).
// TaskDetailDialog krijgt full-screen-mobile via gedeelde
// entityDialogContentClasses (beslissing A in docs/plans/PBI-11-mobile-shell.md;
// ingebouwd via ST-1133/T-317).
import { notFound } from 'next/navigation'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { requireSession } from '@/lib/auth-guard'
import { SoloBoard } from '@/components/solo/solo-board'
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
import type { SoloTask } from '@/components/solo/solo-board'
import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet'
interface Props {
params: Promise<{ id: string }>
}
export default async function MobileSoloProductPage({ params }: Props) {
const { id } = await params
const session = await requireSession()
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const sprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' },
})
if (!sprint) {
return (
<div className="flex flex-col h-full">
<NoActiveSprint productId={id} productName={product.name} />
</div>
)
}
const [rawTasks, rawUnassigned] = await Promise.all([
prisma.task.findMany({
where: {
story: {
sprint_id: sprint.id,
assignee_id: session.userId,
},
},
include: {
story: {
select: {
id: true,
code: true,
title: true,
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
},
},
},
orderBy: [
{ story: { pbi: { priority: 'asc' } } },
{ story: { pbi: { sort_order: 'asc' } } },
{ story: { sort_order: 'asc' } },
{ priority: 'asc' },
{ sort_order: 'asc' },
],
}),
prisma.story.findMany({
where: { sprint_id: sprint.id, assignee_id: null },
select: {
id: true,
code: true,
title: true,
tasks: {
select: { id: true, title: true, description: true, priority: true, status: true },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
orderBy: { sort_order: 'asc' },
}),
])
const tasks: SoloTask[] = rawTasks.map(t => ({
id: t.id,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
priority: t.priority,
sort_order: t.sort_order,
status: t.status as SoloTask['status'],
verify_only: t.verify_only,
verify_required: t.verify_required as SoloTask['verify_required'],
story_id: t.story.id,
story_code: t.story.code,
story_title: t.story.title,
task_code: t.code,
}))
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
id: s.id,
code: s.code,
title: s.title,
tasks: s.tasks.map(t => ({
id: t.id,
title: t.title,
description: t.description,
priority: t.priority,
status: t.status,
})),
}))
return (
<SoloBoard
productId={id}
sprintGoal={sprint.sprint_goal}
tasks={tasks}
unassignedStories={unassignedStories}
isDemo={session.isDemo ?? false}
currentUserId={session.userId}
repoUrl={product.repo_url}
/>
)
}