Scrum4Me/CLAUDE.md
janpeter visser 4dd62c199c feat: ST-201-ST-210 M2 stories, drag-and-drop en Zustand stores
- usePlannerStore met pbiOrder/storyOrder init/reorder/rollback (ST-201)
- useSelectionStore uitgebreid met selectedStoryId en clearSelection (ST-202)
- PBI drag-and-drop binnen prioriteitsgroep via dnd-kit (ST-203)
- PBI slepen over prioriteitsgrens wijzigt priority (ST-204)
- Stories als blokken met prioriteit- en statusbadge (ST-205/ST-206)
- Story drag-and-drop horizontaal binnen en tussen groepen (ST-207)
- Story detail slide-over met bewerkformulier (ST-208)
- Story verwijderen met bevestigingsstap (ST-209)
- Filter op status en prioriteit in rechterpaneel (ST-210)
- Fix: infinite loop in useEffect door stabiele string dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 11:46:18 +02:00

17 KiB
Raw Blame History

CLAUDE.md — Scrum4Me

Dit is het centrale instructiedocument voor Claude Code. Lees dit volledig voordat je iets bouwt.


Wat is Scrum4Me?

Een desktop-first fullstack webapplicatie voor solo developers en kleine Scrum Teams die meerdere softwareprojecten parallel beheren. De app organiseert werk hiërarchisch (product → PBI → story → taak), biedt gesplitste planningsschermen met drag-and-drop, en integreert met Claude Code via een REST API zodat implementatieplannen, testresultaten en commits automatisch vastgelegd worden in stories.


Specificatiedocumenten

Lees het relevante document voordat je aan een feature begint. Nooit gokken over requirements.

Document Gebruik voor
scrum4me-functional-spec.md Acceptatiecriteria, randgevallen, user flows per feature
scrum4me-architecture.md Stack, datamodel, Prisma schema, Zustand stores, projectstructuur
scrum4me-backlog.md Welke task bouwen, in welke volgorde, "done when"-criteria
scrum4me-personas.md Lars (primaire gebruiker), Dina, Remi — gebruik bij UI-beslissingen
scrum4me-product-backlog.md Testdata voor de seed — PBI's en stories van Scrum4Me zelf
scrum4me-styling.md Lees dit voor elk component — MD3-kleuren, shadcn gebruik, component-patronen
theme.css Bronbestand — kopieer naar styles/theme.css, importeer in app/globals.css
MD3_Color_Scheme_Documentation.md Volledige MD3-kleurendocumentatie als referentie

Waar te beginnen

Volg de backlog strikt op volgorde. Start bij ST-001. Sla geen milestone over.

M0 (ST-001008) → M1 (ST-101110) → M2 (ST-201210)
→ M3 (ST-301312) → M4 (ST-401410) → M5 (ST-501506)
→ M6 (ST-601612)

Per task:

  1. Lees de task in scrum4me-backlog.md
  2. Zoek de bijbehorende feature-spec op in scrum4me-functional-spec.md
  3. Bouw — test — verifieer de "Done when"-criteria
  4. Commit met de task-ID in het commit-bericht: feat: ST-001 project scaffolding

Tech stack (samenvatting)

Next.js 15 (App Router) + React 19
TypeScript strict
Tailwind CSS + shadcn/ui         ← UI-primitieven (Button, Dialog, Sheet, Badge, etc.)
MD3 kleurensysteem via theme.css ← semantische tokens, nooit willekeurige Tailwind-kleuren
Zustand (client state)
dnd-kit (drag-and-drop)
Prisma v7 (ORM)
PostgreSQL via Neon (cloud) | SQLite (lokaal)
iron-session (auth cookies)
bcrypt (wachtwoord hashing)
Zod (validatie)
Sonner (toasts)

Stylingregel: Gebruik nooit bg-blue-500, bg-green-600 of andere willekeurige Tailwind-kleuren. Gebruik altijd semantische MD3-tokens: bg-primary, bg-status-done, bg-priority-critical, etc. Zie scrum4me-styling.md voor alle patronen en regels.


Exacte dependencies (package.json)

