- 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>
17 KiB
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-001–008) → M1 (ST-101–110) → M2 (ST-201–210)
→ M3 (ST-301–312) → M4 (ST-401–410) → M5 (ST-501–506)
→ M6 (ST-601–612)
Per task:
- Lees de task in
scrum4me-backlog.md - Zoek de bijbehorende feature-spec op in
scrum4me-functional-spec.md - Bouw — test — verifieer de "Done when"-criteria
- 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-600of andere willekeurige Tailwind-kleuren. Gebruik altijd semantische MD3-tokens:bg-primary,bg-status-done,bg-priority-critical, etc. Ziescrum4me-styling.mdvoor 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.isDemovóó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)