From f880cd3c8f74e09d071b95f2389e263d43e38d62 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 26 Apr 2026 20:49:29 +0200 Subject: [PATCH] docs(ST-355): add Solo Paneel implementation spec Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/solo-paneel-spec.md | 771 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 771 insertions(+) create mode 100644 docs/solo-paneel-spec.md diff --git a/docs/solo-paneel-spec.md b/docs/solo-paneel-spec.md new file mode 100644 index 0000000..949776c --- /dev/null +++ b/docs/solo-paneel-spec.md @@ -0,0 +1,771 @@ +# 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 `scrum4me-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. + +```prisma +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:** + +```bash +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. + +```typescript +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(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) + +```typescript +'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) { + 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) { + 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) { + 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, +) { + 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: + +```typescript +'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) { + 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. + +```typescript +// 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 +} +``` + +### 4b. `/products/[id]/solo` — Het Solo Bord + +Server Component. Doet alle queries en geeft data door aan een client-side ``. + +```typescript +// 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 + + // 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 ( + + ) +} +``` + +**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 + +--- + +## 5. Cookie-helper (`lib/cookies.ts`) + +```typescript +'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 `` + 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 + +```tsx +
+

Sprint Backlog

+ +
+``` + +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. `` — root component + +```typescript +'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*`. + +```typescript +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((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) + +```typescript +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. `` + +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. `` — 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 `` met deze taak. + +### 7f. `` + +Shadcn `Dialog`. Inhoud: +- Header: taaktitel + statusbadge (gekleurd via MD3 tokens) +- Sectie *Beschrijving* (read-only `

` of formatted block — volg bestaand task-detailpatroon) +- Sectie *Implementatieplan*: `