{
  "dependencies": {
    "next": "^15.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "typescript": "^5.0.0",
    "tailwindcss": "^3.4.0",
    "zustand": "^5.0.0",
    "@dnd-kit/core": "^6.3.0",
    "@dnd-kit/sortable": "^8.0.0",
    "@dnd-kit/utilities": "^3.2.0",
    "prisma": "^7.0.0",
    "@prisma/client": "^7.0.0",
    "@prisma/adapter-pg": "^7.0.0",
    "iron-session": "^8.0.0",
    "bcryptjs": "^2.4.3",
    "zod": "^3.22.0",
    "sonner": "^1.5.0",
    "pg": "^8.11.0"
  },
  "devDependencies": {
    "@types/bcryptjs": "^2.4.6",
    "@types/pg": "^8.11.0",
    "@types/node": "^20.0.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "eslint": "^8.0.0",
    "eslint-config-next": "^15.0.0"
  }
}

theme.css installeren

# Kopieer theme.css naar de project root of styles map
cp theme.css app/styles/theme.css

# Importeer bovenaan app/globals.css:
# @import './styles/theme.css';

Dark mode werkt via .dark class op <html>. Zie scrum4me-styling.md voor het ThemeToggle component.

shadcn/ui componenten om te installeren

Voer deze uit na npx shadcn@latest init:

npx shadcn@latest add button
npx shadcn@latest add input
npx shadcn@latest add textarea
npx shadcn@latest add dialog
npx shadcn@latest add dropdown-menu
npx shadcn@latest add badge
npx shadcn@latest add tooltip
npx shadcn@latest add separator
npx shadcn@latest add sheet          # voor story slide-over
npx shadcn@latest add select
npx shadcn@latest add alert-dialog   # voor bevestigingsdialogen
npx shadcn@latest add skeleton
npx shadcn@latest add toast

Projectstructuur

scrum4me/
├── app/
│   ├── (auth)/
│   │   ├── login/page.tsx
│   │   └── register/page.tsx
│   ├── (app)/
│   │   ├── layout.tsx              # Auth-check + navigatie
│   │   ├── dashboard/page.tsx
│   │   ├── products/
│   │   │   ├── new/page.tsx
│   │   │   └── [id]/
│   │   │       ├── page.tsx        # Product Backlog
│   │   │       └── sprint/
│   │   │           ├── page.tsx    # Sprint Backlog
│   │   │           └── planning/page.tsx
│   │   ├── todos/page.tsx
│   │   └── settings/
│   │       ├── page.tsx
│   │       └── tokens/page.tsx
│   └── api/
│       ├── products/[id]/next-story/route.ts
│       ├── sprints/[id]/tasks/route.ts
│       ├── stories/[id]/
│       │   ├── log/route.ts
│       │   └── tasks/reorder/route.ts
│       ├── tasks/[id]/route.ts
│       └── todos/route.ts
├── components/
│   ├── ui/                         # shadcn/ui (auto-gegenereerd)
│   ├── split-pane/
│   │   └── split-pane.tsx
│   ├── backlog/
│   │   ├── pbi-list.tsx
│   │   ├── pbi-item.tsx
│   │   ├── story-grid.tsx
│   │   └── story-block.tsx
│   ├── sprint/
│   │   ├── sprint-backlog.tsx
│   │   └── sprint-story-item.tsx
│   ├── planning/
│   │   ├── task-list.tsx
│   │   └── task-item.tsx
│   └── shared/
│       ├── panel-nav-bar.tsx
│       ├── confirm-dialog.tsx
│       └── story-log.tsx
├── stores/
│   ├── planner-store.ts
│   ├── selection-store.ts
│   └── sprint-store.ts
├── lib/
│   ├── prisma.ts
│   ├── session.ts
│   ├── auth.ts
│   ├── api-auth.ts
│   └── env.ts
├── actions/
│   ├── products.ts
│   ├── pbis.ts
│   ├── stories.ts
│   ├── sprints.ts
│   ├── tasks.ts
│   └── todos.ts
├── prisma/
│   ├── schema.prisma
│   ├── migrations/
│   └── seed.ts
├── middleware.ts
├── prisma.config.ts
└── .env.example

Kritieke implementatiepatronen

1. iron-session configuratie

// lib/session.ts
import { SessionOptions } from 'iron-session'

export interface SessionData {
  userId: string
  isDemo: boolean
}

export const sessionOptions: SessionOptions = {
  password: process.env.SESSION_SECRET!,
  cookieName: 'scrum4me-session',
  cookieOptions: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    sameSite: 'lax',
  },
}
// Gebruik in Server Action of Route Handler:
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
import { SessionData, sessionOptions } from '@/lib/session'

const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
if (!session.userId) redirect('/login')

2. Prisma Client singleton

// lib/prisma.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }

