From 5b0b8d5b7ed8975e946df4bbedc9920f06a7e9be Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 3 May 2026 00:59:23 +0200 Subject: [PATCH] docs(split): merge solo-paneel-spec into specs/functional.md --- .claude/settings.local.json | 3 +- docs/INDEX.md | 1 - docs/backlog/index.md | 2 +- docs/plans/docs-restructure-pbi-spec.md | 2 +- docs/solo-paneel-spec.md | 779 ------------------------ docs/specs/functional.md | 775 +++++++++++++++++++++++ 6 files changed, 779 insertions(+), 783 deletions(-) delete mode 100644 docs/solo-paneel-spec.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f065e36..3f5b214 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -77,7 +77,8 @@ "Bash(mv .Plans/2026-04-27-claude-md-workflow-update.md docs/plans/archive/)", "Bash(mv .Plans/2026-04-27-insert-milestone-tool.md docs/plans/archive/)", "Bash(mv .Plans/2026-04-27-m8-realtime-solo.md docs/plans/archive/)", - "Bash(xargs sed *)" + "Bash(xargs sed *)", + "Bash(python3 *)" ] }, "enableAllProjectMcpServers": true, diff --git a/docs/INDEX.md b/docs/INDEX.md index f6a2769..6fd6d7e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -77,4 +77,3 @@ Auto-generated on 2026-05-02 from front-matter and headings. | [Scrum4Me — Styling & Design System](./design/styling.md) | `design/styling.md` | active | 2026-05-03 | | [Obsidian as Personal Authoring Layer](./obsidian-authoring.md) | `obsidian-authoring.md` | active | 2026-05-02 | | [Scrum4Me — API Test Plan](./qa/api-test-plan.md) | `qa/api-test-plan.md` | active | 2026-05-03 | -| [Solo Paneel — Implementatie-specificatie](./solo-paneel-spec.md) | `solo-paneel-spec.md` | active | 2026-05-03 | diff --git a/docs/backlog/index.md b/docs/backlog/index.md index 3c536ce..60ad864 100644 --- a/docs/backlog/index.md +++ b/docs/backlog/index.md @@ -231,7 +231,7 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan ### M3.5: Solo Paneel & Story Assignment -> **Doel:** een persoonlijk Kanban-bord per product dat de taken toont van stories die geclaimd zijn door de ingelogde developer. Story-level assignment volgt het Scrum self-organizing principe: developers claimen vrijwillig stories (pull, niet push). Volledige technische specificatie in `solo-paneel-spec.md`. +> **Doel:** een persoonlijk Kanban-bord per product dat de taken toont van stories die geclaimd zijn door de ingelogde developer. Story-level assignment volgt het Scrum self-organizing principe: developers claimen vrijwillig stories (pull, niet push). Volledige technische specificatie in `specs/functional.md#solo-panel`. - [x] **ST-350** Story.assignee_id schema-migratie + auth-helpers - **Schema:** voeg `assignee_id String?` + `assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull)` toe aan `Story`; voeg `assigned_stories Story[] @relation("StoryAssignee")` toe aan `User`; voeg index `@@index([sprint_id, assignee_id])` toe; migratie via `prisma migrate dev --name add_story_assignee` diff --git a/docs/plans/docs-restructure-pbi-spec.md b/docs/plans/docs-restructure-pbi-spec.md index f3f8a9e..d6d2869 100644 --- a/docs/plans/docs-restructure-pbi-spec.md +++ b/docs/plans/docs-restructure-pbi-spec.md @@ -430,7 +430,7 @@ pbi: implementation_plan: | Append solo-paneel-spec.md content as a new H2 section "Solo Panel" inside docs/specs/functional.md. - git rm docs/solo-paneel-spec.md + git rm docs/specs/functional.md#solo-panel grep -rln "solo-paneel-spec" docs/ CLAUDE.md AGENTS.md README.md \ | xargs sed -i '' 's|docs/solo-paneel-spec\.md|docs/specs/functional.md#solo-panel|g' commit: docs(split): merge solo-paneel-spec into specs/functional.md diff --git a/docs/solo-paneel-spec.md b/docs/solo-paneel-spec.md deleted file mode 100644 index 261ca92..0000000 --- a/docs/solo-paneel-spec.md +++ /dev/null @@ -1,779 +0,0 @@ ---- -title: "Solo Paneel — Implementatie-specificatie" -status: active -audience: [maintainer, contributor] -language: nl -last_updated: 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_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. - -```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*: `