docs: AI-optimized docs restructure (Phases 1–8) (#61)

* docs(dialog-pattern): add generic entity-dialog spec

Introduceert docs/patterns/dialog.md als bron-of-truth voor elke
create/edit/detail-dialog in Scrum4Me, ongeacht het achterliggende
dataobject. Bevat 14 secties: uitgangspunten, stack, component-
architectuur, layout, validatie, drielaagse demo-policy, submission,
dialog-gedrag, theming, footer, triggers/URL-state, per-entiteit
profile-template, out-of-scope, en een verificatie-checklist.

Registreert het patroon in CLAUDE.md "Implementatiepatronen"-tabel
zodat Claude (en mensen) de spec verplicht raadplegen voor elke
nieuwe dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(dialog-pattern): convert task spec + add pbi/story entity-profiles

Reduceert docs/scrum4me-task-dialog.md van 507 naar ~140 regels: alle
gedeelde regels verhuisd naar docs/patterns/dialog.md, dit document
bevat nu alleen Task-specifieke velden, URL-pattern, status-veld,
server actions, triggers en bewuste out-of-scope-keuzes.

Voegt twee nieuwe entity-profielen toe voor bestaande dialogen:
- docs/scrum4me-pbi-dialog.md (PbiDialog: state-based, code+title-rij,
  PbiStatusSelect, geen delete in v1)
- docs/scrum4me-story-dialog.md (StoryDialog: state-based, header met
  status/priority badges, inline activity-log, demo-readonly-fallback,
  inline-delete-confirm i.p.v. AlertDialog)

Beide profielen documenteren expliciet de "Bekende gaps t.o.v.
generieke spec" zodat opvolgende PR's de afwijkingen kunnen
rechtzetten of bewust kunnen accorderen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Added pdevelopment docs

* docs(plans): add docs-restructure plan for AI-optimized lookup

Audit of existing 39 doc files (~10.700 lines) and a phased restructure
proposal aimed at minimising the tokens an AI agent has to read to find
the right reference. Captures resolved decisions on language (English),
ADR template (Nygard default with MADR escape-hatch), index generator
(node script), and folder taxonomy. Proposal status — fase 1 to follow.

* docs(adr): add ADR scaffolding (templates, README, meta-ADR)

Set up docs/adr/ as the canonical home for architecture decisions:

- templates/nygard.md — default four-section format (Status, Context,
  Decision, Consequences) for one-way-door decisions.
- templates/madr.md — MADR v4 with YAML front-matter and explicit
  Considered Options for decisions where rejected alternatives matter.
- README.md — naming convention (NNNN-kebab-case), template-selection
  guidance (Nygard default; MADR for auth, queue mechanics, agent
  integration), status lifecycle, and ADR roster.
- 0000-record-architecture-decisions.md — meta-ADR establishing the
  practice itself, in Nygard format.

Backfilling existing implicit decisions (base-ui-over-radix, float
sort_order, demo-user three-layer policy, etc.) is fase 6 of the
docs-restructure plan.

* feat(docs): add docs index generator + initial INDEX.md

scripts/generate-docs-index.mjs walks docs/**/*.md, parses YAML
front-matter (or first H1 fallback) and a Nygard-style ## Status
section, then writes docs/INDEX.md with grouped tables for ADRs,
Specs, Plans (with archive subsection), Patterns, and Other.

Pure Node 20 (no external deps); idempotent — running it twice
produces byte-identical output. Excludes adr/templates/, the ADR
README, INDEX.md itself, and any *_*.md sidecar file.

Wire-up:
- package.json: docs:index → node scripts/generate-docs-index.mjs

Initial run indexed 35 docs across the existing structure; the
generated INDEX.md is committed so the table is reviewable in the
PR before hooking generation into a pre-commit step.

* chore: ignore Obsidian vault and personal sidecar files

Add .obsidian/ (Obsidian vault config) and _*.md (personal sidecar
notes) to .gitignore so the docs/ tree can serve as canonical source
of truth while still being usable as an Obsidian vault for personal
authoring. The docs index generator already excludes the same _*.md
pattern from INDEX.md.

* docs(plans): add PBI bulk-create spec for docs-restructure

Machine-parseable spec for an executor that calls the scrum4me MCP
(create_pbi → create_story → create_task) to seed the docs-restructure
work into the DB.

- Section 1 (Context) is the PBI description; serves as task-context
  via mcp__scrum4me__get_claude_context.
- Section 2 lists the 6 resolved decisions (English, MD3+styling
  merged, solo-paneel merged, .Plans archived, Nygard ADR default,
  node index script).
- Section 3 records what already shipped on this branch so the
  executor doesn't duplicate the ADR scaffolding or index generator.
- Section 4 carries the structured YAML graph: 1 PBI, 8 stories
  (one per phase), 39 tasks. product_id is REPLACE_ME — fill before
  running.
- YAML validated with PyYAML; field schema sanity-checked.

* docs(junk-cleanup): remove stub patterns/test.md

* docs(junk-cleanup): archive .Plans/ to docs/plans/archive/

* docs(front-matter): add YAML front-matter to docs/ root

* docs(front-matter): add YAML front-matter to patterns/

* docs(front-matter): add YAML front-matter to plans + agent files

* docs(index): regenerate INDEX.md after front-matter pass

* docs(naming): drop scrum4me- prefix from doc filenames

* docs(naming): lowercase API.md and MD3 filenames

* docs(naming): rename plan file to kebab-case ASCII

* docs(naming): rename middleware.md to proxy.md (next 16)

* docs(naming): polish CLAUDE.md doc-index after renames

* docs(taxonomy): scaffold topical folders under docs/

* docs(taxonomy): move spec files into docs/specs/

* docs(taxonomy): move design/api/qa/backlog/assets into folders

* docs(taxonomy): move agent-instruction-audit into decisions/

* docs(split): break architecture.md into 6 topical files

* docs(split): merge solo-paneel-spec into specs/functional.md

* docs(split): merge md3-color-scheme into design/styling

* docs(trim): extract branch/commit rules into runbook

* docs(trim): extract MCP integration into runbook

* docs(adr): add 0001-base-ui-over-radix

* docs(adr): add 0002-float-sort-order

* docs(adr): add 0003-one-branch-per-milestone

* docs(adr): add 0004-status-enum-mapping

* docs(adr): add 0005-iron-session-over-nextauth

* docs(adr): add 0006-demo-user-three-layer-policy

* docs(adr): add 0007-claude-question-channel-design

* docs(adr): add 0008-agent-instructions-in-claude-md + update README index

* docs(index): regenerate after ADR 0001-0008

* docs(glossary): add docs/glossary.md

* chore(docs): regenerate INDEX.md in pre-commit hook

* docs(readme): link INDEX + glossary + agent instructions

* feat(docs): add doc-link checker script

* chore(docs): wire docs:check-links and docs npm scripts

* ci(docs): block merge on broken doc links

* docs(links): fix broken cross-references after restructure

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-03 03:21:59 +02:00 committed by GitHub
parent 289bcf9bf0
commit 7e45bbdbc0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 12364 additions and 3154 deletions

View file

@ -1,3 +1,11 @@
---
title: "Scrum4Me — Functionele Specificatie"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
---
# Scrum4Me — Functionele Specificatie
**Versie:** 0.2 — april 2026
@ -468,7 +476,7 @@ Wanneer Claude Code tijdens het implementeren van een story een keuze niet uit d
**Data:**
- Nieuw: `claude_questions` (id, story_id, task_id?, product_id, asked_by, question, options?, status, answer?, answered_by?, answered_at?, created_at, expires_at)
- Postgres-trigger op `claude_questions` publiceert via `pg_notify('scrum4me_changes', ...)`
- Nieuwe MCP-tools in scrum4me-mcp: `ask_user_question`, `get_question_answer`, `list_open_questions`, `cancel_question`
- Nieuwe MCP-tools in mcp: `ask_user_question`, `get_question_answer`, `list_open_questions`, `cancel_question`
---
@ -648,3 +656,778 @@ Demo-gebruikers zien de knoppen maar krijgen een toast "Niet beschikbaar in demo
- **Product verlaten**: wanneer een lid het product verlaat, wordt hun `active_product_id` gecleard.
- **Lid verwijderen**: wanneer een eigenaar een lid verwijdert, wordt dat lid's `active_product_id` gecleard.
- **Stale referentie**: als bij een request `active_product_id` verwijst naar een gearchiveerd of onbereikbaar product (bijv. toegang ingetrokken in een andere sessie), cleared de layout de referentie server-side en redirect naar `/dashboard` met de toast "Je actieve product is niet meer beschikbaar".
---
## Solo Panel
> **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<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)
```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<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:
```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<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.
```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 <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>`.
```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 <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
---
## 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 `<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
```tsx
<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
```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<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)
```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. `<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"*
```typescript
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
```
```typescript
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 Actions**`requireProductWriter` (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
```tsx
// 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.*