export const prisma = globalForPrisma.prisma ?? new PrismaClient({
  log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

3. prisma.config.ts (Prisma v7 vereiste)

// prisma.config.ts
import 'dotenv/config'
import { defineConfig } from 'prisma/config'

export default defineConfig({
  schema: 'prisma/schema.prisma',
  migrations: { path: 'prisma/migrations' },
})

4. API Bearer token authenticatie

// lib/api-auth.ts
import { createHash } from 'crypto'
import { prisma } from '@/lib/prisma'

export async function authenticateApiRequest(request: Request) {
  const authHeader = request.headers.get('Authorization')
  if (!authHeader?.startsWith('Bearer ')) {
    return { error: 'Unauthorized', status: 401 }
  }

  const token = authHeader.slice(7)
  const tokenHash = createHash('sha256').update(token).digest('hex')

  const apiToken = await prisma.apiToken.findUnique({
    where: { token_hash: tokenHash },
    include: { user: true },
  })

  if (!apiToken || apiToken.revoked_at) {
    return { error: 'Unauthorized', status: 401 }
  }

  return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo }
}

// Gebruik in Route Handler:
export async function GET(request: Request) {
  const auth = await authenticateApiRequest(request)
  if ('error' in auth) {
    return Response.json({ error: auth.error }, { status: auth.status })
  }
  // auth.userId beschikbaar
}

5. Float sort_order — drag-and-drop volgorde

// Bereken nieuwe sort_order bij tussenvoeging:
function getSortOrder(before: number | null, after: number | null): number {
  if (before === null && after === null) return 1.0
  if (before === null) return after! / 2
  if (after === null) return before + 1.0
  return (before + after) / 2
}

// Herindexeer als precisie opraakt (< 0.001 verschil):
async function reindexIfNeeded(items: { id: string; sort_order: number }[]) {
  const minGap = Math.min(...items.slice(1).map((item, i) =>
    item.sort_order - items[i].sort_order
  ))
  if (minGap < 0.001) {
    // Herindexeer: 1.0, 2.0, 3.0, ...
    await Promise.all(items.map((item, i) =>
      prisma.pbi.update({ where: { id: item.id }, data: { sort_order: i + 1.0 } })
    ))
  }
}

6. Zustand store patroon (optimistische update + rollback)

// Gebruik in dnd-kit onDragEnd:
const { pbiOrder, reorderPbis, rollbackPbis } = usePlannerStore()

async function handleDragEnd(event: DragEndEvent) {
  const { active, over } = event
  if (!over || active.id === over.id) return

  const prevOrder = [...pbiOrder[productId]]
  const newOrder = arrayMove(prevOrder, oldIndex, newIndex)

  // 1. Optimistisch updaten
  reorderPbis(productId, newOrder)

  // 2. Server Action aanroepen
  const result = await reorderPbisAction(productId, newOrder)

  // 3. Rollback bij fout
  if (!result.success) {
    rollbackPbis(productId, prevOrder)
    toast.error('Volgorde opslaan mislukt')
  }
}

7. Server Action patroon

// actions/pbis.ts
'use server'

import { revalidatePath } from 'next/cache'
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'

const createPbiSchema = z.object({
  productId: z.string().cuid(),
  title: z.string().min(1).max(200),
  priority: z.number().int().min(1).max(4),
})

export async function createPbi(formData: FormData) {
  const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
  if (!session.userId) return { error: 'Niet ingelogd' }
  if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }

  const parsed = createPbiSchema.safeParse({
    productId: formData.get('productId'),
    title: formData.get('title'),
    priority: Number(formData.get('priority')),
  })
  if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }

  // Valideer eigenaarschap
  const product = await prisma.product.findFirst({
    where: { id: parsed.data.productId, user_id: session.userId }
  })
  if (!product) return { error: 'Product niet gevonden' }

  // Bepaal sort_order (onderaan de prioriteitsgroep)
  const last = await prisma.pbi.findFirst({
    where: { product_id: parsed.data.productId, priority: parsed.data.priority },
    orderBy: { sort_order: 'desc' },
  })
  const sort_order = (last?.sort_order ?? 0) + 1.0

  const pbi = await prisma.pbi.create({
    data: { ...parsed.data, product_id: parsed.data.productId, sort_order },
  })

  revalidatePath(`/products/${parsed.data.productId}`)
  return { success: true, pbi }
}

8. Route Handler patroon (REST API)

