Scrum4Me/docs/solo-paneel-spec.md
Scrum4Me Agent 29597e96a7 docs(naming): drop scrum4me- prefix from doc filenames
Rename 10 docs/scrum4me-*.md files to unprefixed kebab-case names.
Update every internal link in docs/, CLAUDE.md, AGENTS.md, README.md.
2026-05-03 00:07:38 +02:00

27 KiB

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_id veld.

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.md over 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 fout
  • requireWriter — ingelogd én niet-demo
  • requireProductAccess — 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.all parallelliseert de twee onafhankelijke queries
  • select projectie houdt payload klein

'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 opclaimStoryAction — zichtbaar als ongeclaimd of niet-jij
  • Geef terug aan teamunclaimStoryAction — 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?: boolean prop (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
  • 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:

  1. Server ActionsrequireProductWriter (en requireWriter) throwt early met "Niet beschikbaar in demo-modus". Doe je niets, dan kan een demo-user via gespoofte requests toch wijzigen.
  2. UI-knoppen — disabled + tooltip "Niet beschikbaar in demo-modus" (ST-604 conventie). Pass isDemo als prop door vanaf de Server Component.
  3. DndContext — wrap kaarten zonder useDraggable als isDemo, of zet disabled op 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.id en 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
  • /solo redirect 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

  1. Sortering binnen kolom — drag binnen kolom is in v1 een no-op. Toekomstige uitbreiding via solo_sort_order veld of een aparte UserTaskOrder-tabel.
  2. Markdown-rendering implementatieplan — v2; v1 is plain textarea.
  3. Multi-product Solo bord — alle producten in één bord. Component is hier al op voorbereid via optionele showProduct prop op task card.
  4. REVIEW-status — bewuste scope-uitstel; voegt later kolom + enum-migratie toe.

Klaar om te valideren en aan Claude Code te geven.