27 KiB
| title | status | audience | language | last_updated | ||
|---|---|---|---|---|---|---|
| Solo Paneel — Implementatie-specificatie | active |
|
nl | 2026-05-03 |
Solo Paneel — Implementatie-specificatie (v2)
Doel: een persoonlijk Kanban-bord per product dat de taken toont van stories die geclaimd zijn door de ingelogde developer. Bord werkt met drie kolommen (TO_DO / IN_PROGRESS / DONE), drag-and-drop tussen kolommen, en koppelt aan een nieuw
Story.assignee_idveld.Scope v1: geen REVIEW-status, geen multi-product aggregatie, geen taak-level overrides. Story-level assignment volstaat. Desktop-first conform ST-606 — onder 1024px tonen we dezelfde "te smal scherm"-melding als de rest van de app.
Versie: v2 — verwerkt antwoorden uit
backlog.mdover sessie-flag, bestaande Server Actions en desktop-first scope.
Wat veranderde t.o.v. v1
| Onderdeel | v1 aanname | v2 (op basis van backlog) |
|---|---|---|
isDemo toegang |
DB-lookup of session, ambivalent | Komt uit session.isDemo (ST-006, ST-604) — geen DB-call |
| Implementation_plan editen | Bestaande Server Action of API | Nieuwe updateTaskPlanAction (gericht, optimistisch-vriendelijk) |
| Mobiel | Optionele chunk 13 (tab-strip) | Geen mobile UI; volg ST-606 desktop-first patroon |
| Toast | Algemeen genoemd | Sonner is geïnstalleerd (ST-603) — gebruik consistent |
| Pending states | Niet uitgewerkt | useFormStatus of useTransition zoals ST-601 voorschrijft |
| Demo-tooltip tekst | "Read-only in demo-modus" | "Niet beschikbaar in demo-modus" zoals ST-604 |
| Sprint Board referentie | Generieke "sprint board" | ST-313 drie-panelen Sprint Board — assignee-UI komt in middenpaneel |
1. Datamodel — Prisma migratie
Eén veld erbij, één index erbij. Geen enum-wijzigingen.
model Story {
// ... bestaande velden ongewijzigd ...
assignee_id String?
assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)
@@index([sprint_id, assignee_id]) // hot path: solo-bord query
// bestaande indexen ongewijzigd
}
model User {
// ... bestaande velden ongewijzigd ...
assigned_stories Story[] @relation("StoryAssignee")
}
Migratie:
npx prisma migrate dev --name add_story_assignee
onDelete-keuze: SetNull zodat verwijderen van een user de stories behoudt (assignee valt terug naar "team"). Cascade zou stories vernietigen — niet wat we willen.
Named relation "StoryAssignee": voorkomt botsing met andere mogelijke User↔Story relations in de toekomst.
2. Auth-helper (lib/auth.ts uitbreiding)
isDemo zit al in de sessiecookie sinds ST-006 — geen DB-lookup nodig.
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
import { sessionOptions, type SessionData } from '@/lib/session'
import { prisma } from '@/lib/prisma'
export async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
export async function requireUser() {
const session = await getSession()
if (!session?.userId) throw new Error('Niet ingelogd')
return session
}
export async function requireWriter() {
const session = await requireUser()
if (session.isDemo) throw new Error('Niet beschikbaar in demo-modus')
return session
}
export async function requireProductAccess(productId: string) {
const session = await requireUser()
const product = await prisma.product.findFirst({
where: {
id: productId,
OR: [
{ user_id: session.userId }, // owner
{ members: { some: { user_id: session.userId } } }, // member
],
},
select: { id: true },
})
if (!product) throw new Error('Geen toegang tot dit product')
return session
}
export async function requireProductWriter(productId: string) {
const session = await requireProductAccess(productId)
if (session.isDemo) throw new Error('Niet beschikbaar in demo-modus')
return session
}
Patroon-uitleg:
requireUser— ingelogd, anders foutrequireWriter— ingelogd én niet-demorequireProductAccess— ingelogd én lid (read)requireProductWriter— ingelogd én lid én niet-demo (write)
Afhankelijkheid: controleer of bestaande actions/*.ts een eigen lokale getSession definiëren. Zo ja, optioneel migreren bij gelegenheid (geen blocker).
3. Server Actions
3a. Story-claim acties (actions/stories.ts uitbreiding)
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
import { requireProductWriter } from '@/lib/auth'
// ---------------------------------------------------------------------------
const claimSchema = z.object({
storyId: z.string().cuid(),
productId: z.string().cuid(),
})
export async function claimStoryAction(input: z.infer<typeof claimSchema>) {
const { storyId, productId } = claimSchema.parse(input)
const session = await requireProductWriter(productId)
await prisma.story.update({
where: { id: storyId, product_id: productId }, // tenant-guard
data: { assignee_id: session.userId },
})
revalidatePath(`/products/${productId}/sprint`)
revalidatePath(`/products/${productId}/solo`)
}
// ---------------------------------------------------------------------------
export async function unclaimStoryAction(input: z.infer<typeof claimSchema>) {
const { storyId, productId } = claimSchema.parse(input)
await requireProductWriter(productId)
await prisma.story.update({
where: { id: storyId, product_id: productId },
data: { assignee_id: null },
})
revalidatePath(`/products/${productId}/sprint`)
revalidatePath(`/products/${productId}/solo`)
}
// ---------------------------------------------------------------------------
const reassignSchema = z.object({
storyId: z.string().cuid(),
productId: z.string().cuid(),
targetUserId: z.string().cuid(),
})
export async function reassignStoryAction(input: z.infer<typeof reassignSchema>) {
const { storyId, productId, targetUserId } = reassignSchema.parse(input)
await requireProductWriter(productId)
// Valideer dat target-user lid is van het product (anders cross-tenant assignment)
const isMember = await prisma.product.findFirst({
where: {
id: productId,
OR: [
{ user_id: targetUserId },
{ members: { some: { user_id: targetUserId } } },
],
},
select: { id: true },
})
if (!isMember) throw new Error('Doel-gebruiker is geen lid van dit product')
await prisma.story.update({
where: { id: storyId, product_id: productId },
data: { assignee_id: targetUserId },
})
revalidatePath(`/products/${productId}/sprint`)
revalidatePath(`/products/${productId}/solo`)
}
// ---------------------------------------------------------------------------
const bulkClaimSchema = z.object({ productId: z.string().cuid() })
export async function claimAllUnassignedInActiveSprintAction(
input: z.infer<typeof bulkClaimSchema>,
) {
const { productId } = bulkClaimSchema.parse(input)
const session = await requireProductWriter(productId)
const activeSprint = await prisma.sprint.findFirst({
where: { product_id: productId, status: 'ACTIVE' },
select: { id: true },
})
if (!activeSprint) throw new Error('Geen actieve sprint gevonden')
const result = await prisma.story.updateMany({
where: {
sprint_id: activeSprint.id,
product_id: productId,
assignee_id: null,
},
data: { assignee_id: session.userId },
})
revalidatePath(`/products/${productId}/sprint`)
revalidatePath(`/products/${productId}/solo`)
return { claimed: result.count }
}
3b. Implementation plan editen (actions/tasks.ts uitbreiding)
Bestaande updateTaskStatus (ST-310) en updateTask (ST-311) blijven ongewijzigd. We voegen één nieuwe gerichte action toe:
'use server'
const planSchema = z.object({
taskId: z.string().cuid(),
productId: z.string().cuid(), // voor tenant-guard
implementationPlan: z.string().max(20000),
})
export async function updateTaskPlanAction(input: z.infer<typeof planSchema>) {
const { taskId, productId, implementationPlan } = planSchema.parse(input)
await requireProductWriter(productId)
// Tenant-guard via geneste relatie
await prisma.task.update({
where: {
id: taskId,
story: { product_id: productId }, // verifieer dat task bij product hoort
},
data: { implementation_plan: implementationPlan },
})
revalidatePath(`/products/${productId}/solo`)
revalidatePath(`/products/${productId}/sprint`)
}
Waarom een aparte action: korter, optimistisch-vriendelijk (kleine payload, lage latency), past bij save-on-blur in de detail-dialoog. De bestaande updateTask is voor volledige edits via een formulier.
Toast/UX: geen success-toast (te frequent bij save-on-blur). Wel error-toast bij fout. Indicator in dialoog ("Bezig met opslaan…" / "Opgeslagen").
4. Routes en pagina's
app/
├── solo/
│ └── page.tsx # /solo → redirect of picker
└── products/
└── [id]/
├── sprint/page.tsx # bestaand (ST-313 drie-panelen) — krijgt UI-uitbreidingen
└── solo/
└── page.tsx # /products/[id]/solo → het bord
4a. /solo — Redirect-pagina
Server Component. Leest cookie lastProductId, valideert toegang, redirect.
// app/solo/page.tsx
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { requireUser } from '@/lib/auth'
import { ProductPicker } from '@/components/solo/product-picker'
export default async function SoloRedirectPage() {
const session = await requireUser()
const lastProductId = (await cookies()).get('lastProductId')?.value
if (lastProductId) {
const valid = await prisma.product.findFirst({
where: {
id: lastProductId,
archived: false,
OR: [
{ user_id: session.userId },
{ members: { some: { user_id: session.userId } } },
],
},
select: { id: true },
})
if (valid) redirect(`/products/${valid.id}/solo`)
}
// Geen valide cookie → toon picker
const products = await prisma.product.findMany({
where: {
archived: false,
OR: [
{ user_id: session.userId },
{ members: { some: { user_id: session.userId } } },
],
},
select: { id: true, name: true },
orderBy: { updated_at: 'desc' },
})
return <ProductPicker products={products} basePath="/solo" />
}
4b. /products/[id]/solo — Het Solo Bord
Server Component. Doet alle queries en geeft data door aan een client-side <SoloBoard>.
// app/products/[id]/solo/page.tsx
import { notFound } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { requireUser } from '@/lib/auth'
import { setLastProductCookie } from '@/lib/cookies'
import { SoloBoard } from '@/components/solo/solo-board'
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
export default async function SoloPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const session = await requireUser()
await setLastProductCookie(id)
const product = await prisma.product.findFirst({
where: {
id,
OR: [
{ user_id: session.userId },
{ members: { some: { user_id: session.userId } } },
],
},
select: { id: true, name: true },
})
if (!product) notFound()
const activeSprint = await prisma.sprint.findFirst({
where: { product_id: id, status: 'ACTIVE' },
select: { id: true, sprint_goal: true },
})
if (!activeSprint) return <NoActiveSprint product={product} />
// Parallel: eigen taken + count ongeclaimde stories
const [tasks, unassignedStoryCount] = await Promise.all([
prisma.task.findMany({
where: {
sprint_id: activeSprint.id,
story: { assignee_id: session.userId },
},
select: {
id: true,
title: true,
priority: true,
sort_order: true,
status: true,
description: true,
implementation_plan: true,
story: { select: { id: true, title: true } },
},
orderBy: [{ priority: 'desc' }, { sort_order: 'asc' }],
}),
prisma.story.count({
where: { sprint_id: activeSprint.id, assignee_id: null },
}),
])
return (
<SoloBoard
product={product}
sprint={activeSprint}
tasks={tasks}
unassignedStoryCount={unassignedStoryCount}
isDemo={session.isDemo}
/>
)
}
Performance:
- Query gebruikt
[sprint_id, assignee_id]index die we toevoegen → snelle filter Promise.allparallelliseert de twee onafhankelijke queriesselectprojectie houdt payload klein
5. Cookie-helper (lib/cookies.ts)
'use server'
import { cookies } from 'next/headers'
const ONE_MONTH = 60 * 60 * 24 * 30
export async function setLastProductCookie(productId: string) {
const store = await cookies()
store.set('lastProductId', productId, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: ONE_MONTH,
path: '/',
})
}
6. Sprint Board (ST-313) uitbreidingen
In het middenpaneel (Sprint Backlog) van het drie-panelen Sprint Board komen de assignee-UI elementen.
6a. Story-kaart op het Sprint Backlog paneel
Nieuwe elementen op elke story-kaart:
┌──────────────────────────────────────────────────┐
│ ⚡ Story title [···] │ ← actie-menu rechts
│ Some PBI · 3 taken │
│ ───────────────────────────────────────────── │
│ [👤 jan.visser] of [— Niet geclaimd] │ ← assignee-chip
└──────────────────────────────────────────────────┘
Assignee-chip: klein component met <UserAvatar size="xs"> + username, of een muted badge (bg-muted text-muted-foreground) als assignee_id === null.
Actie-menu (shadcn DropdownMenu):
- Pak op →
claimStoryAction— zichtbaar als ongeclaimd of niet-jij - Geef terug aan team →
unclaimStoryAction— zichtbaar als geclaimd - Wijs toe aan ▶ (submenu met members) →
reassignStoryAction
Demo-modus: hele dropdown disabled met tooltip "Niet beschikbaar in demo-modus" (consistent met ST-604).
6b. Bovenaan het Sprint Backlog paneel
<div className="flex items-center justify-between">
<h2>Sprint Backlog</h2>
<Button
onClick={handleClaimAll}
disabled={unassignedCount === 0 || isDemo}
variant="outline"
>
Claim alle ongeclaimde stories ({unassignedCount})
</Button>
</div>
Na succes: Sonner-toast "X stories geclaimd" (gewone success-toast, niet drag-and-drop frequentie). Bij demo: knop disabled met tooltip "Niet beschikbaar in demo-modus".
7. Solo Paneel componenten
components/solo/
├── solo-board.tsx # Client root, dnd context, layout
├── solo-column.tsx # Drop target per status
├── solo-task-card.tsx # Draggable kaart (bestaande task-card hergebruiken)
├── task-detail-dialog.tsx # Shadcn Dialog
├── unassigned-stories-sheet.tsx # Shadcn Sheet
├── no-active-sprint.tsx # Empty state
└── product-picker.tsx # Voor /solo zonder cookie
7a. <SoloBoard> — root component
'use client'
interface Props {
product: { id: string; name: string }
sprint: { id: string; sprint_goal: string }
tasks: TaskWithStory[]
unassignedStoryCount: number
isDemo: boolean
}
export function SoloBoard({ product, sprint, tasks, unassignedStoryCount, isDemo }: Props) {
// Zustand store gehydrateerd met initiële taken
// DndContext (overslaan als isDemo) met sensor + collision detection
// Header: productnaam, sprint goal, knop "Toon openstaande stories (N)"
// Drie kolommen in een grid (md:grid-cols-3)
}
7b. Zustand store (stores/solo-store.ts)
Volgt het patroon van usePlannerStore (ST-201): init*, optimistic*, rollback*.
import { create } from 'zustand'
import type { TaskStatus } from '@prisma/client'
interface SoloState {
tasks: TaskWithStory[]
initTasks: (tasks: TaskWithStory[]) => void
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null // returns prev for rollback
rollback: (taskId: string, prevStatus: TaskStatus) => void
updatePlan: (taskId: string, plan: string) => void
}
export const useSoloStore = create<SoloState>((set, get) => ({
tasks: [],
initTasks: (tasks) => set({ tasks }),
optimisticMove: (taskId, toStatus) => {
const task = get().tasks.find(t => t.id === taskId)
if (!task) return null
const prev = task.status
set({ tasks: get().tasks.map(t => t.id === taskId ? { ...t, status: toStatus } : t) })
return prev
},
rollback: (taskId, prevStatus) => {
set({ tasks: get().tasks.map(t => t.id === taskId ? { ...t, status: prevStatus } : t) })
},
updatePlan: (taskId, plan) => {
set({ tasks: get().tasks.map(t => t.id === taskId ? { ...t, implementation_plan: plan } : t) })
},
}))
7c. Drag-and-drop (dnd-kit)
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over) return
const taskId = String(active.id)
const toStatus = String(over.id) as TaskStatus // kolom-id = status enum-value
if (!['TO_DO', 'IN_PROGRESS', 'DONE'].includes(toStatus)) return
const prev = useSoloStore.getState().optimisticMove(taskId, toStatus)
if (prev === null || prev === toStatus) return
startTransition(async () => {
try {
await updateTaskStatusAction(taskId, toStatus)
} catch (err) {
useSoloStore.getState().rollback(taskId, prev)
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
}
})
}
Sensor-keuze: PointerSensor met activationConstraint: { distance: 5 } om accidentele drags op klik te voorkomen. Klik = open dialog, drag = verplaats.
Collision detection: closestCorners voor kolom-niveau drops; geen sortering binnen kolom in v1.
Toast-strategie: consistent met ST-603 — geen success-toast bij drag (te frequent), wél error-toast bij rollback.
Demo-user: sla de hele DndContext over en wrap kaart-componenten zonder draggable. Klik werkt nog wel (lezen mag).
7d. <SoloColumn>
Status-token mapping (briefing):
| Status | Header background |
|---|---|
TO_DO |
bg-status-todo/15 text-status-todo border-status-todo/30 |
IN_PROGRESS |
bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30 |
DONE |
bg-status-done/15 text-status-done border-status-done/30 |
7e. <SoloTaskCard> — hergebruik bestaande task-card
Bestaande task-card uit het sprint board (ST-313 rechterpaneel) hergebruiken. Pas zo nodig aan:
- Linker-rand of dot met
bg-priority-{level}voor prioriteit - Taaktitel (
font-medium,truncate) - Story-titel (
text-sm text-muted-foreground,truncate) - Optionele
showProduct?: booleanprop (off op product-specifieke pagina; reservering voor toekomstig multi-product bord)
Klik → opent <TaskDetailDialog> met deze taak.
7f. <TaskDetailDialog>
Shadcn Dialog. Inhoud:
- Header: taaktitel + statusbadge (gekleurd via MD3 tokens)
- Sectie Beschrijving (read-only
<p>of formatted block — volg bestaand task-detailpatroon) - Sectie Implementatieplan:
<Textarea>met save-on-blur- On blur:
updateTaskPlanAction({ taskId, productId, implementationPlan }) - Indicator rechtsonder: "Bezig met opslaan…" tijdens transition, "Opgeslagen" daarna (vervaagt na 2s)
- Bij fout: error-toast + waarde rollback in store
- On blur:
- Footer: link "Open in Sprint Board ↗" naar
/products/[id]/sprint?storyId=... - Demo-modus: textarea heeft
readOnly+ tooltip "Niet beschikbaar in demo-modus"
function handleBlur(plan: string) {
if (plan === task.implementation_plan) return // geen no-op call
startTransition(async () => {
try {
await updateTaskPlanAction({ taskId: task.id, productId, implementationPlan: plan })
useSoloStore.getState().updatePlan(task.id, plan)
} catch (err) {
toast.error('Opslaan mislukt')
}
})
}
Markdown-rendering voor implementatieplan kan in v2; voor v1 plain text in textarea — sneller te bouwen, past bij scope.
7g. <UnassignedStoriesSheet>
Shadcn Sheet (slide-out van rechts). Trigger: knop bovenaan het bord met badge (N).
Inhoud:
- Lijst van ongeclaimde stories in actieve sprint, met titel + taakaantal
- Per item: knop "Pak op" →
claimStoryAction→ revalidate → Sonner toast - Sheet blijft open tot user 'm sluit (zodat meerdere achter elkaar claimen kan)
- Lege staat: "Geen ongeclaimde stories. Lekker bezig!"
useFormStatus op de claim-knoppen voor pending state (ST-601).
7h. <NoActiveSprint> — empty state
Geen ACTIVE sprint: nette empty-state met titel, korte uitleg en link naar productpagina om er een te starten (ST-302 stappen).
8. <UserAvatar> component (nieuw, herbruikbaar)
components/ui/user-avatar.tsx
interface Props {
userId: string
username: string
size?: 'xs' | 'sm' | 'md' | 'lg'
className?: string
}
export function UserAvatar({ userId, username, size = 'md', className }: Props) {
const sizeClasses = {
xs: 'h-5 w-5 text-[10px]',
sm: 'h-6 w-6 text-xs',
md: 'h-8 w-8 text-sm',
lg: 'h-10 w-10 text-base',
}
const initials = username.slice(0, 2).toUpperCase()
return (
<Avatar className={cn(sizeClasses[size], className)}>
<AvatarImage
src={`/api/users/${userId}/avatar`}
alt={username}
/>
<AvatarFallback className="bg-primary-container text-primary">
{initials}
</AvatarFallback>
</Avatar>
)
}
Gebaseerd op shadcn <Avatar>. Fallback in MD3-token (bg-primary-container).
Aandachtspunt: als /api/users/[id]/avatar 404 returnt (user heeft geen avatar gezet), valt shadcn automatisch terug op <AvatarFallback> met initialen. Test dit gedrag — anders forceer je via onError.
Hergebruik: dit component is ook nuttig in toekomstige plekken (story-detail, instellingen, sprint board kaarten) — geen Solo-specifieke component.
9. Demo-modus
Eenvoudig nu we weten dat isDemo in de sessiecookie zit:
Drie plekken waar isDemo ertoe doet:
- Server Actions —
requireProductWriter(enrequireWriter) throwt early met "Niet beschikbaar in demo-modus". Doe je niets, dan kan een demo-user via gespoofte requests toch wijzigen. - UI-knoppen — disabled + tooltip "Niet beschikbaar in demo-modus" (ST-604 conventie). Pass
isDemoals prop door vanaf de Server Component. - DndContext — wrap kaarten zonder
useDraggablealsisDemo, of zetdisabledop de hele context.
Seed-vereiste: in prisma/seed.ts (ST-004) zorgen dat de demo-user (is_demo = true) een product heeft met:
- Een ACTIVE sprint
- Stories met
assignee_id = demoUser.iden bijbehorende taken in alle drie statussen (om bord werkend te tonen) - Minstens 1 ongeclaimde story (om "Toon openstaande" te demonstreren — demo-user kan niet claimen, ziet wel hoe het werkt)
10. Navbar
// components/navbar.tsx (uitbreiding)
<NavLink href="/solo" icon={<UserSquare className="h-4 w-4" />}>
Solo
</NavLink>
Plek: tussen "Producten" en "Todos" (of zoals layout het bepaalt). Altijd zichtbaar voor ingelogde users — geen product-context nodig, die kiest de redirect-handler zelf.
11. Werkvolgorde voor Claude Code (chunks)
Elke chunk komt overeen met een story uit M3.5 in de backlog en is afzonderlijk reviewbaar en commitbaar.
| # | Story | Inhoud | Verifiëer met |
|---|---|---|---|
| 1 | ST-350 | Schema-migratie + auth-helpers | prisma migrate dev slaagt; helpers werken vanuit testbestand |
| 2 | ST-351 | <UserAvatar> component |
Visuele check op 4 sizes; fallback bij ontbrekende avatar |
| 3 | ST-352 | Story-claim Server Actions (4 acties) | Aanroepen vanuit Sprint Board of test-route; demo-guard werkt |
| 4 | ST-353 | Sprint Board: assignee-chip + dropdown | Klikken claimt; demo-user krijgt disabled tooltip |
| 5 | ST-354 | Sprint Board: bulk-claim knop + count | Werkt in regular/demo (disabled) sessie + toast |
| 6 | ST-355 | Solo route + queries + empty states + cookie | /solo redirect werkt; pagina toont juiste taken |
| 7 | ST-356 | Solo Kanban + Zustand + DnD | Sleep tussen kolommen, status persisteert; netwerk-fail → rollback |
| 8 | ST-357 | Task detail-dialoog + updateTaskPlanAction |
Edit, blur, refresh: persisteert; demo: read-only |
| 9 | ST-358 | Openstaande stories sheet | Sheet opent met N items; claimen werkt; lege staat correct |
| 10 | ST-359 | Navbar-link "Solo" | Klik gaat naar /solo (en redirect verder) |
| 11 | ST-360 | Demo-seed uitbreiden | Login als demo, Solo bord toont werkende staat |
Bouwvolgorde-inzicht: chunks 1-5 leveren al op het Sprint Board (ST-313) een werkend assignment-systeem. Daar is een natuurlijke release-grens. Chunks 6-9 vormen het Solo Paneel zelf. Chunks 10-11 zijn polish & demo.
12. Acceptatiecriteria (volledig v1)
Functioneel:
- Een user kan op het Sprint Board een story claimen, teruggeven, of aan een andere member toewijzen
- Een user kan met één klik alle ongeclaimde stories in de actieve sprint claimen
/soloredirect naar laatst-bezochte product, met fallback naar product-picker- Solo-bord toont alle taken van geclaimde stories in de actieve sprint, gegroepeerd in 3 kolommen
- Drag-and-drop tussen kolommen update status, met optimistische UI en rollback bij fout
- Klik op taakkaart opent dialoog met bewerkbaar implementatieplan (save-on-blur)
- Knop bovenaan toont openstaande stories en laat ze individueel claimen
- Navbar-link "Solo" altijd zichtbaar voor ingelogde users
Niet-functioneel:
- Demo-user kan lezen maar niets muteren — alle Server Actions throwen, alle knoppen disabled met tooltip "Niet beschikbaar in demo-modus"
- Membership-check werkt voor zowel owner (
Product.user_id) als members (ProductMember) - Reassignment kan alleen naar geldige product-members
- Foutberichten in het Nederlands voor eindgebruikers
- Stylingregels uit briefing (MD3-tokens) consistent toegepast
- Desktop-first; volgt ST-606 melding bij < 1024px
Performance:
- Solo-pagina laadt < 500ms voor sprint met 50 taken (lokaal)
- Optimistische update voelt direct (< 50ms)
13. Nog open / mogelijke v1.1
- Sortering binnen kolom — drag binnen kolom is in v1 een no-op. Toekomstige uitbreiding via
solo_sort_orderveld of een aparteUserTaskOrder-tabel. - Markdown-rendering implementatieplan — v2; v1 is plain textarea.
- Multi-product Solo bord — alle producten in één bord. Component is hier al op voorbereid via optionele
showProductprop op task card. - REVIEW-status — bewuste scope-uitstel; voegt later kolom + enum-migratie toe.
Klaar om te valideren en aan Claude Code te geven.