// app/api/products/[id]/next-story/route.ts
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const auth = await authenticateApiRequest(request)
  if ('error' in auth) {
    return Response.json({ error: auth.error }, { status: auth.status })
  }

  const { id } = await params

  // Valideer eigenaarschap
  const sprint = await prisma.sprint.findFirst({
    where: { product_id: id, status: 'ACTIVE', product: { user_id: auth.userId } },
  })
  if (!sprint) {
    return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 })
  }

  const story = await prisma.story.findFirst({
    where: { sprint_id: sprint.id, status: 'IN_SPRINT' },
    orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
    include: { tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] } },
  })

  if (!story) {
    return Response.json({ error: 'Geen open stories in de Sprint' }, { status: 404 })
  }

  return Response.json(story)
}

Middleware patroon

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'

const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings']
const authRoutes = ['/login', '/register']

export async function middleware(request: NextRequest) {
  const response = NextResponse.next()
  const session = await getIronSession<SessionData>(request.cookies, sessionOptions)

  const isProtected = protectedRoutes.some(r => request.nextUrl.pathname.startsWith(r))
  const isAuthRoute = authRoutes.some(r => request.nextUrl.pathname.startsWith(r))

  if (isProtected && !session.userId) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
  if (isAuthRoute && session.userId) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  return response
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

Scrum-terminologie (gebruik consistent)

Correct Niet gebruiken
Product Backlog Item (PBI) Feature, Epic, Issue
Story User Story, Ticket
Sprint Goal Sprint Objective
Sprint Planning Sprint Meeting
Scrum Team Team
Definition of Done DoD criteria

Env vars

# .env.local
DATABASE_URL="postgresql://user:password@host/dbname?sslmode=require"
DIRECT_URL="postgresql://user:password@host/dbname?sslmode=require"
SESSION_SECRET="genereer-met-openssl-rand-base64-32"

# Lokaal (SQLite):
# DATABASE_URL="file:./dev.db"
# DIRECT_URL niet nodig bij SQLite

Lokale setup (quickstart)

git clone <repo>
cd scrum4me
npm install
cp .env.example .env.local
# Vul SESSION_SECRET in .env.local

# SQLite lokaal:
npx prisma db push
npx prisma db seed
npm run dev

Demo-gebruiker credentials

Na seeding:

  • Gebruikersnaam: demo
  • Wachtwoord: demo1234

De demo-gebruiker heeft read-only rechten. Alle schrijfacties geven een 403 of zijn uitgeschakeld in de UI.


REST API — alle endpoints

Methode Endpoint Doel
GET /api/products Actieve producten ophalen
GET /api/products/:id/next-story Hoogst geprioriteerde open story
GET /api/sprints/:id/tasks?limit=10 Eerste N taken van de Sprint
PATCH /api/stories/:id/tasks/reorder Taakvolgorde aanpassen
POST /api/stories/:id/log Plan / testresultaat / commit vastleggen
PATCH /api/tasks/:id Taakstatus bijwerken
POST /api/todos Todo aanmaken

Alle endpoints vereisen: Authorization: Bearer <token>

POST /api/stories/:id/log — body schema

// Implementatieplan:
{ "type": "IMPLEMENTATION_PLAN", "content": "string" }

// Testresultaat:
{ "type": "TEST_RESULT", "content": "string", "status": "PASSED" | "FAILED" }

// Commit:
{ "type": "COMMIT", "content": "string", "commit_hash": "string", "commit_message": "string" }

Conventies

  • Commit-berichten: feat: ST-XXX beschrijving / fix: ST-XXX beschrijving
  • Branch-namen: feat/ST-001-scaffolding
  • Server Actions: altijd in actions/[domein].ts, nooit inline in page.tsx
  • Validatie: altijd Zod, nooit handmatige checks
  • Eigenaarschap: elke Server Action en Route Handler controleert dat de resource bij de geverifieerde gebruiker hoort
  • Demo-check: elke Server Action controleert session.isDemo vóór schrijven
  • Foutberichten: altijd in het Nederlands voor eindgebruikers
  • Comments in code: Engels

Definitie of Done (project)

De MVP is klaar wanneer:

  • Alle 62 tasks (ST-001 t/m ST-612) zijn afgerond
  • Volledige Lars-flow doorlopen zonder fouten (ST-612)
  • Alle 7 API-endpoints werken via curl
  • Demo-gebruiker heeft geen schrijfrechten
  • App lokaal opzetbaar via README zonder extra hulp
  • CI/CD actief — falende build blokkeert merge
  • Beveiligingsreview API geslaagd (cross-user toegang onmogelijk)