docs(split): merge solo-paneel-spec into specs/functional.md
This commit is contained in:
parent
481e0c65cd
commit
5b0b8d5b7e
6 changed files with 779 additions and 783 deletions
|
|
@ -656,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