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:
parent
289bcf9bf0
commit
7e45bbdbc0
81 changed files with 12364 additions and 3154 deletions
|
|
@ -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.*
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue