From b049822f8f98fa8a56476f2238935c2d98a957c5 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 29 Apr 2026 17:07:35 +0200 Subject: [PATCH 001/282] feat(ST-1109.2): add PbiStatus enum and status field to Pbi model - New PbiStatus enum (READY/BLOCKED/DONE) for PBI lifecycle tracking - Pbi.status PbiStatus @default(READY) - Index on (product_id, status) for filter queries - Migration: 20260429150643_add_pbi_status - ERD regenerated via prisma generate Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/erd.svg | 2 +- .../20260429150643_add_pbi_status/migration.sql | 8 ++++++++ prisma/schema.prisma | 12 ++++++++++-- 3 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260429150643_add_pbi_status/migration.sql diff --git a/docs/erd.svg b/docs/erd.svg index 17e265e..ec45d14 100644 --- a/docs/erd.svg +++ b/docs/erd.svg @@ -1 +1 @@ -

active_product

user

enum:role

user

user

product

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

sprint

enum:status

product

user

user

product

user

story

task

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

DateTime

created_at

DateTime

updated_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file +

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

sprint

enum:status

product

user

user

product

user

story

task

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

DateTime

created_at

DateTime

updated_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file diff --git a/prisma/migrations/20260429150643_add_pbi_status/migration.sql b/prisma/migrations/20260429150643_add_pbi_status/migration.sql new file mode 100644 index 0000000..7b78867 --- /dev/null +++ b/prisma/migrations/20260429150643_add_pbi_status/migration.sql @@ -0,0 +1,8 @@ +-- CreateEnum +CREATE TYPE "PbiStatus" AS ENUM ('READY', 'BLOCKED', 'DONE'); + +-- AlterTable +ALTER TABLE "pbis" ADD COLUMN "status" "PbiStatus" NOT NULL DEFAULT 'READY'; + +-- CreateIndex +CREATE INDEX "pbis_product_id_status_idx" ON "pbis"("product_id", "status"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 466d538..07c5816 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,12 @@ enum StoryStatus { DONE } +enum PbiStatus { + READY + BLOCKED + DONE +} + enum TaskStatus { TO_DO IN_PROGRESS @@ -131,12 +137,14 @@ model Pbi { description String? priority Int sort_order Float - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + status PbiStatus @default(READY) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt stories Story[] @@unique([product_id, code]) @@index([product_id, priority, sort_order]) + @@index([product_id, status]) @@map("pbis") } From 445e1522c8673277c139c6347bf662d267517fed Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 29 Apr 2026 17:08:34 +0200 Subject: [PATCH 002/282] feat(ST-1109.3): add PBI status API mappers - pbiStatusToApi / pbiStatusFromApi following same pattern as task/story - PbiStatusApi type derived from PBI_DB_TO_API - PBI_STATUS_API_VALUES export for downstream Zod schemas - Lowercase API surface (ready/blocked/done), DB stays UPPER_SNAKE Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/task-status.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/task-status.ts b/lib/task-status.ts index 523b61b..3968042 100644 --- a/lib/task-status.ts +++ b/lib/task-status.ts @@ -1,7 +1,7 @@ // Bidirectionele case-mappers voor de REST API-boundary. // DB houdt UPPER_SNAKE; API exposeert lowercase. -import type { TaskStatus, StoryStatus } from '@prisma/client' +import type { TaskStatus, StoryStatus, PbiStatus } from '@prisma/client' const TASK_DB_TO_API = { TO_DO: 'todo', @@ -29,8 +29,21 @@ const STORY_API_TO_DB: Record = { done: 'DONE', } +const PBI_DB_TO_API = { + READY: 'ready', + BLOCKED: 'blocked', + DONE: 'done', +} as const satisfies Record + +const PBI_API_TO_DB: Record = { + ready: 'READY', + blocked: 'BLOCKED', + done: 'DONE', +} + export type TaskStatusApi = typeof TASK_DB_TO_API[TaskStatus] export type StoryStatusApi = typeof STORY_DB_TO_API[StoryStatus] +export type PbiStatusApi = typeof PBI_DB_TO_API[PbiStatus] export function taskStatusToApi(s: TaskStatus): TaskStatusApi { return TASK_DB_TO_API[s] @@ -48,5 +61,14 @@ export function storyStatusFromApi(s: string): StoryStatus | null { return STORY_API_TO_DB[s.toLowerCase()] ?? null } +export function pbiStatusToApi(s: PbiStatus): PbiStatusApi { + return PBI_DB_TO_API[s] +} + +export function pbiStatusFromApi(s: string): PbiStatus | null { + return PBI_API_TO_DB[s.toLowerCase()] ?? null +} + export const TASK_STATUS_API_VALUES = Object.values(TASK_DB_TO_API) export const STORY_STATUS_API_VALUES = Object.values(STORY_DB_TO_API) +export const PBI_STATUS_API_VALUES = Object.values(PBI_DB_TO_API) From 878fa161efb95428e99bf577027d09c9ebca2e22 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 29 Apr 2026 17:09:48 +0200 Subject: [PATCH 003/282] feat(ST-1109.4): support status in PBI create/update actions - Optional status field in Zod schemas (lowercase API: ready/blocked/done) - pbiStatusFromApi() maps to DB enum before persistence - Status omitted on create => Prisma @default(READY) takes effect - Update preserves existing status when not provided - Demo-check unchanged Co-Authored-By: Claude Opus 4.7 (1M context) --- actions/pbis.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/actions/pbis.ts b/actions/pbis.ts index b0bce8c..f2221e8 100644 --- a/actions/pbis.ts +++ b/actions/pbis.ts @@ -9,6 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct } from '@/lib/product-access' import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextPbiCode } from '@/lib/code-server' +import { pbiStatusFromApi } from '@/lib/task-status' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -16,12 +17,15 @@ async function getSession() { const codeField = z.string().max(MAX_CODE_LENGTH).optional() +const statusField = z.enum(['ready', 'blocked', 'done']).optional() + const createPbiSchema = z.object({ productId: z.string(), code: codeField, title: z.string().min(1, 'Titel is verplicht').max(200), description: z.string().max(2000).optional(), priority: z.coerce.number().int().min(1).max(4), + status: statusField, }) const updatePbiSchema = z.object({ @@ -30,6 +34,7 @@ const updatePbiSchema = z.object({ title: z.string().min(1, 'Titel is verplicht').max(200), description: z.string().max(2000).optional(), priority: z.coerce.number().int().min(1).max(4), + status: statusField, }) export async function createPbiAction(_prevState: unknown, formData: FormData) { @@ -43,6 +48,7 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { title: formData.get('title'), description: formData.get('description') || undefined, priority: formData.get('priority'), + status: (formData.get('status') as string) || undefined, }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } @@ -64,6 +70,8 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { }) const sort_order = (last?.sort_order ?? 0) + 1.0 + const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined + const insert = (code: string | null) => prisma.pbi.create({ data: { @@ -73,6 +81,7 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { description: parsed.data.description ?? null, priority: parsed.data.priority, sort_order, + ...(status ? { status } : {}), }, }) @@ -103,6 +112,7 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) { title: formData.get('title'), description: formData.get('description') || undefined, priority: formData.get('priority'), + status: (formData.get('status') as string) || undefined, }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } @@ -125,6 +135,8 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) { if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } } + const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined + await prisma.pbi.update({ where: { id: parsed.data.id }, data: { @@ -132,6 +144,7 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) { title: parsed.data.title, description: parsed.data.description ?? null, priority: parsed.data.priority, + ...(status ? { status } : {}), }, }) From a10ccc936e1df50b202bd1e88e6ef833ca6e26ff Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 29 Apr 2026 17:10:56 +0200 Subject: [PATCH 004/282] feat(ST-1109.5): auto-mark PBI as DONE when all its stories are DONE on sprint close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends completeSprintAction's $transaction with PBI status cascade: - Pre-transaction: identify PBIs touched by this close (via stories.pbi_id), fetch each with all its stories - Skip PBIs already DONE; skip PBIs with 0 stories - Mark PBI DONE only when every story (post-decision) is DONE — stories outside the sprint are evaluated against their current DB status - Promote-only: never demotes a PBI that becomes "incomplete" again Co-Authored-By: Claude Opus 4.7 (1M context) --- actions/sprints.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/actions/sprints.ts b/actions/sprints.ts index ed8857d..7eb7229 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -171,10 +171,28 @@ export async function completeSprintAction( const stories = await prisma.story.findMany({ where: { id: { in: storyIds }, sprint_id: sprintId, product_id: sprint.product_id }, - select: { id: true }, + select: { id: true, pbi_id: true }, }) if (stories.length !== storyIds.length) return { error: 'Ongeldige Sprint-afronding' } + const affectedPbiIds = [...new Set(stories.map((s) => s.pbi_id))] + const candidatePbis = await prisma.pbi.findMany({ + where: { id: { in: affectedPbiIds }, status: { not: 'DONE' } }, + select: { id: true, stories: { select: { id: true, status: true } } }, + }) + + const decisionByStoryId = new Map(entries) + const pbiIdsToMarkDone = candidatePbis + .filter( + (pbi) => + pbi.stories.length > 0 && + pbi.stories.every((s) => { + const next = decisionByStoryId.get(s.id) ?? s.status + return next === 'DONE' + }) + ) + .map((p) => p.id) + await prisma.$transaction([ ...entries.map(([storyId, status]) => prisma.story.update({ @@ -185,6 +203,9 @@ export async function completeSprintAction( }, }) ), + ...pbiIdsToMarkDone.map((id) => + prisma.pbi.update({ where: { id }, data: { status: 'DONE' } }) + ), prisma.sprint.update({ where: { id: sprintId }, data: { status: 'COMPLETED', completed_at: new Date() }, From 4cb36f727438cab0d34464c8acdfa49ab58cfbcc Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 29 Apr 2026 17:11:48 +0200 Subject: [PATCH 005/282] feat(ST-1109.6): add Popover primitive (base-ui wrapper) - Mirrors the Tooltip pattern: render-prop composition, data-slot attrs - Exports Popover (Root), PopoverTrigger, PopoverContent (Portal+Positioner+Popup) - MD3 popover/popover-foreground tokens, animated open/close states - Will be used to consolidate the backlog filter UI in ST-1109.8 Co-Authored-By: Claude Opus 4.7 (1M context) --- components/ui/popover.tsx | 52 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 components/ui/popover.tsx diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx new file mode 100644 index 0000000..35f4b18 --- /dev/null +++ b/components/ui/popover.tsx @@ -0,0 +1,52 @@ +"use client" + +import { Popover as PopoverPrimitive } from "@base-ui/react/popover" + +import { cn } from "@/lib/utils" + +function Popover({ ...props }: PopoverPrimitive.Root.Props) { + return +} + +function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) { + return +} + +function PopoverContent({ + className, + side = "bottom", + sideOffset = 8, + align = "center", + alignOffset = 0, + children, + ...props +}: PopoverPrimitive.Popup.Props & + Pick< + PopoverPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + {children} + + + + ) +} + +export { Popover, PopoverTrigger, PopoverContent } From 2381832c6736b1428da6ce2d14b76afb56d1caad Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 29 Apr 2026 17:13:36 +0200 Subject: [PATCH 006/282] feat(ST-1109.7): add status select to PBI dialog - New components/shared/pbi-status-select.tsx mirrors PrioritySelect: PBI_STATUS_LABELS (NL), PBI_STATUS_COLORS, PbiStatusSelect component - Reuses existing --status-todo/blocked/done MD3 tokens - PbiDialog: status state with sync-on-open; default 'ready' for create, pbi.status for edit; hidden input submits lowercase API value - Priority + Status sit side-by-side in 2-col grid - PbiDialogPbi.status is optional; pbi-list.tsx will populate in ST-1109.8 Co-Authored-By: Claude Opus 4.7 (1M context) --- components/backlog/pbi-dialog.tsx | 23 +++++++++++--- components/shared/pbi-status-select.tsx | 41 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 components/shared/pbi-status-select.tsx diff --git a/components/backlog/pbi-dialog.tsx b/components/backlog/pbi-dialog.tsx index b93de29..6af393a 100644 --- a/components/backlog/pbi-dialog.tsx +++ b/components/backlog/pbi-dialog.tsx @@ -16,7 +16,9 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { PrioritySelect } from '@/components/shared/priority-select' +import { PbiStatusSelect } from '@/components/shared/pbi-status-select' import { createPbiAction, updatePbiAction } from '@/actions/pbis' +import type { PbiStatusApi } from '@/lib/task-status' export interface PbiDialogPbi { id: string @@ -24,6 +26,7 @@ export interface PbiDialogPbi { priority: number description?: string | null code?: string | null + status?: PbiStatusApi } type CreateState = { mode: 'create'; productId: string; defaultPriority?: number } @@ -51,11 +54,16 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) { const initialPriority = isEdit ? pbi!.priority : (state?.defaultPriority ?? 2) const [priority, setPriority] = useState(initialPriority) - // Sync priority when dialog opens for a different PBI or switches create/edit mode + const initialStatus: PbiStatusApi = isEdit ? (pbi!.status ?? 'ready') : 'ready' + const [status, setStatus] = useState(initialStatus) + + // Sync priority + status when dialog opens for a different PBI or switches create/edit mode useEffect(() => { if (state) { // eslint-disable-next-line react-hooks/set-state-in-effect setPriority(isEdit ? (state as EditState).pbi.priority : ((state as CreateState).defaultPriority ?? 2)) + + setStatus(isEdit ? ((state as EditState).pbi.status ?? 'ready') : 'ready') } }, [state, isEdit]) @@ -105,6 +113,7 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) { {isEdit && } {!isEdit && } +
@@ -135,9 +144,15 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
-
- - +
+
+ + +
+
+ + +
diff --git a/components/shared/pbi-status-select.tsx b/components/shared/pbi-status-select.tsx new file mode 100644 index 0000000..ae93522 --- /dev/null +++ b/components/shared/pbi-status-select.tsx @@ -0,0 +1,41 @@ +'use client' + +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' +import { cn } from '@/lib/utils' +import type { PbiStatusApi } from '@/lib/task-status' + +export const PBI_STATUS_LABELS: Record = { + ready: 'Klaar voor sprint', + blocked: 'Geblokkeerd', + done: 'Afgerond', +} + +export const PBI_STATUS_COLORS: Record = { + ready: 'bg-status-todo/15 text-status-todo border-status-todo/30', + blocked: 'bg-status-blocked/15 text-status-blocked border-status-blocked/30', + done: 'bg-status-done/15 text-status-done border-status-done/30', +} + +interface PbiStatusSelectProps { + value: PbiStatusApi + onChange: (value: PbiStatusApi) => void + className?: string +} + +export function PbiStatusSelect({ value, onChange, className }: PbiStatusSelectProps) { + return ( + + ) +} From 72d72fd648dba21ee4a44f2dd09697d26d7916bf Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 29 Apr 2026 17:16:44 +0200 Subject: [PATCH 007/282] feat(ST-1109.8): show PBI status badge and consolidate filters into popover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pbi.status (lowercase API) flows from page.tsx via pbiStatusToApi - Status badge rendered in BacklogCard's badge slot using PBI_STATUS_COLORS - Two old Select dropdowns replaced by single Popover with three pill-button sections (Sorteren, Prioriteit, Status) and a "Wis filters" footer - Filter trigger shows active count "(n)" badge in label - Active priority/status filters still surface as dismissable chips next to the trigger for at-a-glance feedback - onEdit passes the full Pbi (incl. status) so the dialog opens with the correct current status — closes the data flow loop opened in ST-1109.7 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/products/[id]/page.tsx | 3 +- components/backlog/pbi-list.tsx | 170 +++++++++++++++++++++++++------ 2 files changed, 139 insertions(+), 34 deletions(-) diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index b6e110b..426cd03 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' +import { pbiStatusToApi } from '@/lib/task-status' import { SplitPane } from '@/components/split-pane/split-pane' import { PbiList } from '@/components/backlog/pbi-list' import { StoryPanel } from '@/components/backlog/story-panel' @@ -94,7 +95,7 @@ export default async function ProductBacklogPage({ params }: Props) { left={ ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at }))} + pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) }))} isDemo={isDemo} /> } diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index 588706a..538dcaa 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -23,7 +23,7 @@ import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { useSelectionStore } from '@/stores/selection-store' import { usePlannerStore } from '@/stores/planner-store' @@ -33,6 +33,8 @@ import { cn } from '@/lib/utils' import { PbiDialog, type PbiDialogState } from './pbi-dialog' import { BacklogCard } from './backlog-card' import { PRIORITY_COLORS } from '@/components/shared/priority-select' +import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select' +import type { PbiStatusApi } from '@/lib/task-status' const PRIORITY_LABELS: Record = { 1: 'Kritiek', @@ -44,6 +46,62 @@ const PRIORITY_LABELS: Record = { type SortMode = 'priority' | 'code' | 'date' +const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [ + { value: 'priority', label: 'Prioriteit' }, + { value: 'code', label: 'Code' }, + { value: 'date', label: 'Datum' }, +] + +const PRIORITY_OPTIONS: Array<{ value: number | 'all'; label: string }> = [ + { value: 'all', label: 'Alle' }, + { value: 1, label: 'Kritiek' }, + { value: 2, label: 'Hoog' }, + { value: 3, label: 'Gemiddeld' }, + { value: 4, label: 'Laag' }, +] + +const STATUS_OPTIONS: Array<{ value: PbiStatusApi | 'all'; label: string }> = [ + { value: 'all', label: 'Alle' }, + { value: 'ready', label: 'Klaar' }, + { value: 'blocked', label: 'Geblokkeerd' }, + { value: 'done', label: 'Afgerond' }, +] + +function FilterPills({ + label, + options, + value, + onChange, +}: { + label: string + options: Array<{ value: T; label: string }> + value: T + onChange: (v: T) => void +}) { + return ( +
+

{label}

+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + interface Pbi { id: string code: string | null @@ -51,6 +109,7 @@ interface Pbi { priority: number description?: string | null created_at: Date + status: PbiStatusApi } interface PbiListProps { @@ -100,6 +159,11 @@ function SortablePbiRow({ aria-selected={isSelected} onClick={onSelect} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }} + badge={ + + {PBI_STATUS_LABELS[pbi.status]} + + } actions={!isDemo ? (
)} - - + {filterStatus !== 'all' && ( + + )} + + + {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} + + } + /> + + + + +
+ +
+
+
{!isDemo && ( + ))} +
+
+ ) +} + interface Pbi { id: string code: string | null @@ -51,6 +109,7 @@ interface Pbi { priority: number description?: string | null created_at: Date + status: PbiStatusApi } interface PbiListProps { @@ -100,6 +159,11 @@ function SortablePbiRow({ aria-selected={isSelected} onClick={onSelect} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }} + badge={ + + {PBI_STATUS_LABELS[pbi.status]} + + } actions={!isDemo ? (
)} - - + {filterStatus !== 'all' && ( + + )} + + + {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} + + } + /> + + + + +
+ +
+
+
{!isDemo && (
{!session.isDemo && ( - + + Nieuw product )} @@ -149,8 +146,8 @@ export default async function SettingsPage() { label="Maak actief" /> )} - {pb.kind === 'member' && !session.isDemo && ( - + {pb.kind === 'member' && ( + )}
diff --git a/app/api/auth/pair/claim/route.ts b/app/api/auth/pair/claim/route.ts index 69b4b89..555cd39 100644 --- a/app/api/auth/pair/claim/route.ts +++ b/app/api/auth/pair/claim/route.ts @@ -80,6 +80,11 @@ export async function POST(request: Request) { return Response.json({ error: 'Pairing zonder user' }, { status: 500 }) } + if (pairing.user?.is_demo) { + await clearPairCookie() + return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) + } + const session = await getIronSession(await cookies(), sessionOptions) session.userId = pairing.user_id session.isDemo = pairing.user?.is_demo ?? false diff --git a/app/api/auth/pair/start/route.ts b/app/api/auth/pair/start/route.ts index 2062887..30ef836 100644 --- a/app/api/auth/pair/start/route.ts +++ b/app/api/auth/pair/start/route.ts @@ -9,6 +9,8 @@ // // Rate-limit: 10 pogingen per IP per minuut (lib/rate-limit.ts → 'pair-start'). +import { getIronSession } from 'iron-session' +import { cookies } from 'next/headers' import { prisma } from '@/lib/prisma' import { generateMobileSecret, @@ -17,6 +19,7 @@ import { } from '@/lib/auth/pairing' import { setPairCookie } from '@/lib/auth/pair-cookie' import { checkRateLimit } from '@/lib/rate-limit' +import { SessionData, sessionOptions } from '@/lib/session' export const runtime = 'nodejs' @@ -34,6 +37,11 @@ function getClientIp(request: Request): string { } export async function POST(request: Request) { + const session = await getIronSession(await cookies(), sessionOptions) + if (session.isDemo) { + return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) + } + const ip = getClientIp(request) if (!checkRateLimit(`pair-start:${ip}`)) { return Response.json( diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index 9586fd8..c4b0171 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -32,6 +32,7 @@ import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories' import { cn } from '@/lib/utils' import { PbiDialog, type PbiDialogState } from './pbi-dialog' import { BacklogCard } from './backlog-card' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { PRIORITY_COLORS } from '@/components/shared/priority-select' import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select' import type { PbiStatusApi } from '@/lib/task-status' @@ -164,24 +165,30 @@ function SortablePbiRow({ {PBI_STATUS_LABELS[pbi.status]} } - actions={!isDemo ? ( + actions={
- - + + + + + +
- ) : undefined} + } /> ) } @@ -383,15 +390,16 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) { - {!isDemo && ( + - )} + } /> @@ -400,11 +408,11 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) { {pbis.length === 0 ? (

Nog geen PBI's aangemaakt.

- {!isDemo && ( - - )} +
) : ( = { DONE: 'Klaar', } -function SubmitButton({ label }: { label: string }) { +function SubmitButton({ label, disabled }: { label: string; disabled?: boolean }) { const { pending } = useFormStatus() return ( - ) @@ -262,9 +263,9 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps )} - {isEdit && !isDemo && ( + {isEdit && (
- {confirmDelete ? ( + {!isDemo && confirmDelete ? (
Weet je het zeker? Taken worden ook verwijderd. @@ -277,24 +278,29 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
) : ( - + + + )}
)}
}> - {isDemo ? 'Sluiten' : 'Annuleren'} + Annuleren - {!isDemo && } + + +
diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 5f4eb6d..bb65854 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -30,6 +30,7 @@ import { usePlannerStore } from '@/stores/planner-store' import { reorderStoriesAction } from '@/actions/stories' import { StoryDialog, type StoryDialogState } from './story-dialog' import { BacklogCard } from './backlog-card' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { cn } from '@/lib/utils' type SortMode = 'priority' | 'code' | 'date' @@ -223,14 +224,17 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps) Klaar - {selectedPbiId && !isDemo && ( - + {selectedPbiId && ( + + + )} } @@ -244,10 +248,12 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps) ) : rawStories.length === 0 ? (

Nog geen stories voor dit PBI.

- {!isDemo && selectedPbiId && ( - + {selectedPbiId && ( + + + )}
) : ( diff --git a/components/dashboard/product-list.tsx b/components/dashboard/product-list.tsx index fa1280b..d01f8ce 100644 --- a/components/dashboard/product-list.tsx +++ b/components/dashboard/product-list.tsx @@ -7,6 +7,7 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { restoreProductAction } from '@/actions/products' import { setActiveProductAction } from '@/actions/active-product' @@ -38,7 +39,6 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd } function handleActivate(id: string) { - if (isDemo) { toast.error('Niet beschikbaar in demo-modus'); return } startTransition(async () => { const result = await setActiveProductAction(id) if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt') @@ -54,11 +54,11 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd ? 'Geen gearchiveerde producten.' : 'Je hebt nog geen producten aangemaakt.'}

- {!isDemo && !showArchived && ( - - )} + ) } @@ -103,21 +103,27 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd product.id === activeProductId ? Actief : ( - + + + ) )} - {showArchived && !isDemo && ( - + {showArchived && ( + + + )} diff --git a/components/settings/leave-product-button.tsx b/components/settings/leave-product-button.tsx index 4dba5ad..976e480 100644 --- a/components/settings/leave-product-button.tsx +++ b/components/settings/leave-product-button.tsx @@ -2,13 +2,15 @@ import { useState, useTransition } from 'react' import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { leaveProductAction } from '@/actions/products' interface LeaveProductButtonProps { productId: string + isDemo?: boolean } -export function LeaveProductButton({ productId }: LeaveProductButtonProps) { +export function LeaveProductButton({ productId, isDemo = false }: LeaveProductButtonProps) { const [confirming, setConfirming] = useState(false) const [isPending, startTransition] = useTransition() @@ -32,13 +34,16 @@ export function LeaveProductButton({ productId }: LeaveProductButtonProps) { } return ( - + + + ) } diff --git a/components/settings/token-manager.tsx b/components/settings/token-manager.tsx index 6f48b51..ba1f705 100644 --- a/components/settings/token-manager.tsx +++ b/components/settings/token-manager.tsx @@ -4,6 +4,7 @@ import { useState, useActionState, useTransition } from 'react' import { useFormStatus } from 'react-dom' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { createApiTokenAction, revokeApiTokenAction } from '@/actions/api-tokens' interface Token { @@ -18,12 +19,14 @@ interface TokenManagerProps { isDemo: boolean } -function CreateSubmitButton() { +function CreateSubmitButton({ isDemo }: { isDemo: boolean }) { const { pending } = useFormStatus() return ( - + + + ) } @@ -80,21 +83,19 @@ export function TokenManager({ tokens, isDemo }: TokenManagerProps) { )} {/* Create form */} - {!isDemo && ( -
-

Nieuw token aanmaken

-
- - - - {typeof state?.error === 'string' && ( -

{state.error}

- )} -

- Maximaal 10 actieve tokens. Je hebt er nu {activeTokens.length}. -

-
- )} +
+

Nieuw token aanmaken

+
+ + + + {typeof state?.error === 'string' && ( +

{state.error}

+ )} +

+ Maximaal 10 actieve tokens. Je hebt er nu {activeTokens.length}. +

+
{/* Active tokens */}
@@ -111,16 +112,17 @@ export function TokenManager({ tokens, isDemo }: TokenManagerProps) { Aangemaakt {new Date(token.created_at).toLocaleDateString('nl-NL')}

- {!isDemo && ( + - )} + ))} diff --git a/components/shared/activate-product-button.tsx b/components/shared/activate-product-button.tsx index 90cf54b..90c19c4 100644 --- a/components/shared/activate-product-button.tsx +++ b/components/shared/activate-product-button.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation' import { useTransition } from 'react' import { toast } from 'sonner' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { setActiveProductAction } from '@/actions/active-product' interface Props { @@ -18,7 +19,6 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = ' const [isPending, startTransition] = useTransition() function handleActivate() { - if (isDemo) { toast.error('Niet beschikbaar in demo-modus'); return } startTransition(async () => { const result = await setActiveProductAction(productId) if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt') @@ -28,12 +28,14 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = ' } return ( - + + + ) } diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index 5d4ceb7..3555912 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -189,15 +189,16 @@ function SortableSprintRow({ - {!isDemo && ( + - )} + @@ -352,14 +353,15 @@ function DraggablePbiStoryRow({ - {!isDemo && ( + - )} + ) diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx index 3261322..99650c3 100644 --- a/components/sprint/task-list.tsx +++ b/components/sprint/task-list.tsx @@ -24,6 +24,7 @@ import { createTaskAction, updateTaskStatusAction, updateTaskAction, deleteTaskAction, reorderTasksAction, } from '@/actions/tasks' +import { DemoTooltip } from '@/components/shared/demo-tooltip' import { cn } from '@/lib/utils' const STATUS_CYCLE: Record = { @@ -99,7 +100,7 @@ function SortableTaskRow({ PRIORITY_BORDER[task.priority] )}> {!isDemo && ( - + )}
@@ -114,12 +115,14 @@ function SortableTaskRow({ {STATUS_LABELS[task.status]} - {!isDemo && ( -
- - -
- )} +
+ + + + + + +
@@ -220,9 +223,9 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, actions={ <> {doneCount}/{orderedTasks.length} klaar - {!isDemo && ( - - )} + + + } /> @@ -235,7 +238,9 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, {orderedTasks.length === 0 && !creating ? (

Geen taken voor deze story.

- {!isDemo && } + + +
) : ( ` of `disabled+toast`) + +## Gekozen aanpak (ST-1110.2) + +Drie-laagse bescherming: +1. **Middleware-guard** in `proxy.ts` (defense in depth voor toekomstige routes) +2. **Per-route guards** in Server Actions en Route Handlers +3. **UI-laag**: uniform `disabled + DemoTooltip` + +## M11-antwoorden + +### ST-1110.4 — QR-pairing voor demo-gebruiker +**Vraag:** Mag een demo-gebruiker een QR-pairing starten en claimen? +**Opties:** +- Blokken — voeg isDemo-check toe in pair/start en pair/claim, demo krijgt 403 +- Openhouden — geen wijziging; demo kan pair-flow starten maar approve faalt toch + +**Antwoord (2026-04-29):** **Blokken** + +**Implementatie:** +- `pair/start`: `getIronSession(await cookies(), sessionOptions)` → 403 als `session.isDemo` +- `pair/claim`: check `pairing.user?.is_demo` na DB-read → 403 + `clearPairCookie()` +- proxy.ts DEMO_WRITE_ALLOWLIST bevat pair-paden NIET + +### ST-1110.5 — Write-knoppen voor demo-gebruiker +**Vraag:** Hoe write-knoppen tonen aan demo-gebruikers? +**Opties:** +- Verbergen — `{!isDemo && + +``` + +**Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && }`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren. + ## Environment variables | Variabele | Doel | Waar te vinden | diff --git a/proxy.ts b/proxy.ts index 0a4e228..afbfd55 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,17 +1,43 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import { sessionOptions } from '@/lib/session' +import { unsealData } from 'iron-session' +import { sessionOptions, type SessionData } from '@/lib/session' const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo'] const authRoutes = ['/login', '/register'] -export function proxy(request: NextRequest) { - const path = request.nextUrl.pathname - const isProtected = protectedRoutes.some(r => path.startsWith(r)) - const isAuthRoute = authRoutes.some(r => path.startsWith(r)) +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) - // Check cookie existence only — full session validation happens in layout.tsx +// Paden die demo MAY aanroepen ook al zijn het non-GET — worden ingevuld na ST-1110.4 +const DEMO_WRITE_ALLOWLIST = [ + '/api/cron/', // machine-auth, irrelevant for demo +] + +export async function proxy(request: NextRequest) { + const { pathname, method } = { pathname: request.nextUrl.pathname, method: request.method } + + // Demo-guard: block non-GET API writes for demo users (defense in depth) + if ( + pathname.startsWith('/api/') && + !SAFE_METHODS.has(method) && + !DEMO_WRITE_ALLOWLIST.some(p => pathname.startsWith(p)) + ) { + const raw = request.cookies.get(sessionOptions.cookieName)?.value + if (raw) { + const session = await unsealData(raw, { password: sessionOptions.password as string }) + if (session.isDemo) { + return NextResponse.json( + { error: 'Niet beschikbaar in demo-modus' }, + { status: 403 } + ) + } + } + } + + // Route protection: check cookie existence only — full validation in layout.tsx const hasSession = !!request.cookies.get(sessionOptions.cookieName)?.value + const isProtected = protectedRoutes.some(r => pathname.startsWith(r)) + const isAuthRoute = authRoutes.some(r => pathname.startsWith(r)) if (isProtected && !hasSession) { return NextResponse.redirect(new URL('/login', request.url)) @@ -25,5 +51,5 @@ export function proxy(request: NextRequest) { } export const config = { - matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], + matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], } From 73087e9705abbe4ad53278ea95cb377cccd1e1f3 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Wed, 29 Apr 2026 19:51:48 +0200 Subject: [PATCH 013/282] =?UTF-8?q?M13:=20Claude=20job=20queue=20=E2=80=94?= =?UTF-8?q?=20'Voer=20uit'-knop=20+=20worker=20presence=20(ST-1111)=20(#18?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ST-1111.1): add ClaudeJob model and state-machine enum Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.2): add ClaudeJob status API mappers Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.3): add enqueue/cancel ClaudeJob server actions with idempotency + NOTIFY Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.4): forward ClaudeJob events on solo SSE stream + initial state Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.6): add 'Voer uit' + cancel buttons to task detail dialog Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.7): add job status pill with spinner on solo task cards Co-Authored-By: Claude Sonnet 4.6 * test(ST-1111.8): cover job-status mappers and enqueue/cancel actions Co-Authored-By: Claude Sonnet 4.6 * docs(ST-1111.9): document Claude job queue architecture and agent flow Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.10a): add ClaudeWorker presence model Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.10c): forward worker presence events on solo SSE + initial count Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1111.10d): show worker presence indicator and gate 'Voer uit' on connected workers Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- CLAUDE.md | 6 +- __tests__/actions/claude-jobs.test.ts | 165 ++++++++++++++++++ __tests__/lib/job-status.test.ts | 43 +++++ actions/claude-jobs.ts | 97 ++++++++++ app/api/realtime/solo/route.ts | 100 ++++++++++- components/shared/job-status.ts | 21 +++ components/solo/solo-board.tsx | 8 + components/solo/solo-task-card.tsx | 28 ++- components/solo/task-detail-dialog.tsx | 76 +++++++- docs/erd.svg | 2 +- docs/plans/ST-1111-claude-job-trigger.md | 69 ++++++++ docs/scrum4me-architecture.md | 50 ++++++ lib/job-status.ts | 32 ++++ lib/realtime/use-solo-realtime.ts | 38 +++- .../migration.sql | 43 +++++ .../migration.sql | 23 +++ prisma/schema.prisma | 84 +++++++-- stores/solo-store.ts | 63 +++++++ 18 files changed, 921 insertions(+), 27 deletions(-) create mode 100644 __tests__/actions/claude-jobs.test.ts create mode 100644 __tests__/lib/job-status.test.ts create mode 100644 actions/claude-jobs.ts create mode 100644 components/shared/job-status.ts create mode 100644 docs/plans/ST-1111-claude-job-trigger.md create mode 100644 lib/job-status.ts create mode 100644 prisma/migrations/20260429165857_add_claude_job/migration.sql create mode 100644 prisma/migrations/20260429171047_add_claude_worker/migration.sql diff --git a/CLAUDE.md b/CLAUDE.md index 61e57cf..6693080 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -262,7 +262,7 @@ docs(ST-XXX): document profile feature Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp) die de REST-API als native tools voor Claude Code aanbiedt. Schema's worden gedeeld via een git submodule (`vendor/scrum4me`), niet gedupliceerd. -### Tools beschikbaar in Claude Code (16) +### Tools beschikbaar in Claude Code (18) **Read / context:** - `mcp__scrum4me__health` — service + DB ping @@ -285,6 +285,10 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g - `mcp__scrum4me__list_open_questions` — eigen vragen, max 50, recente eerst - `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag +**Job queue — agent worker mode (M13):** +- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. +- `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`. + ### Prompt - `implement_next_story` (arg: `product_id`) — end-to-end workflow diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts new file mode 100644 index 0000000..fea9d8c --- /dev/null +++ b/__tests__/actions/claude-jobs.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { + mockGetSession, + mockFindFirstTask, + mockFindFirstJob, + mockCreateJob, + mockUpdateJob, + mockExecuteRaw, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockFindFirstTask: vi.fn(), + mockFindFirstJob: vi.fn(), + mockCreateJob: vi.fn(), + mockUpdateJob: vi.fn(), + mockExecuteRaw: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + task: { findFirst: mockFindFirstTask }, + claudeJob: { + findFirst: mockFindFirstJob, + create: mockCreateJob, + update: mockUpdateJob, + }, + $executeRaw: mockExecuteRaw, + }, +})) + +import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs' + +const SESSION_USER = { userId: 'user-1', isDemo: false } + +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } +const TASK_ID = 'task-cuid-1' +const JOB_ID = 'job-cuid-1' +const PRODUCT_ID = 'product-cuid-1' + +const MOCK_TASK = { id: TASK_ID, story: { product_id: PRODUCT_ID } } +const MOCK_JOB_QUEUED = { id: JOB_ID, status: 'QUEUED' as const, task_id: TASK_ID, product_id: PRODUCT_ID } + +beforeEach(() => { + vi.clearAllMocks() + mockExecuteRaw.mockResolvedValue(undefined) +}) + +describe('enqueueClaudeJobAction', () => { + it('happy path: creates job with QUEUED status', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstTask.mockResolvedValue(MOCK_TASK) + mockFindFirstJob.mockResolvedValue(null) + mockCreateJob.mockResolvedValue({ id: JOB_ID }) + + const result = await enqueueClaudeJobAction(TASK_ID) + + expect(result).toEqual({ success: true, jobId: JOB_ID }) + expect(mockCreateJob).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ status: 'QUEUED', task_id: TASK_ID }) }) + ) + }) + + it('blocks demo user', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await enqueueClaudeJobAction(TASK_ID) + + expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) + expect(mockCreateJob).not.toHaveBeenCalled() + }) + + it('returns error when task not found', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstTask.mockResolvedValue(null) + + const result = await enqueueClaudeJobAction(TASK_ID) + + expect(result).toMatchObject({ error: 'Task niet gevonden' }) + expect(mockCreateJob).not.toHaveBeenCalled() + }) + + it('idempotency: returns existing jobId when QUEUED job exists', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstTask.mockResolvedValue(MOCK_TASK) + mockFindFirstJob.mockResolvedValue({ id: JOB_ID }) + + const result = await enqueueClaudeJobAction(TASK_ID) + + expect(result).toMatchObject({ error: 'Er loopt al een agent voor deze task', jobId: JOB_ID }) + expect(mockCreateJob).not.toHaveBeenCalled() + }) + + it('allows new enqueue after terminal (DONE) job', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstTask.mockResolvedValue(MOCK_TASK) + mockFindFirstJob.mockResolvedValue(null) // no active job + mockCreateJob.mockResolvedValue({ id: 'new-job-id' }) + + const result = await enqueueClaudeJobAction(TASK_ID) + + expect(result).toEqual({ success: true, jobId: 'new-job-id' }) + }) +}) + +describe('cancelClaudeJobAction', () => { + it('happy path: cancels QUEUED job', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED) + mockUpdateJob.mockResolvedValue({}) + + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toEqual({ success: true }) + expect(mockUpdateJob).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: JOB_ID }, + data: expect.objectContaining({ status: 'CANCELLED' }), + }) + ) + }) + + it('demo user is blocked', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) + expect(mockUpdateJob).not.toHaveBeenCalled() + }) + + it('returns error when job not found (ownership check)', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue(null) + + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toMatchObject({ error: 'Job niet gevonden' }) + expect(mockUpdateJob).not.toHaveBeenCalled() + }) + + it('returns error when cancelling terminal (DONE) job', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const }) + + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' }) + expect(mockUpdateJob).not.toHaveBeenCalled() + }) + + it('returns error when cancelling FAILED job', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'FAILED' as const }) + + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' }) + }) +}) diff --git a/__tests__/lib/job-status.test.ts b/__tests__/lib/job-status.test.ts new file mode 100644 index 0000000..db8d1ab --- /dev/null +++ b/__tests__/lib/job-status.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { + jobStatusToApi, + jobStatusFromApi, + JOB_STATUS_API_VALUES, + ACTIVE_JOB_STATUSES, +} from '@/lib/job-status' + +describe('job-status mappers', () => { + it('round-trips every API value', () => { + for (const api of JOB_STATUS_API_VALUES) { + const db = jobStatusFromApi(api) + expect(db).not.toBeNull() + expect(jobStatusToApi(db!)).toBe(api) + } + }) + + it('returns null for invalid input', () => { + expect(jobStatusFromApi('NOT_A_STATUS')).toBeNull() + expect(jobStatusFromApi('')).toBeNull() + expect(jobStatusFromApi('active')).toBeNull() + }) + + it('is case-insensitive on the API side (accepts both upper and lower)', () => { + expect(jobStatusFromApi('running')).toBe('RUNNING') + expect(jobStatusFromApi('RUNNING')).toBe('RUNNING') + expect(jobStatusFromApi('QUEUED')).toBe('QUEUED') + }) + + it('maps all 6 DB statuses to API', () => { + expect(jobStatusToApi('QUEUED')).toBe('queued') + expect(jobStatusToApi('CLAIMED')).toBe('claimed') + expect(jobStatusToApi('RUNNING')).toBe('running') + expect(jobStatusToApi('DONE')).toBe('done') + expect(jobStatusToApi('FAILED')).toBe('failed') + expect(jobStatusToApi('CANCELLED')).toBe('cancelled') + }) + + it('ACTIVE_JOB_STATUSES contains exactly QUEUED, CLAIMED, RUNNING', () => { + expect(ACTIVE_JOB_STATUSES).toEqual(expect.arrayContaining(['QUEUED', 'CLAIMED', 'RUNNING'])) + expect(ACTIVE_JOB_STATUSES).toHaveLength(3) + }) +}) diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts new file mode 100644 index 0000000..fa9a1e8 --- /dev/null +++ b/actions/claude-jobs.ts @@ -0,0 +1,97 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import { productAccessFilter } from '@/lib/product-access' +import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status' + +type EnqueueResult = + | { success: true; jobId: string } + | { error: string; jobId?: string } + +type CancelResult = { success: true } | { error: string } + +export async function enqueueClaudeJobAction(taskId: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + if (!taskId) return { error: 'task_id is verplicht' } + + // Resolve task + product access in one query + const task = await prisma.task.findFirst({ + where: { + id: taskId, + story: { product: productAccessFilter(session.userId) }, + }, + select: { id: true, story: { select: { product_id: true } } }, + }) + if (!task) return { error: 'Task niet gevonden' } + + const productId = task.story.product_id + + // Idempotency: weiger als er al een actieve job voor deze task bestaat + const existing = await prisma.claudeJob.findFirst({ + where: { task_id: taskId, status: { in: ACTIVE_JOB_STATUSES } }, + select: { id: true }, + }) + if (existing) { + return { error: 'Er loopt al een agent voor deze task', jobId: existing.id } + } + + const job = await prisma.claudeJob.create({ + data: { user_id: session.userId, product_id: productId, task_id: taskId, status: 'QUEUED' }, + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + task_id: taskId, + user_id: session.userId, + product_id: productId, + status: 'queued', + })}::text) + ` + + revalidatePath(`/products/${productId}/solo`) + return { success: true, jobId: job.id } +} + +export async function cancelClaudeJobAction(jobId: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + if (!jobId) return { error: 'job_id is verplicht' } + + const job = await prisma.claudeJob.findFirst({ + where: { id: jobId, user_id: session.userId }, + select: { id: true, status: true, task_id: true, product_id: true }, + }) + if (!job) return { error: 'Job niet gevonden' } + + if (!ACTIVE_JOB_STATUSES.includes(job.status)) { + return { error: 'Alleen actieve jobs kunnen geannuleerd worden' } + } + + await prisma.claudeJob.update({ + where: { id: jobId }, + data: { status: 'CANCELLED', finished_at: new Date() }, + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_status', + job_id: jobId, + task_id: job.task_id, + user_id: session.userId, + product_id: job.product_id, + status: jobStatusToApi('CANCELLED'), + })}::text) + ` + + revalidatePath(`/products/${job.product_id}/solo`) + return { success: true } +} diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index ba68b63..4e93ba8 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -23,7 +23,7 @@ const CHANNEL = 'scrum4me_changes' const HEARTBEAT_MS = 25_000 const HARD_CLOSE_MS = 240_000 -interface NotifyPayload { +type EntityPayload = { op: 'I' | 'U' | 'D' // M11 (ST-1101) voegt entity:'question' toe op hetzelfde scrum4me_changes- // kanaal; we filteren die hieronder weg zodat solo-clients geen @@ -37,12 +37,49 @@ interface NotifyPayload { changed_fields?: string[] } +type JobPayload = { + type: 'claude_job_enqueued' | 'claude_job_status' + job_id: string + task_id: string + user_id: string + product_id: string + status: string + branch?: string + summary?: string + error?: string +} + +type WorkerPayload = { + type: 'worker_connected' | 'worker_disconnected' + user_id: string + token_id: string + product_id?: string +} + +type NotifyPayload = EntityPayload | JobPayload | WorkerPayload + +function isJobPayload(p: NotifyPayload): p is JobPayload { + return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') +} + +function isWorkerPayload(p: NotifyPayload): p is WorkerPayload { + return 'type' in p && (p.type === 'worker_connected' || p.type === 'worker_disconnected') +} + function shouldEmit( payload: NotifyPayload, productId: string, activeSprintId: string | null, userId: string, ): boolean { + if (isJobPayload(payload)) { + return payload.user_id === userId && payload.product_id === productId + } + + if (isWorkerPayload(payload)) { + return payload.user_id === userId + } + // M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier. if (payload.entity === 'question') return false @@ -159,6 +196,17 @@ export async function GET(request: NextRequest) { })}\n\n`, ) + // Stuur initiële ClaudeJob-state zodat de UI synchroon is bij reconnect + const activeJobs = await prisma_jobs_findActive(userId, productId) + if (activeJobs.length > 0) { + enqueue(`event: claude_jobs_initial\ndata: ${JSON.stringify(activeJobs)}\n\n`) + } + + // Stale workers opruimen + actieve count sturen + await prisma_workers_cleanup() + const workerCount = await prisma_workers_count(userId) + enqueue(`event: workers_initial\ndata: ${JSON.stringify({ count: workerCount })}\n\n`) + // Heartbeat als SSE-comment — voorkomt proxy-timeouts heartbeatTimer = setInterval(() => { enqueue(`: heartbeat\n\n`) @@ -186,8 +234,6 @@ export async function GET(request: NextRequest) { }) } -// Lokaal helper — Prisma vermijden voor deze ene query om de pg-only flow -// schoon te houden. Geeft de actieve sprint van een product, of null. async function prisma_sprint_findActive(productId: string): Promise<{ id: string } | null> { const { prisma } = await import('@/lib/prisma') return prisma.sprint.findFirst({ @@ -196,3 +242,51 @@ async function prisma_sprint_findActive(productId: string): Promise<{ id: string orderBy: { created_at: 'desc' }, }) } + +async function prisma_jobs_findActive(userId: string, productId: string) { + const { prisma } = await import('@/lib/prisma') + const { jobStatusToApi } = await import('@/lib/job-status') + const today = new Date() + today.setHours(0, 0, 0, 0) + const jobs = await prisma.claudeJob.findMany({ + where: { + user_id: userId, + product_id: productId, + OR: [ + { status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] } }, + { status: { in: ['DONE', 'FAILED'] }, finished_at: { gte: today } }, + ], + }, + select: { + id: true, task_id: true, status: true, branch: true, summary: true, error: true, + }, + orderBy: { created_at: 'asc' }, + }) + return jobs.map(j => ({ + job_id: j.id, + task_id: j.task_id, + status: jobStatusToApi(j.status), + branch: j.branch ?? undefined, + summary: j.summary ?? undefined, + error: j.error ?? undefined, + })) +} + +const WORKER_STALE_MS = 60_000 + +async function prisma_workers_cleanup() { + const { prisma } = await import('@/lib/prisma') + await prisma.claudeWorker.deleteMany({ + where: { last_seen_at: { lt: new Date(Date.now() - WORKER_STALE_MS) } }, + }) +} + +async function prisma_workers_count(userId: string): Promise { + const { prisma } = await import('@/lib/prisma') + return prisma.claudeWorker.count({ + where: { + user_id: userId, + last_seen_at: { gt: new Date(Date.now() - 15_000) }, + }, + }) +} diff --git a/components/shared/job-status.ts b/components/shared/job-status.ts new file mode 100644 index 0000000..06b8ecf --- /dev/null +++ b/components/shared/job-status.ts @@ -0,0 +1,21 @@ +import type { ClaudeJobStatusApi } from '@/lib/job-status' + +export const JOB_STATUS_LABELS: Record = { + queued: 'Wacht…', + claimed: 'Geclaimd…', + running: 'Bezig…', + done: 'Klaar', + failed: 'Mislukt', + cancelled: 'Geannuleerd', +} + +export const JOB_STATUS_COLORS: Record = { + queued: 'bg-status-todo/15 text-status-todo border-status-todo/30', + claimed: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', + running: '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', + failed: 'bg-status-blocked/15 text-status-blocked border-status-blocked/30', + cancelled: 'bg-muted text-muted-foreground border-border', +} + +export const JOB_STATUS_ACTIVE = new Set(['queued', 'claimed', 'running']) diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 934f984..fb50be8 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -92,6 +92,7 @@ export function SoloBoard({ const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore() const realtimeStatus = useSoloStore((s) => s.realtimeStatus) const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) + const connectedWorkers = useSoloStore((s) => s.connectedWorkers) const [activeDragId, setActiveDragId] = useState(null) const [selectedTask, setSelectedTask] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) @@ -192,6 +193,13 @@ export function SoloBoard({ status={realtimeStatus} showConnectingIndicator={showConnectingIndicator} /> +
+ 0 ? 'bg-status-done' : 'bg-muted-foreground/40' + )} /> + {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} +
{sprintGoal && (

{sprintGoal}

diff --git a/components/solo/solo-task-card.tsx b/components/solo/solo-task-card.tsx index 61135c8..6289d57 100644 --- a/components/solo/solo-task-card.tsx +++ b/components/solo/solo-task-card.tsx @@ -3,8 +3,11 @@ import type React from 'react' import { useDraggable } from '@dnd-kit/core' import { CSS } from '@dnd-kit/utilities' +import { Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' import { CodeBadge } from '@/components/shared/code-badge' +import { JOB_STATUS_LABELS, JOB_STATUS_COLORS, JOB_STATUS_ACTIVE } from '@/components/shared/job-status' +import { useSoloStore } from '@/stores/solo-store' import type { SoloTask } from './solo-board' const PRIORITY_BORDER: Record = { @@ -21,6 +24,7 @@ interface SoloTaskCardProps { } export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) { + const job = useSoloStore(s => s.claudeJobsByTaskId[task.id]) const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id, disabled: isDemo, @@ -51,10 +55,26 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) {

{task.title}

{task.task_code && } -

- {task.story_code && {task.story_code}} - {task.story_title} -

+
+

+ {task.story_code && {task.story_code}} + {task.story_title} +

+ {job && ( + { e.stopPropagation(); onClick() }} + role="button" + aria-label={`Agent-status: ${JOB_STATUS_LABELS[job.status]}`} + > + {JOB_STATUS_ACTIVE.has(job.status) && } + {JOB_STATUS_LABELS[job.status]} + + )} +
) } diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index b37be68..d9b6db1 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -5,9 +5,12 @@ import Link from 'next/link' import { toast } from 'sonner' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { useSoloStore } from '@/stores/solo-store' +import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs' import { cn } from '@/lib/utils' import type { SoloTask } from './solo-board' @@ -43,12 +46,34 @@ type SaveState = 'idle' | 'saving' | 'saved' function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailContentProps) { const { updatePlan } = useSoloStore() + const job = useSoloStore(s => s.claudeJobsByTaskId[task.id]) + const connectedWorkers = useSoloStore(s => s.connectedWorkers) const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '') const [saveState, setSaveState] = useState('idle') const [, startTransition] = useTransition() + const [jobPending, startJobTransition] = useTransition() const fadeTimer = useRef | null>(null) const savedPlanRef = useRef(task.implementation_plan ?? '') + function handleEnqueue() { + startJobTransition(async () => { + const result = await enqueueClaudeJobAction(task.id) + if ('error' in result) { + toast.error(result.error) + } else { + toast.success('Agent ingeschakeld') + } + }) + } + + function handleCancel() { + if (!job) return + startJobTransition(async () => { + const result = await cancelClaudeJobAction(job.job_id) + if ('error' in result) toast.error(result.error) + }) + } + function handleBlur() { if (isDemo || localPlan === savedPlanRef.current) return @@ -133,14 +158,61 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte -
+
Open in Sprint Board ↗ + + {!isDemo && !job && ( + + + + Voer uit + + } + /> + {connectedWorkers === 0 && ( + + Geen Claude Code-sessie verbonden. Start claude lokaal en zeg 'wacht op jobs'. + + )} + + + )} + + {job?.status === 'queued' && ( + Wacht op agent… + )} + + {(job?.status === 'claimed' || job?.status === 'running') && ( + <> + Bezig: {job.summary ?? '…'} + + + )} + + {job?.status === 'done' && ( + + Klaar{job.branch ? ` — branch ${job.branch}` : ''} + + )} + + {job?.status === 'failed' && ( + Mislukt: {job.error} + )}
) diff --git a/docs/erd.svg b/docs/erd.svg index ec45d14..fd4b3eb 100644 --- a/docs/erd.svg +++ b/docs/erd.svg @@ -1 +1 @@ -

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

sprint

enum:status

product

user

user

product

user

story

task

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

DateTime

created_at

DateTime

updated_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file +

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

sprint

enum:status

user

product

task

enum:status

claimed_by_token

user

token

product

user

user

product

user

story

task

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

String

branch

String

summary

String

error

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file diff --git a/docs/plans/ST-1111-claude-job-trigger.md b/docs/plans/ST-1111-claude-job-trigger.md new file mode 100644 index 0000000..1e8d0ab --- /dev/null +++ b/docs/plans/ST-1111-claude-job-trigger.md @@ -0,0 +1,69 @@ +# ST-1111 — 'Voer uit'-knop met Claude Code job queue + +**Story:** Als developer wil ik op het solo-scherm per task een 'Voer uit'-knop, zodat ik mijn lokale Claude Code-sessie kan inschakelen om de taak uit te voeren. + +**Branch:** `feat/M13-claude-job-queue` + +--- + +## Sub-tasks en commits + +| Task | Commit | +|---|---| +| ST-1111.1 DB: ClaudeJob model + enum + migration | `5274e1e` | +| ST-1111.2 API: ClaudeJob status mappers | `a1b1f69` | +| ST-1111.3 Server actions: enqueue + cancel | `9d9fb4b` | +| ST-1111.4 SSE: ClaudeJob events op solo-stream + initial state | `ece0aa9` | +| ST-1111.5 MCP-tools (scrum4me-mcp repo — aparte PR) | — | +| ST-1111.6 UI: 'Voer uit' + cancel in TaskDetailDialog | `b9c65eb` | +| ST-1111.7 UI: status-pill op SoloTaskCard | `dace427` | +| ST-1111.8 Tests: mappers + actions | `2c2a246` | +| ST-1111.9 Docs | dit bestand | + +--- + +## Architectuur + +### State machine + +``` +QUEUED → CLAIMED → RUNNING → DONE + → FAILED + → CANCELLED (cancel-knop of server action) +CLAIMED → QUEUED (stale cleanup, >30min, via wait_for_job) +``` + +### NOTIFY-pijplijn + +Omdat `claude_jobs` geen row-trigger heeft (zoals `tasks` en `stories`), stuurt de **server action** zelf `pg_notify` via `prisma.$executeRaw`: + +```ts +await prisma.$executeRaw`SELECT pg_notify('scrum4me_changes', ${JSON.stringify(payload)}::text)` +``` + +Voordeel: expliciete controle over het payload-shape (met `type` i.p.v. `entity`). Nadeel: MCP-tools in de `scrum4me-mcp`-repo moeten hun eigen NOTIFY-aanroep hebben bij `update_job_status`. + +### SSE-routing + +De bestaande `/api/realtime/solo`-route herkent nu twee payload-shapes: +- `entity: 'task'|'story'` — bestaande trigger-events +- `type: 'claude_job_enqueued'|'claude_job_status'` — nieuwe job-events + +Job-events worden gefilterd op `user_id + product_id`. Bij connect stuurt de route een `claude_jobs_initial`-event met alle actieve + recente (vandaag) jobs. + +### Idempotency + +`enqueueClaudeJobAction` weigert als `claude_jobs WHERE task_id=X AND status IN (QUEUED, CLAIMED, RUNNING)` bestaat. De client ontvangt `{ error, jobId }` zodat de UI naar de actieve job kan linken in plaats van een nieuw venstertje te openen. + +--- + +## Beslissingen + +**Waarom geen DB-trigger voor NOTIFY?** +De MCP-server claimt jobs via raw SQL (FOR UPDATE SKIP LOCKED); die schrijft ook direct naar de DB. Een trigger zou clean zijn, maar de MCP-tools moeten hoe dan ook hun eigen NOTIFY-payload bouwen voor `update_job_status`. Applicatie-NOTIFY houdt de payloads consistent en expliciet. + +**Waarom `cancelled` verwijderd uit de store?** +Geannuleerde jobs zijn terminaal; het pill-element zou "Geannuleerd" tonen tot de gebruiker een refresh doet. In plaats daarvan wist `handleJobEvent` de entry bij `status === 'cancelled'` zodat de kaart teruggaat naar de "Voer uit"-staat. + +**Auto-clear DONE/FAILED?** +Niet geïmplementeerd in v1. De pill blijft staan totdat de SSE-connectie herstart (refresh, tab-hidden+visible). Acceptabel voor de eerste iteratie. diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md index 02da55b..bc244f5 100644 --- a/docs/scrum4me-architecture.md +++ b/docs/scrum4me-architecture.md @@ -1047,6 +1047,56 @@ Patroon: **Let op:** drag-and-drop handles (`⠿`) blijven verborgen voor demo (`{!isDemo && }`) — dragging is geen UI-showcase maar zou nep-optimistische updates triggeren. +--- + +## Claude job queue (M13 — ST-1111) + +Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie inschakelen. De job queue zorgt voor coördinatie en realtime-status. + +### State machine + +``` +QUEUED → CLAIMED → RUNNING → DONE + → FAILED + → CANCELLED (door user) +CLAIMED → QUEUED (stale claim cleanup, >30min) +``` + +### ClaudeJob model + +``` +claude_jobs + id, user_id, product_id, task_id + status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED) + claimed_by_token_id (FK → api_tokens, nullable) + claimed_at, started_at, finished_at + branch, summary, error + @@index([user_id, status]) + @@index([task_id, status]) + @@index([status, claimed_at]) — voor stale-claim cleanup +``` + +### NOTIFY/LISTEN flow + +``` +UI klikt 'Voer uit' + → enqueueClaudeJobAction() Server Action + → prisma.claudeJob.create(QUEUED) + → prisma.$executeRaw pg_notify('scrum4me_changes', {type:'claude_job_enqueued',...}) + → /api/realtime/solo SSE server-side filter: user_id + product_id + → EventSource.onmessage browser: handleJobEvent() + → useSoloStore.claudeJobsByTaskId map + → SoloTaskCard pill + dialog-footer update +``` + +### Idempotency + +`enqueueClaudeJobAction` weigert een tweede enqueue als er al een job bestaat met `status IN (QUEUED, CLAIMED, RUNNING)`. Teruggestuurde fout bevat het bestaande `jobId` zodat de UI ernaar kan linken. + +### Hybride-ready + +De huidige implementatie verwacht een lokale Claude Code-sessie die `wait_for_job` aanroept vanuit `madhura68/scrum4me-mcp`. Toekomstige uitbreiding naar Vercel Sandbox (serverless agent) vereist alleen een nieuw claim-endpoint — het datamodel en SSE-flow zijn ongewijzigd. + ## Environment variables | Variabele | Doel | Waar te vinden | diff --git a/lib/job-status.ts b/lib/job-status.ts new file mode 100644 index 0000000..f6ac4ee --- /dev/null +++ b/lib/job-status.ts @@ -0,0 +1,32 @@ +import type { ClaudeJobStatus } from '@prisma/client' + +const JOB_DB_TO_API = { + QUEUED: 'queued', + CLAIMED: 'claimed', + RUNNING: 'running', + DONE: 'done', + FAILED: 'failed', + CANCELLED: 'cancelled', +} as const satisfies Record + +const JOB_API_TO_DB: Record = { + queued: 'QUEUED', + claimed: 'CLAIMED', + running: 'RUNNING', + done: 'DONE', + failed: 'FAILED', + cancelled: 'CANCELLED', +} + +export type ClaudeJobStatusApi = typeof JOB_DB_TO_API[ClaudeJobStatus] + +export function jobStatusToApi(s: ClaudeJobStatus): ClaudeJobStatusApi { + return JOB_DB_TO_API[s] +} + +export function jobStatusFromApi(s: string): ClaudeJobStatus | null { + return JOB_API_TO_DB[s.toLowerCase()] ?? null +} + +export const JOB_STATUS_API_VALUES = Object.values(JOB_DB_TO_API) +export const ACTIVE_JOB_STATUSES: ClaudeJobStatus[] = ['QUEUED', 'CLAIMED', 'RUNNING'] diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts index 6f0340f..928dd80 100644 --- a/lib/realtime/use-solo-realtime.ts +++ b/lib/realtime/use-solo-realtime.ts @@ -20,7 +20,7 @@ import { useEffect, useRef } from 'react' import { flushSync } from 'react-dom' import { useSoloStore } from '@/stores/solo-store' -import type { RealtimeEvent, RealtimeStatus } from '@/stores/solo-store' +import type { ClaudeJobEvent, JobState, RealtimeEvent, RealtimeStatus } from '@/stores/solo-store' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 @@ -35,6 +35,11 @@ export function useSoloRealtime(productId: string | null) { useEffect(() => { const setStatus = useSoloStore.getState().setRealtimeStatus const handleEvent = useSoloStore.getState().handleRealtimeEvent + const handleJobEvent = useSoloStore.getState().handleJobEvent + const initJobs = useSoloStore.getState().initJobs + const setWorkers = useSoloStore.getState().setWorkers + const incrementWorkers = useSoloStore.getState().incrementWorkers + const decrementWorkers = useSoloStore.getState().decrementWorkers if (!productId) { // Geen actief product (gebruiker zit niet op /solo) — stream uit @@ -84,10 +89,39 @@ export function useSoloRealtime(productId: string | null) { scheduleIndicator('open') }) + source.addEventListener('claude_jobs_initial', (e) => { + if (!e.data) return + try { + initJobs(JSON.parse(e.data) as JobState[]) + } catch { + // ignore malformed payload + } + }) + + source.addEventListener('workers_initial', (e) => { + if (!e.data) return + try { + const { count } = JSON.parse(e.data) as { count: number } + setWorkers(count) + } catch { + // ignore malformed payload + } + }) + source.onmessage = (e) => { if (!e.data) return try { - const payload = JSON.parse(e.data) as RealtimeEvent + const raw = JSON.parse(e.data) as RealtimeEvent | ClaudeJobEvent | { type: string } + if ('type' in raw) { + if (raw.type === 'claude_job_enqueued' || raw.type === 'claude_job_status') { + handleJobEvent(raw as ClaudeJobEvent) + return + } + if (raw.type === 'worker_connected') { incrementWorkers(); return } + if (raw.type === 'worker_disconnected') { decrementWorkers(); return } + return + } + const payload = raw as RealtimeEvent // Animatie A: kanban-move animeren via View Transitions API. Voor // task UPDATE-events wrap'en we de store-update in een view // transition. flushSync forceert React om synchroon te renderen diff --git a/prisma/migrations/20260429165857_add_claude_job/migration.sql b/prisma/migrations/20260429165857_add_claude_job/migration.sql new file mode 100644 index 0000000..7360c3f --- /dev/null +++ b/prisma/migrations/20260429165857_add_claude_job/migration.sql @@ -0,0 +1,43 @@ +-- CreateEnum +CREATE TYPE "ClaudeJobStatus" AS ENUM ('QUEUED', 'CLAIMED', 'RUNNING', 'DONE', 'FAILED', 'CANCELLED'); + +-- CreateTable +CREATE TABLE "claude_jobs" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "task_id" TEXT NOT NULL, + "status" "ClaudeJobStatus" NOT NULL DEFAULT 'QUEUED', + "claimed_by_token_id" TEXT, + "claimed_at" TIMESTAMP(3), + "started_at" TIMESTAMP(3), + "finished_at" TIMESTAMP(3), + "branch" TEXT, + "summary" TEXT, + "error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "claude_jobs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "claude_jobs_user_id_status_idx" ON "claude_jobs"("user_id", "status"); + +-- CreateIndex +CREATE INDEX "claude_jobs_task_id_status_idx" ON "claude_jobs"("task_id", "status"); + +-- CreateIndex +CREATE INDEX "claude_jobs_status_claimed_at_idx" ON "claude_jobs"("status", "claimed_at"); + +-- AddForeignKey +ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_claimed_by_token_id_fkey" FOREIGN KEY ("claimed_by_token_id") REFERENCES "api_tokens"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260429171047_add_claude_worker/migration.sql b/prisma/migrations/20260429171047_add_claude_worker/migration.sql new file mode 100644 index 0000000..f951db3 --- /dev/null +++ b/prisma/migrations/20260429171047_add_claude_worker/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "claude_workers" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_id" TEXT NOT NULL, + "product_id" TEXT, + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "claude_workers_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "claude_workers_user_id_last_seen_at_idx" ON "claude_workers"("user_id", "last_seen_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "claude_workers_token_id_key" ON "claude_workers"("token_id"); + +-- AddForeignKey +ALTER TABLE "claude_workers" ADD CONSTRAINT "claude_workers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "claude_workers" ADD CONSTRAINT "claude_workers_token_id_fkey" FOREIGN KEY ("token_id") REFERENCES "api_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 07c5816..6586980 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,6 +29,15 @@ enum PbiStatus { DONE } +enum ClaudeJobStatus { + QUEUED + CLAIMED + RUNNING + DONE + FAILED + CANCELLED +} + enum TaskStatus { TO_DO IN_PROGRESS @@ -66,14 +75,16 @@ model User { created_at DateTime @default(now()) updated_at DateTime @updatedAt roles UserRole[] - api_tokens ApiToken[] - products Product[] - todos Todo[] - product_members ProductMember[] - assigned_stories Story[] @relation("StoryAssignee") - login_pairings LoginPairing[] + api_tokens ApiToken[] + products Product[] + todos Todo[] + product_members ProductMember[] + assigned_stories Story[] @relation("StoryAssignee") + login_pairings LoginPairing[] asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker") answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") + claude_jobs ClaudeJob[] + claude_workers ClaudeWorker[] @@index([active_product_id]) @@map("users") @@ -90,13 +101,15 @@ model UserRole { } model ApiToken { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - token_hash String @unique - label String? - created_at DateTime @default(now()) - revoked_at DateTime? + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token_hash String @unique + label String? + created_at DateTime @default(now()) + revoked_at DateTime? + claimed_jobs ClaudeJob[] + claude_worker ClaudeWorker? @@index([token_hash]) @@map("api_tokens") @@ -119,8 +132,9 @@ model Product { stories Story[] todos Todo[] members ProductMember[] - active_for_users User[] @relation("UserActiveProduct") + active_for_users User[] @relation("UserActiveProduct") claude_questions ClaudeQuestion[] + claude_jobs ClaudeJob[] @@unique([user_id, name]) @@unique([user_id, code]) @@ -225,12 +239,54 @@ model Task { created_at DateTime @default(now()) updated_at DateTime @updatedAt claude_questions ClaudeQuestion[] + claude_jobs ClaudeJob[] @@index([story_id, priority, sort_order]) @@index([sprint_id, status]) @@map("tasks") } +model ClaudeJob { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + task Task @relation(fields: [task_id], references: [id], onDelete: Cascade) + task_id String + status ClaudeJobStatus @default(QUEUED) + claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) + claimed_by_token_id String? + claimed_at DateTime? + started_at DateTime? + finished_at DateTime? + branch String? + summary String? + error String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([user_id, status]) + @@index([task_id, status]) + @@index([status, claimed_at]) + @@map("claude_jobs") +} + +model ClaudeWorker { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) + token_id String + product_id String? + started_at DateTime @default(now()) + last_seen_at DateTime @default(now()) + + @@unique([token_id]) + @@index([user_id, last_seen_at]) + @@map("claude_workers") +} + model ProductMember { id String @id @default(cuid()) product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) diff --git a/stores/solo-store.ts b/stores/solo-store.ts index 39c3500..46cf569 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -1,8 +1,22 @@ import { create } from 'zustand' import type { SoloTask } from '@/components/solo/solo-board' +import type { ClaudeJobStatusApi } from '@/lib/job-status' type TaskStatus = SoloTask['status'] +export interface JobState { + job_id: string + task_id: string + status: ClaudeJobStatusApi + branch?: string + summary?: string + error?: string +} + +export type ClaudeJobEvent = + | { type: 'claude_job_enqueued'; job_id: string; task_id: string; user_id: string; product_id: string; status: 'queued' } + | { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; summary?: string; error?: string } + // Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801 // + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit // /api/realtime/solo (ST-802). @@ -42,6 +56,9 @@ interface SoloStore { realtimeStatus: RealtimeStatus showConnectingIndicator: boolean + claudeJobsByTaskId: Record + connectedWorkers: number + initTasks: (tasks: SoloTask[]) => void optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null rollback: (taskId: string, prevStatus: TaskStatus) => void @@ -52,6 +69,13 @@ interface SoloStore { setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void + initJobs: (jobs: JobState[]) => void + handleJobEvent: (event: ClaudeJobEvent) => void + + setWorkers: (count: number) => void + incrementWorkers: () => void + decrementWorkers: () => void + handleRealtimeEvent: (event: RealtimeEvent) => void } @@ -60,6 +84,8 @@ export const useSoloStore = create((set, get) => ({ pendingOps: new Set(), realtimeStatus: 'connecting', showConnectingIndicator: false, + claudeJobsByTaskId: {}, + connectedWorkers: 0, initTasks: (tasks) => set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }), @@ -101,6 +127,43 @@ export const useSoloStore = create((set, get) => ({ return { realtimeStatus: status, showConnectingIndicator } }), + initJobs: (jobs) => + set({ claudeJobsByTaskId: Object.fromEntries(jobs.map(j => [j.task_id, j])) }), + + setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }), + incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })), + decrementWorkers: () => set(s => ({ connectedWorkers: Math.max(0, s.connectedWorkers - 1) })), + + handleJobEvent: (event) => { + const { job_id, task_id } = event + if (event.type === 'claude_job_enqueued') { + set((s) => ({ + claudeJobsByTaskId: { + ...s.claudeJobsByTaskId, + [task_id]: { job_id, task_id, status: 'queued' }, + }, + })) + return + } + if (event.type === 'claude_job_status') { + const { status, branch, summary, error } = event + if (status === 'cancelled') { + set((s) => { + const next = { ...s.claudeJobsByTaskId } + delete next[task_id] + return { claudeJobsByTaskId: next } + }) + return + } + set((s) => ({ + claudeJobsByTaskId: { + ...s.claudeJobsByTaskId, + [task_id]: { job_id, task_id, status, branch, summary, error }, + }, + })) + } + }, + handleRealtimeEvent: (event) => { if (event.entity === 'task') { const { id, op } = event From 868a53c2ed4b65c0d6e2a2cec16242f910815aab Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Wed, 29 Apr 2026 20:23:40 +0200 Subject: [PATCH 014/282] fix(M13): hydration mismatch on backlog list filter chips (#19) useState initializers read localStorage synchronously, which produced a different render on client (with persisted filterStatus='blocked') than on server (which has no localStorage and rendered 'all'). The chip-buttons that surface active filters caused a structural DOM mismatch next to the Popover trigger, raising a hydration error. Move the localStorage read into a post-mount useEffect, defaulting state to the SSR-compatible 'all'/'priority' on first render. Add a prefsLoaded flag so persist effects skip the initial render and don't overwrite saved values with defaults. Co-authored-by: Claude Opus 4.7 (1M context) --- components/backlog/pbi-list.tsx | 49 ++++++++++++++++++++------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index c4b0171..cb6a721 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -197,29 +197,40 @@ function SortablePbiRow({ export function PbiList({ productId, pbis, isDemo }: PbiListProps) { const { selectedPbiId, selectPbi } = useSelectionStore() const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore() - const [filterPriority, setFilterPriority] = useState(() => { - if (typeof window === 'undefined') return 'all' - const saved = localStorage.getItem('scrum4me:pbi_filter_priority') - if (!saved || saved === 'all') return 'all' - const n = parseInt(saved, 10) - return Number.isInteger(n) && n >= 1 && n <= 4 ? n : 'all' - }) - const [filterStatus, setFilterStatus] = useState(() => { - if (typeof window === 'undefined') return 'all' - const saved = localStorage.getItem('scrum4me:pbi_filter_status') - return saved === 'ready' || saved === 'blocked' || saved === 'done' ? saved : 'all' - }) - const [sortMode, setSortMode] = useState(() => { - const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:pbi_sort') : null - return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority' - }) + // Defaults match SSR; persisted values applied post-mount in the loader effect below. + // This avoids hydration mismatch when localStorage holds non-default values. + const [filterPriority, setFilterPriority] = useState('all') + const [filterStatus, setFilterStatus] = useState('all') + const [sortMode, setSortMode] = useState('priority') + const [prefsLoaded, setPrefsLoaded] = useState(false) const [dialogState, setDialogState] = useState(null) const [activeDragId, setActiveDragId] = useState(null) const [, startTransition] = useTransition() - useEffect(() => { localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode]) - useEffect(() => { localStorage.setItem('scrum4me:pbi_filter_priority', String(filterPriority)) }, [filterPriority]) - useEffect(() => { localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus]) + // Load persisted preferences once after mount (client-only). + // setState calls here are intentional: hydrating from localStorage on first paint. + useEffect(() => { + const savedSort = localStorage.getItem('scrum4me:pbi_sort') + if (savedSort === 'priority' || savedSort === 'code' || savedSort === 'date') { + // eslint-disable-next-line react-hooks/set-state-in-effect + setSortMode(savedSort) + } + const savedPriority = localStorage.getItem('scrum4me:pbi_filter_priority') + if (savedPriority && savedPriority !== 'all') { + const n = parseInt(savedPriority, 10) + if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n) + } + const savedStatus = localStorage.getItem('scrum4me:pbi_filter_status') + if (savedStatus === 'ready' || savedStatus === 'blocked' || savedStatus === 'done') { + setFilterStatus(savedStatus) + } + setPrefsLoaded(true) + }, []) + + // Persist on change, but skip the initial render so we don't overwrite saved values with defaults. + useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode, prefsLoaded]) + useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded]) + useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus, prefsLoaded]) // Sync server data into store — use stable string dep to avoid infinite loop const pbiIdKey = pbis.map(p => p.id).join(',') From c6fdd45d98c248f1e4b3494d1e536fa0792572f1 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Wed, 29 Apr 2026 20:35:40 +0200 Subject: [PATCH 015/282] chore: debug-realtime tooling for SSE pipeline diagnostics (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(debug): add /debug-realtime page + bare SSE endpoint Tijdelijke debug-tooling voor M8-acceptance op Vercel preview. - app/api/debug/realtime-stream/route.ts — geen auth, geen filtering; dropt elke pg_notify-event op scrum4me_changes rauw door als SSE - app/debug-realtime/page.tsx — open zonder login op de root, toont binnenkomende events in een simpele Doel: isoleren of de SSE + Postgres LISTEN-pipe op Vercel überhaupt events laat zien, los van iron-session, productfilter of solo-store. Als ook deze niets binnen krijgt: probleem zit in pg connection of Vercel function lifecycle. Als deze wel events toont: probleem zit hoger in de stack (filter, store, hook). VERWIJDEREN voordat de PR uit draft gaat. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(debug): extend /debug-realtime with stats, emit-button and filters Bouwt de basale luister-tabel uit met diagnostische tooling om de SSE+LISTEN-pipe stress-vrij te kunnen valideren. Toegevoegd: - POST /api/debug/emit-test-notify — vuurt een handmatige pg_notify op scrum4me_changes met een synthetic payload (debug:true) zonder een echte DB-UPDATE te doen. Isoleert de SSE-route van Prisma/triggers. - DebugRealtimeClient: stats-grid (status, reconnects, total events, since last event met >30s rood-warning, largest gap, first-event- time), emit-button, reset-stats, filters op type en entity (incl. "debug only"). - Type/entity kolom in de tabel met kleuring per type. Geen impact op productie- of solo-flow. Tijdelijke testtooling; verwijderen wanneer we deze pagina niet meer nodig hebben. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(debug): add Layer 2 — mini Zustand-store + dispatch toggles Test of SSE-event → store → render-pipeline werkt buiten de Solo Paneel context. Mirrort het patroon van solo-store maar minimaal. - debug-store.ts: kleine Zustand-store met tasks + applyEvent + applyCount/skipCount-tellers - store-panel.tsx: rendert store-state in een tabel met statuskleuring - client.tsx: drie layer-toggles (dispatch / flushSync / startView- Transition) + lift dispatch in onmessage. Zo kunnen we elke combinatie isoleren Bevestigd: alle drie de toggles werken op het bare /debug-realtime endpoint. Volgende laag is Server Action revalidation. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/api/debug/emit-test-notify/route.ts | 59 +++ app/api/debug/realtime-stream/route.ts | 114 ++++++ app/debug-realtime/client.tsx | 490 ++++++++++++++++++++++++ app/debug-realtime/debug-store.ts | 71 ++++ app/debug-realtime/page.tsx | 23 ++ app/debug-realtime/store-panel.tsx | 111 ++++++ 6 files changed, 868 insertions(+) create mode 100644 app/api/debug/emit-test-notify/route.ts create mode 100644 app/api/debug/realtime-stream/route.ts create mode 100644 app/debug-realtime/client.tsx create mode 100644 app/debug-realtime/debug-store.ts create mode 100644 app/debug-realtime/page.tsx create mode 100644 app/debug-realtime/store-panel.tsx diff --git a/app/api/debug/emit-test-notify/route.ts b/app/api/debug/emit-test-notify/route.ts new file mode 100644 index 0000000..e480258 --- /dev/null +++ b/app/api/debug/emit-test-notify/route.ts @@ -0,0 +1,59 @@ +// TIJDELIJKE debug-endpoint. Stuurt een handmatige pg_notify op +// `scrum4me_changes` zonder een echte UPDATE te doen. Bedoeld om de +// SSE-pipe te testen los van Prisma/triggers. +// +// VERWIJDEREN voor M8 out-of-draft. + +import { Client } from 'pg' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const CHANNEL = 'scrum4me_changes' + +export async function POST(request: Request) { + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl) { + return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet gezet' }, { status: 500 }) + } + + let body: unknown = null + try { + body = await request.json() + } catch { + // empty body is OK — we vullen defaults in + } + + const overrides = (body && typeof body === 'object' ? body : {}) as Record + const payload = { + op: 'U', + entity: 'task', + id: `debug-${Date.now()}`, + story_id: 'debug-story', + product_id: 'debug-product', + sprint_id: null, + assignee_id: null, + task_status: 'TO_DO', + task_sort_order: 1, + task_title: 'manual debug emit', + changed_fields: ['status'], + debug: true, + emitted_at: new Date().toISOString(), + ...overrides, + } + + const client = new Client({ connectionString: directUrl }) + try { + await client.connect() + // pg_notify met JSON-string als payload — zelfde formaat als de trigger + await client.query('SELECT pg_notify($1, $2)', [CHANNEL, JSON.stringify(payload)]) + return Response.json({ ok: true, payload }) + } catch (err) { + return Response.json( + { ok: false, error: err instanceof Error ? err.message : String(err) }, + { status: 500 }, + ) + } finally { + try { await client.end() } catch {} + } +} diff --git a/app/api/debug/realtime-stream/route.ts b/app/api/debug/realtime-stream/route.ts new file mode 100644 index 0000000..e909bfc --- /dev/null +++ b/app/api/debug/realtime-stream/route.ts @@ -0,0 +1,114 @@ +// TIJDELIJKE debug-endpoint voor M8-acceptance. +// Geen auth, geen filtering — alle pg_notify-events op `scrum4me_changes` +// stromen rauw door naar de browser. Bedoeld om te isoleren of de +// SSE + LISTEN-pipe op Vercel werkt, los van iron-session, productfilter +// of solo-store. +// +// VERWIJDEREN VOOR M8 OUT-OF-DRAFT. + +import { NextRequest } from 'next/server' +import { Client } from 'pg' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const CHANNEL = 'scrum4me_changes' + +export async function GET(request: NextRequest) { + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl) { + return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet gezet' }, { status: 500 }) + } + + const isPooled = directUrl.includes('pooler.') + const hostHint = directUrl.match(/@([^/]+)/)?.[1] ?? 'unknown-host' + console.log(`[debug-realtime] connecting (${isPooled ? 'POOLED' : 'direct'}) host=${hostHint}`) + + const encoder = new TextEncoder() + const pgClient = new Client({ connectionString: directUrl }) + let closed = false + let heartbeatTimer: ReturnType | null = null + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(chunk)) + } catch { + // already closed + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + try { + await pgClient.end() + } catch { + // ignore + } + try { + controller.close() + } catch { + // already closed + } + console.log(`[debug-realtime] closed: ${reason}`) + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + console.log('[debug-realtime] LISTEN ready') + } catch (err) { + console.error('[debug-realtime] pg connect/listen failed:', err) + enqueue(`event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`) + await cleanup('pg connect failed') + return + } + + pgClient.on('notification', (msg) => { + console.log(`[debug-realtime] RAW notification length=${msg.payload?.length ?? 0}`) + if (!msg.payload) return + enqueue(`data: ${msg.payload}\n\n`) + }) + + pgClient.on('error', async (err) => { + console.error('[debug-realtime] pg client error:', err) + await cleanup('pg error') + }) + + pgClient.on('end', () => { + console.log('[debug-realtime] pg client end') + }) + + enqueue( + `event: ready\ndata: ${JSON.stringify({ + host: hostHint, + pooled: isPooled, + channel: CHANNEL, + time: new Date().toISOString(), + })}\n\n`, + ) + + heartbeatTimer = setInterval(() => { + enqueue(`: heartbeat ${new Date().toISOString()}\n\n`) + }, 25_000) + + request.signal.addEventListener('abort', () => { + cleanup('client aborted') + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/app/debug-realtime/client.tsx b/app/debug-realtime/client.tsx new file mode 100644 index 0000000..67ce1ef --- /dev/null +++ b/app/debug-realtime/client.tsx @@ -0,0 +1,490 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { flushSync } from 'react-dom' +import { useDebugStore, type DebugRealtimeEvent } from './debug-store' +import { StorePanel } from './store-panel' + +type RowType = 'ready' | 'message' | 'error' | 'open' | 'close' + +interface Row { + receivedAt: number + type: RowType + raw: string + parsed?: Record +} + +interface Stats { + reconnects: number + totalEvents: number + firstEventAt: number | null + lastEventAt: number | null + largestGapMs: number + emitInFlight: boolean + lastEmitResult: string | null +} + +const MAX_ROWS = 500 +const HEARTBEAT_WARN_MS = 30_000 + +export function DebugRealtimeClient() { + const [rows, setRows] = useState([]) + const [status, setStatus] = useState<'connecting' | 'open' | 'closed' | 'error'>('connecting') + const [stats, setStats] = useState({ + reconnects: 0, + totalEvents: 0, + firstEventAt: null, + lastEventAt: null, + largestGapMs: 0, + emitInFlight: false, + lastEmitResult: null, + }) + const [filterType, setFilterType] = useState<'all' | RowType>('all') + const [filterEntity, setFilterEntity] = useState<'all' | 'task' | 'story' | 'debug'>('all') + const [tickNow, setTickNow] = useState(() => Date.now()) + + // Layer-toggles — elke combinatie isoleert een potentiële bron van bugs. + // - dispatchToStore: voert applyEvent uit op een mini Zustand-store + // - useFlushSync: forceert React om synchroon te renderen tijdens dispatch + // - useViewTransition: wrap dispatch in document.startViewTransition + const [dispatchToStore, setDispatchToStore] = useState(true) + const [useFlushSync, setUseFlushSync] = useState(false) + const [useViewTransition, setUseViewTransition] = useState(false) + const dispatchToStoreRef = useRef(dispatchToStore) + const useFlushSyncRef = useRef(useFlushSync) + const useViewTransitionRef = useRef(useViewTransition) + useEffect(() => { dispatchToStoreRef.current = dispatchToStore }, [dispatchToStore]) + useEffect(() => { useFlushSyncRef.current = useFlushSync }, [useFlushSync]) + useEffect(() => { useViewTransitionRef.current = useViewTransition }, [useViewTransition]) + + const sourceRef = useRef(null) + const lastEventTimeRef = useRef(null) + + // Tick every second om "since last event"-counter levend te houden + useEffect(() => { + const t = setInterval(() => setTickNow(Date.now()), 1000) + return () => clearInterval(t) + }, []) + + useEffect(() => { + const append = (row: Row) => { + setRows((prev) => [row, ...prev].slice(0, MAX_ROWS)) + lastEventTimeRef.current = row.receivedAt + setStats((s) => { + const prevLast = s.lastEventAt + const gap = prevLast ? row.receivedAt - prevLast : 0 + return { + ...s, + totalEvents: s.totalEvents + 1, + firstEventAt: s.firstEventAt ?? row.receivedAt, + lastEventAt: row.receivedAt, + largestGapMs: Math.max(s.largestGapMs, gap), + } + }) + } + + const open = () => { + const source = new EventSource('/api/debug/realtime-stream') + sourceRef.current = source + + append({ receivedAt: Date.now(), type: 'open', raw: '(EventSource opening)' }) + setStats((s) => ({ ...s, reconnects: s.reconnects + 1 })) + setStatus('connecting') + + source.addEventListener('ready', (e) => { + setStatus('open') + const data = (e as MessageEvent).data ?? '' + const parsed = safeParse(data) + append({ receivedAt: Date.now(), type: 'ready', raw: data, parsed }) + }) + + source.addEventListener('error', (e) => { + setStatus('error') + const data = (e as MessageEvent).data ?? '(no data)' + append({ receivedAt: Date.now(), type: 'error', raw: data }) + }) + + source.onmessage = (e) => { + const parsed = safeParse(e.data ?? '') + append({ receivedAt: Date.now(), type: 'message', raw: e.data ?? '', parsed }) + + // Dispatch naar de mini Zustand-store, optioneel met flushSync + // en/of startViewTransition om de echte solo-flow te mimeken. + if (dispatchToStoreRef.current && parsed) { + const dispatch = () => + useDebugStore.getState().applyEvent(parsed as DebugRealtimeEvent) + const wrapWithFlush = useFlushSyncRef.current + ? () => flushSync(dispatch) + : dispatch + if ( + useViewTransitionRef.current && + typeof document !== 'undefined' && + typeof (document as Document & { startViewTransition?: unknown }) + .startViewTransition === 'function' + ) { + ;( + document as Document & { + startViewTransition: (cb: () => void) => unknown + } + ).startViewTransition(wrapWithFlush) + } else { + wrapWithFlush() + } + } + } + + source.onerror = () => { + setStatus('error') + append({ receivedAt: Date.now(), type: 'close', raw: '(EventSource error/close)' }) + } + } + + open() + + return () => { + sourceRef.current?.close() + sourceRef.current = null + } + }, []) + + async function emitTestEvent() { + setStats((s) => ({ ...s, emitInFlight: true, lastEmitResult: null })) + try { + const res = await fetch('/api/debug/emit-test-notify', { method: 'POST' }) + const json = (await res.json()) as { ok?: boolean; error?: string } + setStats((s) => ({ + ...s, + emitInFlight: false, + lastEmitResult: json.ok ? 'sent ✓' : `failed: ${json.error ?? 'unknown'}`, + })) + } catch (err) { + setStats((s) => ({ + ...s, + emitInFlight: false, + lastEmitResult: `failed: ${err instanceof Error ? err.message : String(err)}`, + })) + } + } + + function reset() { + setRows([]) + setStats({ + reconnects: 0, + totalEvents: 0, + firstEventAt: null, + lastEventAt: null, + largestGapMs: 0, + emitInFlight: false, + lastEmitResult: null, + }) + lastEventTimeRef.current = null + } + + const filteredRows = useMemo(() => { + return rows.filter((row) => { + if (filterType !== 'all' && row.type !== filterType) return false + if (filterEntity !== 'all') { + const entity = (row.parsed?.entity as string | undefined) ?? null + if (filterEntity === 'debug') { + if (!row.parsed?.debug) return false + } else { + if (entity !== filterEntity) return false + } + } + return true + }) + }, [rows, filterType, filterEntity]) + + const sinceLastEvent = lastEventTimeRef.current + ? tickNow - lastEventTimeRef.current + : null + + return ( +
+ + + {dispatchToStore && } +
+ + + + + + + + + + {filteredRows.length === 0 ? ( + + + + ) : ( + filteredRows.map((row, idx) => ( + + + + + + + )) + )} + +
received_attypeentity / oppayload
+ Wachten op events… trigger een mutatie via UI / script of klik "emit test event". +
+ {new Date(row.receivedAt).toISOString()} + + {row.type} + + {row.parsed?.entity ? ( + <> + {row.parsed.entity as string} + {row.parsed.op ? ` / ${row.parsed.op as string}` : ''} + {row.parsed.debug ? ' [debug]' : ''} + + ) : ( + '—' + )} + + {row.raw} +
+
+ ) +} + +function ControlBar({ + status, + stats, + sinceLastEvent, + filterType, + setFilterType, + filterEntity, + setFilterEntity, + onEmit, + onReset, +}: { + status: 'connecting' | 'open' | 'closed' | 'error' + stats: Stats + sinceLastEvent: number | null + filterType: 'all' | RowType + setFilterType: (t: 'all' | RowType) => void + filterEntity: 'all' | 'task' | 'story' | 'debug' + setFilterEntity: (e: 'all' | 'task' | 'story' | 'debug') => void + onEmit: () => void + onReset: () => void +}) { + const heartbeatStale = sinceLastEvent !== null && sinceLastEvent > HEARTBEAT_WARN_MS + return ( +
+
+ + + + + 0 + ? `${(stats.largestGapMs / 1000).toFixed(1)}s` + : '—' + } + /> + +
+
+ + {stats.lastEmitResult && ( + {stats.lastEmitResult} + )} + + filter: + + +
+
+ ) +} + +function LayerToggles({ + dispatchToStore, + setDispatchToStore, + useFlushSync, + setUseFlushSync, + useViewTransition, + setUseViewTransition, +}: { + dispatchToStore: boolean + setDispatchToStore: (v: boolean) => void + useFlushSync: boolean + setUseFlushSync: (v: boolean) => void + useViewTransition: boolean + setUseViewTransition: (v: boolean) => void +}) { + return ( +
+ Layers: + + + +
+ ) +} + +function Stat({ label, value, color }: { label: string; value: string; color?: string }) { + return ( +
+ {label} + {value} +
+ ) +} + +function statusColor(status: 'connecting' | 'open' | 'closed' | 'error') { + switch (status) { + case 'open': + return 'green' + case 'error': + return 'red' + case 'closed': + return 'gray' + default: + return 'orange' + } +} + +function typeColor(type: RowType) { + switch (type) { + case 'message': + return '#0070cc' + case 'ready': + return 'green' + case 'error': + return 'red' + case 'open': + return '#888' + case 'close': + return '#888' + } +} + +function safeParse(raw: string): Record | undefined { + try { + return JSON.parse(raw) as Record + } catch { + return undefined + } +} diff --git a/app/debug-realtime/debug-store.ts b/app/debug-realtime/debug-store.ts new file mode 100644 index 0000000..32e3f65 --- /dev/null +++ b/app/debug-realtime/debug-store.ts @@ -0,0 +1,71 @@ +// Mini Zustand-store voor de debug-pagina. Mirrort het patroon van +// stores/solo-store.ts maar dan minimaal: alleen tasks-record + applyEvent. +// Doel: testen of de SSE-event → store → component-render keten werkt +// zonder de complexiteit van de echte Solo Paneel. + +import { create } from 'zustand' + +export interface DebugTask { + id: string + status: string + title: string + story_id: string + updated_at: string +} + +export interface DebugRealtimeEvent { + op: 'I' | 'U' | 'D' + entity: 'task' | 'story' + id: string + story_id?: string + task_status?: string + task_title?: string + debug?: boolean + emitted_at?: string + [key: string]: unknown +} + +interface DebugStore { + tasks: Record + applyCount: number + skipCount: number + applyEvent: (event: DebugRealtimeEvent) => void + reset: () => void +} + +export const useDebugStore = create((set, get) => ({ + tasks: {}, + applyCount: 0, + skipCount: 0, + + applyEvent: (event) => { + if (event.entity !== 'task') { + set((s) => ({ skipCount: s.skipCount + 1 })) + return + } + if (event.op === 'D') { + set((s) => { + const next = { ...s.tasks } + delete next[event.id] + return { tasks: next, applyCount: s.applyCount + 1 } + }) + return + } + // INSERT/UPDATE — schrijf altijd, ongeacht of de task al bestond + set((s) => ({ + tasks: { + ...s.tasks, + [event.id]: { + id: event.id, + status: event.task_status ?? get().tasks[event.id]?.status ?? '?', + title: event.task_title ?? get().tasks[event.id]?.title ?? '(no title)', + story_id: event.story_id ?? get().tasks[event.id]?.story_id ?? '?', + updated_at: new Date().toISOString(), + }, + }, + applyCount: s.applyCount + 1, + })) + }, + + reset: () => set({ tasks: {}, applyCount: 0, skipCount: 0 }), +})) diff --git a/app/debug-realtime/page.tsx b/app/debug-realtime/page.tsx new file mode 100644 index 0000000..4dc28f3 --- /dev/null +++ b/app/debug-realtime/page.tsx @@ -0,0 +1,23 @@ +// TIJDELIJKE debug-pagina voor M8-acceptance. +// Geen auth, geen styling — toont alle inkomende pg_notify-events op +// `scrum4me_changes` in een tabel zodat we kunnen zien of de SSE + LISTEN- +// pipe überhaupt events doorstroomt op Vercel. +// +// VERWIJDEREN VOOR M8 OUT-OF-DRAFT. + +import { DebugRealtimeClient } from './client' + +export const dynamic = 'force-dynamic' + +export default function DebugRealtimePage() { + return ( +
+

Realtime debug — scrum4me_changes

+

+ Live SSE-stream rechtstreeks van Postgres LISTEN op channel{' '} + scrum4me_changes. Geen auth, geen filtering. Verwijderen na M8 acceptance. +

+ +
+ ) +} diff --git a/app/debug-realtime/store-panel.tsx b/app/debug-realtime/store-panel.tsx new file mode 100644 index 0000000..35495ed --- /dev/null +++ b/app/debug-realtime/store-panel.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useDebugStore } from './debug-store' + +export function StorePanel() { + const tasks = useDebugStore((s) => s.tasks) + const applyCount = useDebugStore((s) => s.applyCount) + const skipCount = useDebugStore((s) => s.skipCount) + const reset = useDebugStore((s) => s.reset) + + const taskList = Object.values(tasks) + + return ( +
+
+ Store layer: + + applyCount: {applyCount} + + + skipCount: {skipCount} + + + taskCount: {taskList.length} + + +
+ + + + + + + + + + + + {taskList.length === 0 ? ( + + + + ) : ( + taskList.map((t) => ( + + + + + + + )) + )} + +
idstatustitleupdated_at (lokaal)
+ Store is leeg. Trigger een event en kijk of de tabel hier vult. +
+ {t.id} + + {t.status} + {t.title} + {t.updated_at} +
+
+ ) +} + +function statusColor(status: string) { + switch (status) { + case 'TO_DO': + return '#888' + case 'IN_PROGRESS': + return '#0070cc' + case 'REVIEW': + return '#cc7a00' + case 'DONE': + return 'green' + default: + return 'inherit' + } +} From 9beb831da5581cac1f0e5f0b5f240fef7dc0954a Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Thu, 30 Apr 2026 07:55:31 +0200 Subject: [PATCH 016/282] Add daily backup workflow for Neon database --- .github/workflows/neon-backup.yml | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/neon-backup.yml diff --git a/.github/workflows/neon-backup.yml b/.github/workflows/neon-backup.yml new file mode 100644 index 0000000..ee6a368 --- /dev/null +++ b/.github/workflows/neon-backup.yml @@ -0,0 +1,38 @@ +name: Daily Neon Database Backup + +on: + schedule: + # Elke nacht om 02:00 UTC = 04:00 Nederlandse zomertijd / 03:00 wintertijd + - cron: "0 2 * * *" + + # Hiermee kun je handmatig testen via GitHub + workflow_dispatch: + +jobs: + backup: + runs-on: ubuntu-latest + + steps: + - name: Install PostgreSQL client + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client + + - name: Create backup + env: + DATABASE_URL: ${{ secrets.NEON_DATABASE_URL }} + run: | + mkdir -p backups + DATE=$(date +"%Y-%m-%d_%H-%M-%S") + pg_dump "$DATABASE_URL" \ + --format=custom \ + --no-owner \ + --no-privileges \ + --file="backups/neon-backup-$DATE.dump" + + - name: Upload backup artifact + uses: actions/upload-artifact@v4 + with: + name: neon-database-backup + path: backups/*.dump + retention-days: 30 From e90383a284fa1eed5a854cc2ae978a55ca6122ca Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Thu, 30 Apr 2026 07:57:29 +0200 Subject: [PATCH 017/282] Update DATABASE_URL secret in neon-backup.yml --- .github/workflows/neon-backup.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/neon-backup.yml b/.github/workflows/neon-backup.yml index ee6a368..9120dd9 100644 --- a/.github/workflows/neon-backup.yml +++ b/.github/workflows/neon-backup.yml @@ -20,7 +20,7 @@ jobs: - name: Create backup env: - DATABASE_URL: ${{ secrets.NEON_DATABASE_URL }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} run: | mkdir -p backups DATE=$(date +"%Y-%m-%d_%H-%M-%S") From 64e3f610a6d738dbd20cf968aefa3d1d0e4a2946 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Thu, 30 Apr 2026 08:04:52 +0200 Subject: [PATCH 018/282] Update PostgreSQL client installation in backup workflow --- .github/workflows/neon-backup.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/neon-backup.yml b/.github/workflows/neon-backup.yml index 9120dd9..5806c71 100644 --- a/.github/workflows/neon-backup.yml +++ b/.github/workflows/neon-backup.yml @@ -2,10 +2,8 @@ name: Daily Neon Database Backup on: schedule: - # Elke nacht om 02:00 UTC = 04:00 Nederlandse zomertijd / 03:00 wintertijd - cron: "0 2 * * *" - # Hiermee kun je handmatig testen via GitHub workflow_dispatch: jobs: @@ -13,10 +11,15 @@ jobs: runs-on: ubuntu-latest steps: - - name: Install PostgreSQL client + - name: Install PostgreSQL 17 client run: | sudo apt-get update - sudo apt-get install -y postgresql-client + sudo apt-get install -y curl ca-certificates gnupg + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /usr/share/keyrings/postgresql.gpg + echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt noble-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list + sudo apt-get update + sudo apt-get install -y postgresql-client-17 + pg_dump --version - name: Create backup env: @@ -24,7 +27,7 @@ jobs: run: | mkdir -p backups DATE=$(date +"%Y-%m-%d_%H-%M-%S") - pg_dump "$DATABASE_URL" \ + /usr/lib/postgresql/17/bin/pg_dump "$DATABASE_URL" \ --format=custom \ --no-owner \ --no-privileges \ From 6cd98129f245d79317e9293c7cea74fe25274263 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Thu, 30 Apr 2026 16:55:20 +0200 Subject: [PATCH 019/282] M14: TaskDialog (create/edit) + story auto-promotion (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ST-1112): add deps for task dialog Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add shared zod schema for task dialog Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add missing MD3 tokens for task dialog outline-variant, on-error-container, status-review (light + dark) Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add saveTask and deleteTask server actions for TaskDialog Unified create/edit action (saveTask) replaces separate formData-based actions for the new TaskDialog. Uses shared zod schema, structured SaveTaskResult union type, and context-aware revalidatePath for both sprint and backlog routes. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add TaskDialog component (create & edit mode) Builds the full TaskDialog on top of the existing @base-ui/react Dialog primitive. Covers create mode, edit mode (status field + created_at metadata + delete), dirty-check AlertDialog, delete confirm AlertDialog, Cmd+Enter submit, and per-field char counters. Uses react-hook-form + zodResolver against the shared taskSchema. Priority and status are extracted to PrioritySegmented and StatusSelect sub-components using MD3 tokens throughout. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): refactor task-list to open TaskDialog via URL params Replaces inline create/edit forms with router.push navigation: - Clicking a task row → ?editTask= - "+ Taak" button → ?newTask=1&storyId= Removes CreateTaskForm, EditSubmitButton, updateTaskAction, and createTaskAction from the component. Status toggle and DnD remain unchanged. Rows now have cursor-pointer and keyboard a11y. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): wire TaskDialog into sprint page via searchParams Sprint page now reads ?newTask, ?storyId, and ?editTask query params. For edit mode: fetches the task server-side with productAccessFilter scope (invalid/foreign IDs redirect to closePath). Renders TaskDialog when either param is present. closePath is the sprint route without query params. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add Suspense skeleton for edit-mode task loading Extracts task fetch into EditTaskLoader (async server component) so the sprint board renders immediately while the task loads. TaskDialogSkeleton shows 3 grey bars during the fetch. Invalid or out-of-scope task IDs redirect to closePath. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): render description as markdown in task-detail-dialog Solo task detail now renders description via react-markdown + remark-gfm with prose styling. Sanitizes script/iframe elements. Co-Authored-By: Claude Sonnet 4.6 * test(ST-1112): add saveTask/deleteTask server action tests Covers all three demo-policy layers and cross-tenant scope: demo blocked (403), unauthenticated blocked, validation 422, edit cross-tenant forbidden, create cross-tenant forbidden, and happy-path for both edit and create. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): add updateTaskStatusWithStoryPromotion helper Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1112): wire story-promotion into saveTask and PATCH /api/tasks/:id Co-Authored-By: Claude Sonnet 4.6 * docs(ST-1112): add task-dialog doc and architecture note Co-Authored-By: Claude Sonnet 4.6 * chore: extend allowed tools in settings.local.json Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1113): add 200ms animation-delay to TaskDialogSkeleton to prevent flicker Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1114): add DirtyCloseGuard reusable component for dirty-form close confirmation Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1114): add shared Markdown wrapper, apply to task-detail and story-dialog Co-Authored-By: Claude Sonnet 4.6 * chore: allow grep -E pattern in settings.local.json Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .claude/settings.local.json | 19 +- CLAUDE.md | 4 + __tests__/actions/tasks-dialog.test.ts | 225 +++ __tests__/api/security.test.ts | 30 +- __tests__/api/tasks.test.ts | 64 +- __tests__/lib/tasks-status-update.test.ts | 153 ++ actions/tasks.ts | 137 +- app/(app)/products/[id]/sprint/page.tsx | 35 +- app/_components/tasks/edit-task-loader.tsx | 47 + app/_components/tasks/priority-segmented.tsx | 56 + app/_components/tasks/status-select.tsx | 55 + .../tasks/task-dialog-skeleton.tsx | 42 + app/_components/tasks/task-dialog.tsx | 424 +++++ app/api/tasks/[id]/route.ts | 31 +- app/globals.css | 1 + app/styles/theme.css | 17 + components/backlog/story-dialog.tsx | 3 +- .../entity-dialog/dirty-close-guard.tsx | 58 + components/markdown.tsx | 21 + components/solo/task-detail-dialog.tsx | 3 +- components/sprint/task-list.tsx | 187 +- docs/scrum4me-architecture.md | 2 + docs/scrum4me-task-dialog.md | 506 ++++++ lib/schemas/task.ts | 12 + lib/tasks-status-update.ts | 72 + package-lock.json | 1585 ++++++++++++++++- package.json | 6 + 27 files changed, 3665 insertions(+), 130 deletions(-) create mode 100644 __tests__/actions/tasks-dialog.test.ts create mode 100644 __tests__/lib/tasks-status-update.test.ts create mode 100644 app/_components/tasks/edit-task-loader.tsx create mode 100644 app/_components/tasks/priority-segmented.tsx create mode 100644 app/_components/tasks/status-select.tsx create mode 100644 app/_components/tasks/task-dialog-skeleton.tsx create mode 100644 app/_components/tasks/task-dialog.tsx create mode 100644 components/entity-dialog/dirty-close-guard.tsx create mode 100644 components/markdown.tsx create mode 100644 docs/scrum4me-task-dialog.md create mode 100644 lib/schemas/task.ts create mode 100644 lib/tasks-status-update.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 30d7187..dd2c1d5 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -38,7 +38,24 @@ "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp/vendor/scrum4me log --oneline -3)", "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp/vendor/scrum4me branch -a)", "Bash(git fetch *)", - "Bash(git reset *)" + "Bash(git reset *)", + "mcp__scrum4me__update_task_plan", + "mcp__scrum4me__create_task", + "mcp__scrum4me__ask_user_question", + "Bash(git *)", + "mcp__scrum4me__create_pbi", + "mcp__scrum4me__create_story", + "mcp__scrum4me__health", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers', {}\\), indent=2\\)\\)\")", + "Read(//Users/janpetervisser/.claude/**)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2\\)\\)\")", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers',{}\\), indent=2\\)\\)\")", + "Bash(python3 -m json.tool)", + "mcp__scrum4me__wait_for_job", + "Bash(npx ctx7@latest docs /websites/github_en_rest \"How to fetch Copilot bot pull request reviews and identify them by author\")", + "Bash(npm i *)", + "Bash(curl *)", + "Bash(grep -E \"\\\\.\\(tsx|ts\\)$\")" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 6693080..f13941a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,6 +289,10 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g - `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. - `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`. +**Code koppellen aan app** +- 'Pak de volgende job uit de Scrum4Me-queue' - geeft in claude_workers een record toe, tool wait_for_job + + ### Prompt - `implement_next_story` (arg: `product_id`) — end-to-end workflow diff --git a/__tests__/actions/tasks-dialog.test.ts b/__tests__/actions/tasks-dialog.test.ts new file mode 100644 index 0000000..877aac5 --- /dev/null +++ b/__tests__/actions/tasks-dialog.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + task: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findMany: vi.fn(), + }, + story: { + findFirst: vi.fn(), + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { getIronSession } from 'iron-session' +import { saveTask, deleteTask } from '@/actions/tasks' + +const mockPrisma = prisma as unknown as { + task: { + findFirst: ReturnType + create: ReturnType + update: ReturnType + delete: ReturnType + findMany: ReturnType + } + story: { + findFirst: ReturnType + findUniqueOrThrow: ReturnType + update: ReturnType + } + $transaction: ReturnType +} +const mockSession = getIronSession as ReturnType + +const VALID_INPUT = { + title: 'Test taak', + description: 'Beschrijving', + implementation_plan: 'Plan', + priority: 3, +} + +const TASK = { + id: 'task-1', + title: 'Test taak', + status: 'TO_DO', +} + +const STORY = { sprint_id: 'sprint-1' } + +beforeEach(() => { + vi.clearAllMocks() + mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + // Pass-through transaction so saveTask's $transaction wrapper executes its callback inline. + mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { + return run(prisma) + }) +}) + +// ─── saveTask ──────────────────────────────────────────────────────────────── + +describe('saveTask — demo-readonly (laag 2)', () => { + it('blokkeert demo-sessie', async () => { + mockSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) + const result = await saveTask(VALID_INPUT, { productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'demo_readonly' }) + }) +}) + +describe('saveTask — unauthenticated', () => { + it('blokkeert niet-ingelogde gebruiker', async () => { + mockSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const result = await saveTask(VALID_INPUT, { productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) + }) +}) + +describe('saveTask — validatie', () => { + it('retourneert 422 bij lege titel', async () => { + const result = await saveTask({ ...VALID_INPUT, title: '' }, { productId: 'p-1', storyId: 's-1' }) + expect(result).toMatchObject({ ok: false, code: 422, error: 'validation' }) + }) + + it('retourneert 422 bij te lange titel (>120 tekens)', async () => { + const result = await saveTask( + { ...VALID_INPUT, title: 'a'.repeat(121) }, + { productId: 'p-1', storyId: 's-1' }, + ) + expect(result).toMatchObject({ ok: false, code: 422, error: 'validation' }) + }) +}) + +describe('saveTask — edit (cross-tenant scope)', () => { + it('retourneert forbidden als task buiten scope valt', async () => { + mockPrisma.task.findFirst.mockResolvedValue(null) // out-of-scope + const result = await saveTask(VALID_INPUT, { taskId: 'task-1', productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) + }) + + it('update slaagt voor een geautoriseerde task', async () => { + mockPrisma.task.findFirst.mockResolvedValue(TASK) + mockPrisma.task.update.mockResolvedValue(TASK) + const result = await saveTask(VALID_INPUT, { taskId: 'task-1', productId: 'p-1' }) + expect(result).toMatchObject({ ok: true }) + // scope-filter is toegepast: findFirst bevat `story.product` + expect(mockPrisma.task.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'task-1', story: expect.anything() }), + }), + ) + }) +}) + +describe('saveTask — edit met status-promotie', () => { + it('promotes story naar DONE wanneer status flip naar DONE alle siblings DONE maakt', async () => { + mockPrisma.task.findFirst.mockResolvedValue({ id: 'task-1', status: 'IN_PROGRESS' }) + mockPrisma.task.update.mockResolvedValue({ + id: 'task-1', + title: 'Test taak', + status: 'IN_PROGRESS', + story_id: 'story-1', + implementation_plan: null, + }) + // Wanneer de helper draait, gebruikt-ie tx.task.update voor de status-flip. + // Dezelfde mock vangt beide updates op; tweede return-value voor de status-update. + mockPrisma.task.update.mockResolvedValueOnce({ + id: 'task-1', + title: 'Test taak', + status: 'IN_PROGRESS', + story_id: 'story-1', + implementation_plan: null, + }).mockResolvedValueOnce({ + id: 'task-1', + title: 'Test taak', + status: 'DONE', + story_id: 'story-1', + implementation_plan: null, + }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await saveTask( + { ...VALID_INPUT, status: 'DONE' }, + { taskId: 'task-1', productId: 'p-1' }, + ) + + expect(result).toMatchObject({ ok: true }) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) +}) + +describe('saveTask — create (cross-tenant scope)', () => { + it('retourneert forbidden als story buiten scope valt', async () => { + mockPrisma.story.findFirst.mockResolvedValue(null) + const result = await saveTask(VALID_INPUT, { storyId: 's-1', productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) + }) + + it('aanmaken slaagt voor een geautoriseerde story', async () => { + mockPrisma.story.findFirst.mockResolvedValue(STORY) + mockPrisma.task.findFirst.mockResolvedValue(null) // geen vorige taak + mockPrisma.task.create.mockResolvedValue(TASK) + const result = await saveTask(VALID_INPUT, { storyId: 's-1', productId: 'p-1' }) + expect(result).toMatchObject({ ok: true }) + }) +}) + +// ─── deleteTask ────────────────────────────────────────────────────────────── + +describe('deleteTask — demo-readonly (laag 2)', () => { + it('blokkeert demo-sessie', async () => { + mockSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) + const result = await deleteTask('task-1', { productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'demo_readonly' }) + }) +}) + +describe('deleteTask — unauthenticated', () => { + it('blokkeert niet-ingelogde gebruiker', async () => { + mockSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const result = await deleteTask('task-1', { productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) + }) +}) + +describe('deleteTask — cross-tenant scope', () => { + it('retourneert forbidden als task buiten scope valt', async () => { + mockPrisma.task.findFirst.mockResolvedValue(null) + const result = await deleteTask('task-1', { productId: 'p-1' }) + expect(result).toEqual({ ok: false, code: 403, error: 'forbidden' }) + }) + + it('verwijderen slaagt voor een geautoriseerde task', async () => { + mockPrisma.task.findFirst.mockResolvedValue(TASK) + mockPrisma.task.delete.mockResolvedValue(TASK) + const result = await deleteTask('task-1', { productId: 'p-1' }) + expect(result).toEqual({ ok: true }) + // scope-filter toegepast + expect(mockPrisma.task.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ id: 'task-1', story: expect.anything() }), + }), + ) + }) +}) diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 3df9d88..4d37fdd 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -11,10 +11,13 @@ vi.mock('@/lib/prisma', () => ({ }, story: { findFirst: vi.fn(), + findUniqueOrThrow: vi.fn(), + update: vi.fn(), }, task: { findFirst: vi.fn(), update: vi.fn(), + findMany: vi.fn(), }, storyLog: { create: vi.fn(), @@ -43,8 +46,16 @@ import { POST as postTodo } from '@/app/api/todos/route' const mockPrisma = prisma as unknown as { product: { findMany: ReturnType; findFirst: ReturnType } sprint: { findFirst: ReturnType } - story: { findFirst: ReturnType } - task: { findFirst: ReturnType; update: ReturnType } + story: { + findFirst: ReturnType + findUniqueOrThrow: ReturnType + update: ReturnType + } + task: { + findFirst: ReturnType + update: ReturnType + findMany: ReturnType + } storyLog: { create: ReturnType } todo: { create: ReturnType } $transaction: ReturnType @@ -85,6 +96,11 @@ function routeCtx(id: string) { beforeEach(() => { vi.clearAllMocks() + // Pass-through transaction so callers can `prisma.$transaction(async tx => ...)` in routes. + mockPrisma.$transaction.mockImplementation(async (run: unknown) => { + if (typeof run === 'function') return (run as (tx: typeof prisma) => Promise)(prisma) + return run + }) }) // ─── GET /api/products ──────────────────────────────────────────────────────── @@ -386,7 +402,15 @@ describe('PATCH /api/tasks/:id', () => { id: 'task-1', story: { product: { user_id: 'user-1' } }, }) - mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE' }) + mockPrisma.task.update.mockResolvedValue({ + id: 'task-1', + title: 'Task', + status: 'DONE', + story_id: 'story-1', + implementation_plan: null, + }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'done' }), diff --git a/__tests__/api/tasks.test.ts b/__tests__/api/tasks.test.ts index b51f55b..ed0616e 100644 --- a/__tests__/api/tasks.test.ts +++ b/__tests__/api/tasks.test.ts @@ -5,7 +5,13 @@ vi.mock('@/lib/prisma', () => ({ task: { findFirst: vi.fn(), update: vi.fn(), + findMany: vi.fn(), }, + story: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), }, })) @@ -18,7 +24,16 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' const mockPrisma = prisma as unknown as { - task: { findFirst: ReturnType; update: ReturnType } + task: { + findFirst: ReturnType + update: ReturnType + findMany: ReturnType + } + story: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + $transaction: ReturnType } const mockAuth = authenticateApiRequest as ReturnType @@ -55,6 +70,15 @@ describe('PATCH /api/tasks/:id', () => { id: 'task-1', status: 'DONE', implementation_plan: null, + title: 'Task', + story_id: 'story-1', + }) + // Default sibling state: only this task, already DONE → no story-promotion + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + // Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly. + mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { + return run(prisma) }) }) @@ -111,17 +135,28 @@ describe('PATCH /api/tasks/:id', () => { // TC-T-10 it('updates both status and implementation_plan and returns 200', async () => { const plan = 'Full plan here.' - mockPrisma.task.update.mockResolvedValue({ id: 'task-1', status: 'DONE', implementation_plan: plan }) + // First update writes the implementation_plan; second is the helper's status write. + mockPrisma.task.update + .mockResolvedValueOnce({ id: 'task-1', status: 'TO_DO', implementation_plan: plan }) + .mockResolvedValueOnce({ + id: 'task-1', + title: 'Task', + status: 'DONE', + story_id: 'story-1', + implementation_plan: plan, + }) const res = await patchTask(...makeRequest({ status: 'done', implementation_plan: plan })) const data = await res.json() expect(res.status).toBe(200) expect(data).toMatchObject({ status: 'done', implementation_plan: plan }) + // implementation_plan written via direct update; status written via helper update. expect(mockPrisma.task.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: { status: 'DONE', implementation_plan: plan }, - }) + expect.objectContaining({ data: { implementation_plan: plan } }), + ) + expect(mockPrisma.task.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { status: 'DONE' } }), ) }) @@ -146,6 +181,25 @@ describe('PATCH /api/tasks/:id', () => { expect(reviewRes.status).toBe(422) }) + it('promotes story to DONE when last sibling task transitions to DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ + id: 'task-1', + status: 'DONE', + implementation_plan: null, + title: 'Task', + story_id: 'story-1', + }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const res = await patchTask(...makeRequest({ status: 'done' })) + expect(res.status).toBe(200) + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) + it('returns 400 for malformed JSON', async () => { const req = new Request('http://localhost/api/tasks/task-1', { method: 'PATCH', diff --git a/__tests__/lib/tasks-status-update.test.ts b/__tests__/lib/tasks-status-update.test.ts new file mode 100644 index 0000000..418caa7 --- /dev/null +++ b/__tests__/lib/tasks-status-update.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + task: { + update: vi.fn(), + findMany: vi.fn(), + }, + story: { + findUniqueOrThrow: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +import { prisma } from '@/lib/prisma' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' + +const mockPrisma = prisma as unknown as { + task: { + update: ReturnType + findMany: ReturnType + } + story: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + $transaction: ReturnType +} + +beforeEach(() => { + vi.clearAllMocks() + // Pass-through: $transaction(run) just calls run with the mocked prisma client. + mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { + return run(prisma) + }) +}) + +const TASK_BASE = { + id: 'task-1', + title: 'Task', + story_id: 'story-1', + implementation_plan: null, +} + +describe('updateTaskStatusWithStoryPromotion', () => { + it('promotes story to DONE when last sibling task transitions to DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'DONE' }, + { status: 'DONE' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe('promoted') + expect(result.storyId).toBe('story-1') + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) + + it('does not promote when story is already DONE (idempotent)', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('does not promote when not all siblings are DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'DONE' }, + { status: 'IN_PROGRESS' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'IN_PROGRESS' }, + { status: 'DONE' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(result.storyStatusChange).toBe('demoted') + expect(mockPrisma.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'IN_SPRINT' }, + }) + }) + + it('does not demote when story is not DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('updates the task regardless of story-status change', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') + + expect(mockPrisma.task.update).toHaveBeenCalledWith({ + where: { id: 'task-1' }, + data: { status: 'IN_PROGRESS' }, + select: expect.any(Object), + }) + }) + + it('uses the provided transaction client when passed', async () => { + const tx = { + task: { update: vi.fn(), findMany: vi.fn() }, + story: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, + } + tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + tx.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any) + + expect(result.storyStatusChange).toBe('promoted') + // $transaction should NOT be called when caller already provides a tx. + expect(mockPrisma.$transaction).not.toHaveBeenCalled() + expect(tx.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) + }) +}) diff --git a/actions/tasks.ts b/actions/tasks.ts index f70c452..d3cc5c6 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -8,11 +8,146 @@ import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' +import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' async function getSession() { return getIronSession(await cookies(), sessionOptions) } +// Return types for TaskDialog actions +export type SaveTaskResult = + | { ok: true; task: { id: string; title: string; status: string } } + | { ok: false; code: 422; error: 'validation'; fieldErrors: Record } + | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } + | { ok: false; code: 500; error: 'server_error' } + +export type DeleteTaskResult = + | { ok: true } + | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } + | { ok: false; code: 500; error: 'server_error' } + +// Unified create/edit action used by TaskDialog. +// context.taskId present → edit; context.storyId present → create. +export async function saveTask( + input: TaskInput, + context: { taskId?: string; storyId?: string; productId: string }, +): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } + if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } + + const parsed = sharedTaskSchema.safeParse(input) + if (!parsed.success) { + return { + ok: false, + code: 422, + error: 'validation', + fieldErrors: parsed.error.flatten().fieldErrors as Record, + } + } + + const { title, description, implementation_plan, priority, status } = parsed.data + const scope = productAccessFilter(session.userId) + + try { + if (context.taskId) { + const existing = await prisma.task.findFirst({ + where: { id: context.taskId, story: { product: scope } }, + select: { id: true, status: true }, + }) + if (!existing) return { ok: false, code: 403, error: 'forbidden' } + + const taskId = context.taskId + const statusChanged = status !== undefined && status !== existing.status + + const task = await prisma.$transaction(async (tx) => { + const updated = await tx.task.update({ + where: { id: taskId }, + data: { + title, + description: description ?? null, + implementation_plan: implementation_plan ?? null, + priority, + }, + select: { id: true, title: true, status: true }, + }) + + if (statusChanged) { + const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx) + return { id: result.task.id, title: result.task.title, status: result.task.status } + } + return updated + }) + + revalidatePath(`/products/${context.productId}/sprint`) + revalidatePath(`/products/${context.productId}`) + return { ok: true, task: { ...task, status: task.status.toString() } } + } + + if (!context.storyId) { + return { ok: false, code: 422, error: 'validation', fieldErrors: { storyId: ['Verplicht'] } } + } + + const story = await prisma.story.findFirst({ + where: { id: context.storyId, product: scope }, + select: { sprint_id: true }, + }) + if (!story) return { ok: false, code: 403, error: 'forbidden' } + + const last = await prisma.task.findFirst({ + where: { story_id: context.storyId }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + + const task = await prisma.task.create({ + data: { + story_id: context.storyId, + sprint_id: story.sprint_id ?? null, + title, + description: description ?? null, + implementation_plan: implementation_plan ?? null, + priority, + sort_order: (last?.sort_order ?? 0) + 1.0, + status: 'TO_DO', + }, + select: { id: true, title: true, status: true }, + }) + + revalidatePath(`/products/${context.productId}/sprint`) + revalidatePath(`/products/${context.productId}`) + return { ok: true, task: { ...task, status: task.status.toString() } } + } catch { + return { ok: false, code: 500, error: 'server_error' } + } +} + +// Delete action used by TaskDialog (context-aware revalidation). +export async function deleteTask( + taskId: string, + context: { productId: string }, +): Promise { + const session = await getSession() + if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } + if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } + + try { + const task = await prisma.task.findFirst({ + where: { id: taskId, story: { product: productAccessFilter(session.userId) } }, + }) + if (!task) return { ok: false, code: 403, error: 'forbidden' } + + await prisma.task.delete({ where: { id: taskId } }) + + revalidatePath(`/products/${context.productId}/sprint`) + revalidatePath(`/products/${context.productId}`) + return { ok: true } + } catch { + return { ok: false, code: 500, error: 'server_error' } + } +} + const taskSchema = z.object({ title: z.string().min(1, 'Titel is verplicht').max(200), description: z.string().max(1000).optional(), @@ -99,7 +234,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P }) if (!task) return { error: 'Taak niet gevonden' } - await prisma.task.update({ where: { id }, data: { status } }) + await updateTaskStatusWithStoryPromotion(id, status) // /solo bewust niet revalideren: dat zou de page soft-navigaten en de // open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 0365842..3b16d5f 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' @@ -6,14 +7,24 @@ import { SprintBoardClient } from '@/components/sprint/sprint-board-client' import { SprintHeader } from '@/components/sprint/sprint-header' import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' import type { Task } from '@/components/sprint/task-list' +import { TaskDialog } from '@/app/_components/tasks/task-dialog' +import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' +import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' import Link from 'next/link' interface Props { params: Promise<{ id: string }> + searchParams: Promise<{ + newTask?: string + storyId?: string + editTask?: string + }> } -export default async function SprintBoardPage({ params }: Props) { +export default async function SprintBoardPage({ params, searchParams }: Props) { const { id } = await params + const { newTask, storyId: storyIdParam, editTask } = await searchParams + const session = await getSession() if (!session.userId) redirect('/login') @@ -104,6 +115,7 @@ export default async function SprintBoardPage({ params }: Props) { const sprintStoryIdList = sprintStories.map(s => s.id) const isDemo = session.isDemo ?? false + const closePath = `/products/${id}/sprint` return (
@@ -134,6 +146,27 @@ export default async function SprintBoardPage({ params }: Props) { ← Product Backlog
+ + {newTask && ( + + )} + + {editTask && !newTask && ( + }> + + + )} ) } diff --git a/app/_components/tasks/edit-task-loader.tsx b/app/_components/tasks/edit-task-loader.tsx new file mode 100644 index 0000000..f66cce9 --- /dev/null +++ b/app/_components/tasks/edit-task-loader.tsx @@ -0,0 +1,47 @@ +import { redirect } from 'next/navigation' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { TaskDialog } from './task-dialog' + +interface EditTaskLoaderProps { + taskId: string + userId: string + productId: string + closePath: string + isDemo: boolean +} + +export async function EditTaskLoader({ + taskId, + userId, + productId, + closePath, + isDemo, +}: EditTaskLoaderProps) { + const task = await prisma.task.findFirst({ + where: { + id: taskId, + story: { product: productAccessFilter(userId) }, + }, + select: { + id: true, + title: true, + description: true, + implementation_plan: true, + priority: true, + status: true, + created_at: true, + }, + }) + + if (!task) redirect(closePath) + + return ( + + ) +} diff --git a/app/_components/tasks/priority-segmented.tsx b/app/_components/tasks/priority-segmented.tsx new file mode 100644 index 0000000..4888e59 --- /dev/null +++ b/app/_components/tasks/priority-segmented.tsx @@ -0,0 +1,56 @@ +'use client' + +import { cn } from '@/lib/utils' + +const PRIORITIES = [ + { + value: 1, + label: 'P1 Critical', + selected: 'bg-error-container text-on-error-container border-transparent', + }, + { + value: 2, + label: 'P2 High', + selected: 'bg-priority-high/15 text-priority-high border-priority-high/30', + }, + { + value: 3, + label: 'P3 Medium', + selected: 'bg-primary text-primary-foreground border-transparent', + }, + { + value: 4, + label: 'P4 Low', + selected: 'bg-muted text-foreground border-border', + }, +] + +interface PrioritySegmentedProps { + value: number + onChange: (value: number) => void + disabled?: boolean +} + +export function PrioritySegmented({ value, onChange, disabled }: PrioritySegmentedProps) { + return ( +
+ {PRIORITIES.map(p => ( + + ))} +
+ ) +} diff --git a/app/_components/tasks/status-select.tsx b/app/_components/tasks/status-select.tsx new file mode 100644 index 0000000..5ba794d --- /dev/null +++ b/app/_components/tasks/status-select.tsx @@ -0,0 +1,55 @@ +'use client' + +import type { TaskStatus } from '@prisma/client' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/select' +import { cn } from '@/lib/utils' + +const STATUS_CONFIG: Record = { + TO_DO: { label: 'To Do', dot: 'bg-muted-foreground' }, + IN_PROGRESS: { label: 'Bezig', dot: 'bg-status-in-progress' }, + REVIEW: { label: 'Review', dot: 'bg-status-review' }, + DONE: { label: 'Klaar', dot: 'bg-status-done' }, +} + +const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE'] + +function StatusIndicator({ status }: { status: TaskStatus }) { + return ( + + + {STATUS_CONFIG[status].label} + + ) +} + +interface StatusSelectProps { + value?: TaskStatus + onChange: (value: TaskStatus) => void + disabled?: boolean +} + +export function StatusSelect({ value = 'TO_DO', onChange, disabled }: StatusSelectProps) { + return ( + + ) +} diff --git a/app/_components/tasks/task-dialog-skeleton.tsx b/app/_components/tasks/task-dialog-skeleton.tsx new file mode 100644 index 0000000..bb6a66b --- /dev/null +++ b/app/_components/tasks/task-dialog-skeleton.tsx @@ -0,0 +1,42 @@ +import { Skeleton } from '@/components/ui/skeleton' +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { cn } from '@/lib/utils' + +export function TaskDialogSkeleton() { + return ( + + + Taak laden… + + {/* Header */} +
+ +
+ + {/* Body — 3 bars mimicking title + description + plan */} +
+ + + +
+ + {/* Footer */} +
+
+ + +
+
+
+
+ ) +} diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx new file mode 100644 index 0000000..2426dc1 --- /dev/null +++ b/app/_components/tasks/task-dialog.tsx @@ -0,0 +1,424 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import TextareaAutosize from 'react-textarea-autosize' +import { toast } from 'sonner' +import { Loader2 } from 'lucide-react' +import type { TaskStatus } from '@prisma/client' +import { taskSchema, type TaskInput } from '@/lib/schemas/task' +import { saveTask, deleteTask } from '@/actions/tasks' +import { + Dialog, + DialogContent, + DialogTitle, +} from '@/components/ui/dialog' +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { PrioritySegmented } from './priority-segmented' +import { StatusSelect } from './status-select' +import { cn } from '@/lib/utils' + +export interface TaskDialogTask { + id: string + title: string + description: string | null + implementation_plan: string | null + priority: number + status: TaskStatus + created_at: Date +} + +interface TaskDialogProps { + task?: TaskDialogTask + storyId?: string + productId: string + closePath: string + isDemo?: boolean +} + +function CharCount({ value, max }: { value: string; max: number }) { + const len = (value ?? '').length + if (len < Math.floor(max * 0.75)) return null + return ( + + {len} / {max} + + ) +} + +const textareaClass = cn( + 'flex w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm', + 'transition-colors outline-none placeholder:text-muted-foreground resize-none', + 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50', + 'overflow-y-auto', +) + +export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + const [confirmClose, setConfirmClose] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + const isEdit = !!task + + const form = useForm({ + resolver: zodResolver(taskSchema), + mode: 'onTouched', + defaultValues: { + title: task?.title ?? '', + description: task?.description ?? '', + implementation_plan: task?.implementation_plan ?? '', + priority: task?.priority ?? 3, + status: task?.status, + }, + }) + + function handleClose() { + router.push(closePath) + } + + function handleAttemptClose() { + if (form.formState.isDirty) { + setConfirmClose(true) + } else { + handleClose() + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault() + form.handleSubmit(onSubmit)() + } + } + + function onSubmit(data: TaskInput) { + startTransition(async () => { + const result = await saveTask(data, { + taskId: task?.id, + storyId, + productId, + }) + + if (result.ok) { + toast.success(isEdit ? 'Taak opgeslagen' : 'Taak aangemaakt') + router.push(closePath) + return + } + + if (result.code === 422 && result.error === 'validation') { + for (const [field, errors] of Object.entries(result.fieldErrors)) { + form.setError(field as keyof TaskInput, { message: errors[0] }) + } + const firstError = Object.keys(result.fieldErrors)[0] as keyof TaskInput + form.setFocus(firstError) + return + } + + if (result.code === 403) { + toast.error( + result.error === 'demo_readonly' + ? 'Demo-modus: opslaan uitgeschakeld' + : 'Geen toegang', + ) + return + } + + toast.error('Er ging iets mis. Probeer het opnieuw.', { + action: { label: 'Opnieuw', onClick: () => form.handleSubmit(onSubmit)() }, + }) + }) + } + + function handleDelete() { + if (!task) return + setConfirmDelete(false) + startTransition(async () => { + const result = await deleteTask(task.id, { productId }) + if (result.ok) { + toast.success('Taak verwijderd') + router.push(closePath) + return + } + if (result.code === 403) { + toast.error( + result.error === 'demo_readonly' + ? 'Demo-modus: verwijderen uitgeschakeld' + : 'Geen toegang', + ) + return + } + toast.error('Verwijderen mislukt') + }) + } + + return ( + <> + { if (!open) handleAttemptClose() }}> + + {/* Sticky header */} +
+ + {isEdit ? 'Taak bewerken' : 'Nieuwe taak'} + + {isEdit && ( + + Aangemaakt:{' '} + {new Intl.DateTimeFormat('nl-NL', { + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(new Date(task.created_at))} + + )} +
+ + {/* Scrollable form body */} +
+ {/* Title */} +
+ + { if (e.key === 'Enter') e.preventDefault() }} + /> + {form.formState.errors.title && ( +

+ {form.formState.errors.title.message} +

+ )} +
+ + {/* Description */} +
+ + ( + <> + + +

+ Markdown ondersteund (lijstjes, **vet**, `code`) +

+ + )} + /> + {form.formState.errors.description && ( +

+ {form.formState.errors.description.message} +

+ )} +
+ + {/* Implementation plan */} +
+ + ( + <> + + +

+ Markdown ondersteund (lijstjes, **vet**, `code`) +

+ + )} + /> + {form.formState.errors.implementation_plan && ( +

+ {form.formState.errors.implementation_plan.message} +

+ )} +
+ + {/* Priority */} +
+ + ( + + )} + /> +
+ + {/* Status — edit only */} + {isEdit && ( +
+ + ( + + )} + /> +
+ )} +
+ + {/* Sticky footer */} +
+
+ {isEdit ? ( + + + + ) : ( +
+ )} + +
+ + + + +
+
+
+ +
+ + {/* Dirty-check confirm */} + + + + Wijzigingen niet opgeslagen + + Wil je de wijzigingen weggooien? + + + + setConfirmClose(false)}> + Terug + + { setConfirmClose(false); handleClose() }} + > + Weggooien + + + + + + {/* Delete confirm */} + + + + Taak verwijderen + + Weet je zeker dat je deze taak wilt verwijderen? Dit kan niet ongedaan worden gemaakt. + + + + setConfirmDelete(false)}> + Annuleren + + + {isPending ? : 'Verwijderen'} + + + + + + ) +} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index c183ed2..ef17ccc 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -2,6 +2,7 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' import { z } from 'zod' import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' // `review` is a valid TaskStatus in the DB and the kanban-board UI, but the // sprint task list (components/sprint/task-list.tsx) does not yet render it. @@ -82,14 +83,28 @@ export async function PATCH( } } - const updated = await prisma.task.update({ - where: { id }, - data: { - ...(dbStatus !== undefined && dbStatus !== null && { status: dbStatus }), - ...(parsed.data.implementation_plan !== undefined && { - implementation_plan: parsed.data.implementation_plan, - }), - }, + const updated = await prisma.$transaction(async (tx) => { + const planUpdate = parsed.data.implementation_plan !== undefined + ? await tx.task.update({ + where: { id }, + data: { implementation_plan: parsed.data.implementation_plan }, + select: { id: true, status: true, implementation_plan: true }, + }) + : null + + if (dbStatus !== undefined && dbStatus !== null) { + const result = await updateTaskStatusWithStoryPromotion(id, dbStatus, tx) + return { + id: result.task.id, + status: result.task.status, + implementation_plan: result.task.implementation_plan, + } + } + + if (planUpdate) return planUpdate + + // Should not reach here — patchSchema rejects bodies without status or implementation_plan. + throw new Error('Geen wijzigingen') }) return Response.json({ diff --git a/app/globals.css b/app/globals.css index 965ae89..9e6d7af 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,5 @@ @import "tailwindcss"; @import "tw-animate-css"; +@plugin "@tailwindcss/typography"; @import "./styles/theme.css"; diff --git a/app/styles/theme.css b/app/styles/theme.css index 6b14556..071598a 100644 --- a/app/styles/theme.css +++ b/app/styles/theme.css @@ -73,9 +73,16 @@ --switch-background: #79767d; --ring: var(--primary); + /* MD3 Outline Variant */ + --outline-variant: #c5c6d0; + + /* MD3 On-Error-Container */ + --on-error-container: #410002; + /* Project Management Specific Colors */ --status-todo: #6750a4; --status-in-progress: #0061a4; + --status-review: #7b5ea7; --status-done: #006e1c; --status-blocked: #ba1a1a; @@ -177,9 +184,16 @@ --switch-background: #898790; --ring: var(--primary); + /* MD3 Outline Variant */ + --outline-variant: #45464f; + + /* MD3 On-Error-Container */ + --on-error-container: #ffdad6; + /* Project Management Specific Colors */ --status-todo: #cfbdfe; --status-in-progress: #9fcbfa; + --status-review: #c9b6ef; --status-done: #77db77; --status-blocked: #ffb4ab; @@ -256,6 +270,7 @@ --color-error-foreground: var(--error-foreground); --color-error-container: var(--error-container); --color-error-container-foreground: var(--error-container-foreground); + --color-on-error-container: var(--on-error-container); --color-info: var(--info); --color-info-foreground: var(--info-foreground); @@ -273,6 +288,7 @@ --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); + --color-outline-variant: var(--outline-variant); --color-border: var(--border); --color-input: var(--input); --color-input-background: var(--input-background); @@ -282,6 +298,7 @@ /* Project management colors */ --color-status-todo: var(--status-todo); --color-status-in-progress: var(--status-in-progress); + --color-status-review: var(--status-review); --color-status-done: var(--status-done); --color-status-blocked: var(--status-blocked); diff --git a/components/backlog/story-dialog.tsx b/components/backlog/story-dialog.tsx index dbaac20..724f430 100644 --- a/components/backlog/story-dialog.tsx +++ b/components/backlog/story-dialog.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useRef, useState, useTransition } from 'react' +import { Markdown } from '@/components/markdown' import { useActionState } from 'react' import { useFormStatus } from 'react-dom' import { toast } from 'sonner' @@ -231,7 +232,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps {story?.description && (

Omschrijving

-

{story.description}

+ {story.description}
)} {story?.acceptance_criteria && ( diff --git a/components/entity-dialog/dirty-close-guard.tsx b/components/entity-dialog/dirty-close-guard.tsx new file mode 100644 index 0000000..eedd362 --- /dev/null +++ b/components/entity-dialog/dirty-close-guard.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useState } from 'react' +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel, +} from '@/components/ui/alert-dialog' + +interface DirtyCloseGuardProps { + isDirty: boolean + onConfirm: () => void + children: (attemptClose: () => void) => React.ReactNode +} + +export function DirtyCloseGuard({ isDirty, onConfirm, children }: DirtyCloseGuardProps) { + const [open, setOpen] = useState(false) + + function attemptClose() { + if (isDirty) { + setOpen(true) + } else { + onConfirm() + } + } + + return ( + <> + {children(attemptClose)} + + + + Wijzigingen niet opgeslagen + + Wil je de wijzigingen weggooien? + + + + setOpen(false)}> + Blijven + + { setOpen(false); onConfirm() }} + > + Weggooien + + + + + + ) +} diff --git a/components/markdown.tsx b/components/markdown.tsx new file mode 100644 index 0000000..2b07fa8 --- /dev/null +++ b/components/markdown.tsx @@ -0,0 +1,21 @@ +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { cn } from '@/lib/utils' + +interface MarkdownProps { + children: string + className?: string +} + +export function Markdown({ children, className }: MarkdownProps) { + return ( +
+ + {children} + +
+ ) +} diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index d9b6db1..5757393 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -3,6 +3,7 @@ import { useRef, useState, useTransition } from 'react' import Link from 'next/link' import { toast } from 'sonner' +import { Markdown } from '@/components/markdown' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -132,7 +133,7 @@ function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailConte {task.description && (

Beschrijving

-

{task.description}

+ {task.description}
)} diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx index 99650c3..028e449 100644 --- a/components/sprint/task-list.tsx +++ b/components/sprint/task-list.tsx @@ -1,7 +1,7 @@ 'use client' -import { useState, useTransition, useEffect, useActionState } from 'react' -import { useFormStatus } from 'react-dom' +import { useState, useTransition, useEffect } from 'react' +import { useRouter, usePathname } from 'next/navigation' import { DndContext, DragEndEvent, DragOverlay, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, @@ -13,17 +13,13 @@ import { import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' import { deriveTaskCode } from '@/lib/code' import { useSprintStore } from '@/stores/sprint-store' -import { - createTaskAction, updateTaskStatusAction, updateTaskAction, - deleteTaskAction, reorderTasksAction, -} from '@/actions/tasks' +import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { cn } from '@/lib/utils' @@ -60,69 +56,69 @@ interface TaskListProps { } function SortableTaskRow({ - task, code, isDemo, onStatusToggle, onDelete, -}: { task: Task; code: string | null; isDemo: boolean; onStatusToggle: () => void; onDelete: () => void }) { - const [editing, setEditing] = useState(false) + task, code, isDemo, onStatusToggle, onEdit, +}: { + task: Task + code: string | null + isDemo: boolean + onStatusToggle: () => void + onEdit: () => void +}) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id }) const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 } - const [, formAction] = useActionState( - async (_prev: unknown, fd: FormData) => { - const result = await updateTaskAction(_prev, fd) - if (result?.success) setEditing(false) - return result - }, - undefined - ) - - if (editing) { - return ( -
-
-
- - - -
- - -
-
-
-
- ) - } - return (
-
+
onEdit()} + role="button" + tabIndex={0} + aria-label={`Bewerk taak: ${task.title}`} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onEdit() + } + }} + > {!isDemo && ( - + e.stopPropagation()} + className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5" + aria-hidden="true" + > + ⠿ + )}
-

+

{task.title}

{code && }
-
- -
- - - - - - -
@@ -130,48 +126,12 @@ function SortableTaskRow({ ) } -function EditSubmitButton() { - const { pending } = useFormStatus() - return -} - -function CreateTaskForm({ storyId, sprintId, onDone }: { storyId: string; sprintId: string; onDone: () => void }) { - const [state, formAction] = useActionState( - async (_prev: unknown, fd: FormData) => { - const result = await createTaskAction(_prev, fd) - if (result?.success) { onDone(); return result } - if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Aanmaken mislukt') - return result - }, - undefined - ) - return ( -
- - - -
- - - -
- {state && 'error' in state && typeof state.error === 'string' && ( -

{state.error}

- )} -
- ) -} - -function CreateSubmitButton() { - const { pending } = useFormStatus() - return -} - -export function TaskList({ storyId, storyCode, sprintId, productId: _productId, tasks, isDemo }: TaskListProps) { +export function TaskList({ storyId, storyCode, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) { const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore() - const [creating, setCreating] = useState(false) const [activeDragId, setActiveDragId] = useState(null) const [, startTransition] = useTransition() + const router = useRouter() + const pathname = usePathname() const idKey = tasks.map(t => t.id).join(',') useEffect(() => { @@ -187,7 +147,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ) function handleDragEnd(event: DragEndEvent) { @@ -209,11 +169,12 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, }) } - function handleDelete(id: string) { - startTransition(async () => { - const result = await deleteTaskAction(id) - if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt') - }) + function openCreateDialog() { + router.push(`${pathname}?newTask=1&storyId=${storyId}`) + } + + function openEditDialog(taskId: string) { + router.push(`${pathname}?editTask=${taskId}`) } return ( @@ -224,22 +185,32 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, <> {doneCount}/{orderedTasks.length} klaar - + } />
- {creating && ( - setCreating(false)} /> - )} - - {orderedTasks.length === 0 && !creating ? ( + {orderedTasks.length === 0 ? (

Geen taken voor deze story.

- +
) : ( @@ -258,7 +229,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, code={deriveTaskCode(storyCode, idx + 1)} isDemo={isDemo} onStatusToggle={() => handleStatusToggle(task)} - onDelete={() => handleDelete(task.id)} + onEdit={() => openEditDialog(task.id)} /> ))} @@ -266,7 +237,7 @@ export function TaskList({ storyId, storyCode, sprintId, productId: _productId, {activeDragId && taskMap[activeDragId] && (
{taskMap[activeDragId].title}
diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md index bc244f5..5197f99 100644 --- a/docs/scrum4me-architecture.md +++ b/docs/scrum4me-architecture.md @@ -157,6 +157,8 @@ Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt ger **Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)` +**Auto-promotie/demotie via task-status:** zodra alle tasks van een story op `DONE` staan en de huidige story-status nog niet `DONE` is, promoot dezelfde transactie de story naar `DONE`. Wordt een task van een `DONE`-story uit `DONE` getrokken (heropening), dan demoot de story terug naar `IN_SPRINT` — niet naar `OPEN`, want `OPEN` betekent "terug in productbacklog" en is een sprint-management-actie. De logica zit in [lib/tasks-status-update.ts](../lib/tasks-status-update.ts) en wordt aangeroepen door alle drie de task-status-write-paden (`updateTaskStatusAction`, `saveTask` edit-mode, REST `PATCH /api/tasks/[id]`). + --- ### `story_logs` diff --git a/docs/scrum4me-task-dialog.md b/docs/scrum4me-task-dialog.md new file mode 100644 index 0000000..fa676bd --- /dev/null +++ b/docs/scrum4me-task-dialog.md @@ -0,0 +1,506 @@ +# Scrum4Me — TaskDialog Spec + +> Volledige design-spec voor de add/update task dialog van de inspannings monitor app. +> Resultaat van een grill-me sessie (15 vragen, alle beslissingen vastgelegd). + +--- + +## Stack + +- **Framework:** Next.js (App Router) +- **ORM:** Prisma +- **UI components:** shadcn/ui — wrappers rond `@base-ui/react` (zoals expliciet vastgelegd in `CLAUDE.md`) +- **Styling:** Tailwind CSS +- **Form:** react-hook-form + @hookform/resolvers/zod +- **Design language:** Material Design 3 als theming-laag (geen MUI components) +- **Theming:** `material-color-utilities` voor dynamic color, `next-themes` voor dark mode +- **Icons:** Lucide +- **Markdown rendering:** `react-markdown` + `remark-gfm` +- **Toasts:** sonner (shadcn default) + +> **Composition-regel:** dit project gebruikt `@base-ui/react`, niet Radix. Composition gebeurt via de **`render`-prop**, niet via `asChild`. Zie ook `CLAUDE.md` "UI Library Conventions". +> +> ```tsx +> // ✅ goed +> }>... +> // ❌ fout — geeft TS-errors +> +> ``` + +> **Dialog-primitive:** bouw de TaskDialog op de bestaande wrapper in `components/ui/dialog.tsx` (shadcn rond `@base-ui/react`). **Geen** directe imports uit `@base-ui/react` voor dialog-primitives in deze feature — anders krijg je twee parallelle dialog-implementaties die uit de pas gaan lopen qua animatie, focus-trap en theming. + +--- + +## Dependency-impact + +De volgende packages staan **nog niet** in `package.json` en moeten direct als runtime-`dependencies` worden toegevoegd voordat de eerste commit van deze feature gemerged wordt (CLAUDE.md "Dependencies"-regel). Voeg ze in dezelfde change toe waarin ze geïmporteerd worden, en vermeld ze in de docs-sync. + +| Package | Doel | Scope | +|---|---|---| +| `react-hook-form` | form-state management voor TaskDialog | runtime | +| `@hookform/resolvers` | zod-resolver voor `react-hook-form` | runtime | +| `react-textarea-autosize` | auto-grow textareas voor `description` / `implementation_plan` | runtime | +| `react-markdown` | markdown rendering elders in de app (taakdetail, hover-card) | runtime | +| `remark-gfm` | GFM-extensies (tabellen, taken, strikethrough) | runtime | +| `@tailwindcss/typography` | `prose`-classes voor markdown-styling | runtime (Tailwind v4 plugin) | + +**Bewust niet meegenomen:** + +- `material-color-utilities` — dynamic color valt buiten v1 (zie Theming hieronder). +- `nuqs` — start met **native `searchParams`**; als de URL-state-handling te omslachtig wordt, dan pas `nuqs` als losse refactor-task introduceren. Niet in deze feature mengen. + +Reeds aanwezig en gebruikt: `@base-ui/react`, `next-themes`, `lucide-react`, `sonner`, `zod`, `prisma`. + +--- + +## Component-API + +Eén component `TaskDialog`, mode afgeleid uit `task?: Task` prop: + +```tsx + // task undefined = create mode, task aanwezig = edit mode +``` + +Open/close-state komt uit de URL via `nuqs` of `searchParams`. Taken leven binnen de context van een sprint of een PBI/story — er is **geen** zelfstandige `/tasks`-route: + +``` +/sprint/?newTask=1 → create-dialog open binnen sprint-context +/sprint/?editTask= → edit-dialog open binnen sprint-context +/products//backlog?newTask=1 → create-dialog open binnen backlog-context +/products//backlog?editTask= +``` + +Dialog sluit door dezelfde route opnieuw te pushen zonder de `newTask` / `editTask` query-params (bv. `router.push(\`/sprint/\${sprintId}\`)`). + +--- + +## Velden die de dialog gebruikt + +De dialog leest en schrijft uitsluitend deze velden van het `Task`-record. Het volledige datamodel valt buiten scope van deze spec. + +| Veld | Type | Mode | +|---|---|---| +| `title` | `string` (required) | beide | +| `description` | `string \| null` | beide | +| `implementation_plan` | `string \| null` | beide | +| `priority` | `int` (1-4, P1 = hoogste) | beide | +| `status` | `TaskStatus` enum | alleen edit (default `TO_DO` op create, niet getoond) | +| `created_at` | `Date` | alleen edit, read-only metadata in header | + +`TaskStatus` enum-waarden: `TO_DO | IN_PROGRESS | REVIEW | DONE`. + +--- + +## Layout & responsive gedrag + +| Breakpoint | Breedte | Hoogte | +|---|---|---| +| Mobiel (<640px) | full-screen | full-screen | +| Tablet (640-1024px) | `90vw` | `max-h-[85vh]` | +| Desktop (≥1024px) | `max-w-[50vw]`, `min-w-[480px]` | `max-h-[85vh]` | + +- Padding: `p-6` rondom +- Veld-spacing binnen blok: `space-y-6` (24px) +- Sticky header (titel + close) en sticky footer (knoppen) +- Body scrollt als content de `max-h` overschrijdt +- Footer heeft top-border in `outline-variant` kleur + +--- + +## Velden + +In volgorde van boven naar beneden: + +| Veld | Control | Mode | Validatie | +|---|---|---|---| +| `title` | `Input` (single-line) | beide | required, trim, 1-120 chars | +| `description` | `Textarea` (auto-grow, 3-6 regels) | beide | optional, max 2.000 chars, markdown | +| `implementation_plan` | `Textarea` (auto-grow, 5-12 regels) | beide | optional, max 10.000 chars, markdown | +| `priority` | Segmented buttons (P1/P2/P3/P4) | beide | int 1-4, default 3 | +| `status` | `Select` met gekleurde dot | alleen edit | enum, default TO_DO | + +Verberg `status` in create-mode (default = TO_DO is genoeg). + +### Auto-grow textareas +Gebruik `react-textarea-autosize`. Bereikt het veld zijn max-regels, dan `overflow-y-auto` (interne scroll). De **dialog-body** scrollt onafhankelijk; je krijgt zelden geneste scrolls. + +### Karakter-counter +Alleen tonen vanaf 75% van de limiet. Klein, rechtsonder in het veld, `muted-foreground` kleur. Bv. `1547 / 2000`. + +### Markdown hint +Onder elk textarea: `Markdown ondersteund (lijstjes, **vet**, \`code\`)` — klein, muted. + +### Priority segmented buttons +``` +[ P1 Critical ] [ P2 High ] [ P3 Medium ] [ P4 Low ] + error tertiary primary outline +``` +- Lager getal = hoger prio (industriestandaard, Linear/Jira-conform) +- Default geselecteerd: P3 Medium +- Geen 0-waarde toestaan + +### Status select (alleen edit) +- TO_DO — grijze dot +- IN_PROGRESS — blauwe dot +- REVIEW — paarse dot +- DONE — groene dot + +### `created_at` als header-metadata +In edit-mode tonen in de dialog-header naast de titel: + +``` +Taak bewerken Aangemaakt: 23 apr 2026 +``` + +Klein, `muted-foreground`, niet als form-veld. + +--- + +## Validatie + +- **Gedeeld zod-schema** in `lib/schemas/task.ts`, geïmporteerd door zowel form als server action +- react-hook-form mode: `onTouched` (eerste validatie bij blur, daarna onChange) +- Errors onder het veld, in error-color, met label en outline van het veld in dezelfde kleur +- Geen toasts voor field-level errors +- Submit-button blijft enabled bij errors — klik scrollt naar eerste error-veld + focus + +```ts +// lib/schemas/task.ts (richtlijn) +export const taskSchema = z.object({ + title: z.string().trim().min(1, "Verplicht").max(120), + description: z.string().max(2000).optional(), + implementation_plan: z.string().max(10000).optional(), + priority: z.number().int().min(1).max(4), + status: z.nativeEnum(TaskStatus).optional(), // alleen in edit +}); +``` + +--- + +## Submission + +### Auth-scoping (verplicht) + +Elke server action — zowel `saveTask` als `deleteTask` — moet de operatie scope-en op de huidige user. Cross-tenant writes voorkomen via `productAccessFilter(userId)` (of het project-equivalent), zodat een user geen task kan schrijven of verwijderen die niet onder zijn product-scope valt. + +> Concreet: de Prisma-mutatie staat nóóit alleen op `where: { id: taskId }`. De scope wordt verplicht gecombineerd in elke `update`/`delete`/`create`-call. + +### Demo read-only enforcement (drie lagen — ST-1110) + +Elke write-flow moet door deze drie lagen: + +1. **Middleware-guard in `proxy.ts`** — blokkeert demo-sessies op write-routes vóór de server action überhaupt loopt. Returnt **403**. +2. **`session.isDemo`-check in de server action zelf** — defense-in-depth voor het geval een write-flow buiten een proxy-route loopt (bv. directe action-invocation). Returnt **403**. +3. **`` op de save- en delete-knoppen** — UI-laag: knoppen zijn zichtbaar disabled met tooltip "Demo-modus: opslaan uitgeschakeld". Vermijdt onnodige round-trips. + +### Server Action + +```ts +// app/actions/tasks.ts +"use server" + +export async function saveTask( + input: TaskInput, + context: { sprintId?: string; productId?: string }, // voor revalidatePath en scope +): Promise { + const session = await getSession(); + if (session.isDemo) return { ok: false, code: 403, error: "demo_readonly" }; + + const scope = await productAccessFilter(session.userId); // verplicht + // ... validate met taskSchema → Prisma write binnen `scope` +} + +type SaveTaskResult = + | { ok: true; task: Task } + | { ok: false; code: 422; error: "validation"; fieldErrors: Record } + | { ok: false; code: 403; error: "demo_readonly" | "forbidden" } + | { ok: false; code: 500; error: "server_error" } +``` + +### Foutcodes (volgens `CLAUDE.md` "Foutcodes API") + +| Code | Wanneer | UI-respons | +|---|---|---| +| **422** | zod-validatiefout (server-side dubbelcheck) | `fieldErrors` mappen naar `form.setError()`, geen toast | +| **403** | demo-sessie probeert te schrijven, of cross-tenant write geblokkeerd | toast "Niet toegestaan in demo-modus" / "Geen toegang", form blijft open | +| **500** | onverwachte serverfout | toast met "Opnieuw proberen"-knop, form-state behouden | + +> Field-level errors zijn **alleen** geldig bij `code: 422`. Bij andere codes is `fieldErrors` ongedefinieerd. + +### Revalidation + +`revalidatePath` op de **context-route** waarin de dialog werd geopend, niet op een statische `/tasks`-path: + +```ts +if (context.sprintId) revalidatePath(`/sprint/${context.sprintId}`); +if (context.productId) revalidatePath(`/products/${context.productId}/backlog`); +``` + +De aanroepende client geeft de relevante `sprintId` of `productId` mee als argument bij elke save/delete. Geen hard-coded paths in de action zelf. + +### Flow + +- Synchroon (geen optimistic update in v1) +- Tijdens submit: cancel- en save-knop disabled, spinner in save-knop met "Opslaan...", velden blijven enabled +- Server saniteert en valideert opnieuw met hetzelfde zod-schema +- Field-level server errors (bv. unique constraint op title binnen scope) → `code: 422` met `fieldErrors`, terugmappen naar `form.setError()` + +### Error handling + +- **422** → field errors inline tonen, geen toast +- **403** → toast met passende boodschap, form blijft open, ingevulde waarden behouden +- **500 / netwerk** → toast met "Opnieuw proberen"-knop, form-state behouden, knoppen weer enabled + +--- + +## Dialog-gedrag + +### Sluiten met dirty state +- Form niet aangeraakt → Esc / backdrop-klik / Cancel sluiten direct +- Form `isDirty` → Esc / backdrop-klik / Cancel triggeren `AlertDialog`: *"Wijzigingen niet opgeslagen — weggooien?"* + +### Keyboard shortcuts +- **Esc** — sluit (met dirty-check) +- **Cmd/Ctrl+Enter** — submit vanuit elk veld +- **Enter in title-input** — submit niet (alleen Cmd/Ctrl+Enter) +- **Enter in textarea** — newline (default browser behavior, niet overriden) +- **Tab** — title → description → implementation_plan → priority → (status) → cancel → save + +### Focus management +- Bij openen: focus op `title`-input +- Edit-mode: cursor aan einde van bestaande titel, **geen auto-select** (anders typt user per ongeluk de titel weg) +- Bij sluiten: focus terug naar het element dat de dialog opende (`@base-ui/react` doet dit by default — niet breken) +- Bij submit-error: focus naar eerste error-veld + +### Motion +MD3-conform: +- Open: 250ms, easing `cubic-bezier(0.2, 0, 0, 1)`, scale 0.95→1 + opacity 0→1 +- Close: 200ms, easing `cubic-bezier(0.4, 0, 1, 1)` + +### Backdrop +Scrim `rgba(0,0,0,0.4)` (iets sterker dan MD3-default 0.32 voor betere contrast op licht/donker). + +--- + +## Footer + +### Edit-mode +``` +[ Verwijderen ] [ Annuleren ] [ Opslaan ] + tonal (error-container) text filled (primary) +``` + +### Create-mode +``` + [ Annuleren ] [ Aanmaken ] + text filled (primary) +``` + +### Delete-flow +- Klik op "Verwijderen" → `AlertDialog`: *"Weet je zeker? Dit kan niet ongedaan worden."* +- Bevestigen → `deleteTask` server action (zelfde auth-scoping en demo-checks als `saveTask`) → `revalidatePath` op de context-route (`/sprint/` of `/products//backlog`) → dialog sluit → toast "Taak verwijderd" +- Geen undo in v1 + +--- + +## Triggers (hoe komt de user erbij?) + +De dialog wordt vanuit twee context-pagina's geopend: een sprint-detail (`/sprint/`) of een product-backlog (`/products//backlog`). + +> **Vervangt bestaande create/edit-flows.** Deze TaskDialog is de **enige** flow voor het aanmaken en bewerken van taken in beide contexten. Bestaande inline-edit-paden in `components/sprint/task-list.tsx` (en eventueel in de backlog) worden door deze dialog vervangen — niet er naast geplaatst. De huidige task-row-rendering wordt aangepast om bij klik de dialog te openen via `?editTask=`; geen aparte edit-icon, geen inline form. Een eventuele "+ Nieuwe taak"-knop in de bestaande tasklist-header wordt eveneens omgeleid naar `?newTask=1` op dezelfde route. + +- **Create:** filled button `+ Nieuwe taak` rechtsboven in de tasklist-header van de huidige context (FAB op mobiel optioneel later). Klik zet de juiste query-param (`?newTask=1`) op de huidige route. +- **Edit:** klik op de hele rij in de tasklist (geen apart edit-icoon). Klik zet `?editTask=` op de huidige route. +- **Loading edit-mode:** Suspense met minimale skeleton (3 grijze balken voor inputs), `200ms` delay zodat snelle fetches geen flicker tonen + +### Server-fetch +Bij `?editTask=`: server component fetcht de taak vóór render — **inclusief auth-scoping** via `productAccessFilter(userId)` zodat een user nooit een task uit een ander product kan openen via een geraden ID. Bestaat de taak niet of valt 'm buiten scope → toast + redirect naar de context-route zonder query-param (bv. `/sprint/`). + +--- + +## Theming (Material Design 3 tokens) + +> **Bron-of-truth in v1:** de bestaande **statische** tokens in `app/styles/theme.css` zijn canoniek. De TaskDialog **consumeert** deze tokens en voegt er geen nieuwe aan toe. Dynamic color (`material-color-utilities`) valt **buiten v1** — niet introduceren in deze feature. + +### Color +- TaskDialog gebruikt de bestaande MD3-tokens uit `app/styles/theme.css`: `--primary`, `--on-primary`, `--surface-container`, `--surface-container-high`, `--surface-container-low`, `--error-container`, `--on-error-container`, `--outline-variant`, plus de project-specifieke `--status-*` en `--priority-*` tokens +- Eventueel ontbrekende tokens (bv. een specifieke `surface-container-high` als die er nog niet is) worden in **dezelfde commit** als de feature aan `theme.css` toegevoegd, niet ad-hoc per component gehard-codeerd +- **Verboden:** willekeurige Tailwind-kleuren (`bg-blue-500`, etc.). Altijd semantische tokens — zie `docs/scrum4me-styling.md` + +### Dark mode +- `next-themes` is al in de stack; TaskDialog erft automatisch de actieve kleurmodus via de bestaande tokens +- Geen extra setup nodig in deze feature + +### Surface elevation +Hybrid (tonal surface + zachte shadow): +- Dialog: `surface-container-high` background + `shadow-2xl` met getemperde opacity +- Form inputs: `surface-container-low` background, geen shadow +- Geen pure tonal-only (voelt te plat op desktop) + +### Buttons +- **Filled** (Save/Aanmaken): `primary` background, `on-primary` tekst +- **Text** (Cancel): geen background, `primary` tekst +- **Tonal error** (Delete): `error-container` background, `on-error-container` tekst + +### Density +Comfortable (geen compact): +- Single-line input-hoogte: 56px (MD3 outlined text field default) +- Veld-spacing: 24px (`space-y-6`) +- Dialog-padding: 24px alle kanten (`p-6`) + +### Typography +- **Font:** Inter via `next/font/google` (geen Roboto-dwang) +- **Schaal (beperkt):** + - `headline-small` (24px) — dialog-titel + - `body-large` (16px) — form-input tekst + - `body-medium` (14px) — helptext, counter +- Geen Material-specifieke letter-spacing tweaks; Inter-defaults voldoen + +### Iconen +Lucide (shadcn default). Geen Material Symbols importeren — ~150kb winst en visueel neutraal genoeg om in MD3-themed app te passen. + +--- + +## Markdown rendering (buiten de dialog) + +Voor weergave van `description` en `implementation_plan` elders in de app (taakdetail, hover-card, etc.): + +```tsx +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + + + {content} + +``` + +- Tailwind Typography (`prose prose-sm`) voor styling +- `remark-gfm` voor tabellen, taken, strikethrough +- `react-markdown` saniteert by default; `disallowedElements` als extra defense-in-depth + +--- + +## Hergebruik & generalisatie + +De TaskDialog is de eerste van naar verwachting meerdere entity-dialogs (PBI, Story, Todo volgen logisch). Bouw daarom **vanaf dag 1** een dunne scheiding tussen generic shell+primitives en entity-specifieke form-body. Dit is geen speculatieve abstractie: de breuklijn tussen "dialog-mechanica" en "welke velden horen bij deze entiteit" is natuurlijk en levert per nieuwe entiteit ~70% codebesparing op. + +### Wat generic wordt (`components/entity-dialog/`) + +| Component | Waarom generic | +|---|---| +| `entity-dialog.tsx` | Shell: sticky header/footer, responsive layout, motion, backdrop, dirty-close-guard, keyboard-shortcuts, focus-management. Slot-props voor body en footer-actions. | +| `priority-segmented.tsx` | P1-P4 segmented buttons; `priority: int 1-4` is identiek over Task / PBI / Story / Todo. | +| `auto-grow-textarea.tsx` | Wrapper rond `react-textarea-autosize` met char-counter (vanaf 75%) en markdown-hint. Generic — neemt min/max regels en max-chars als props. | +| `dirty-close-guard.tsx` | AlertDialog "Wijzigingen niet opgeslagen — weggooien?" — entity-agnostisch. | + +Deze primitives importeren **alleen** uit `components/ui/*` en hebben geen kennis van Task / Story / PBI. + +### Wat entity-specifiek blijft (`components/tasks/`) + +| Component | Waarom niet generic | +|---|---| +| `task-dialog.tsx` | Dunne wrapper: kiest body, koppelt `saveTask`/`deleteTask`, levert label-strings ("Taak bewerken" / "Aangemaakt: …"). Geen mechanica meer in dit bestand. | +| `task-form.tsx` | Velden zijn task-specifiek (`title`, `description`, `implementation_plan`, `priority`, `status`). Andere entiteiten (Story heeft `acceptance_criteria`, PBI heeft alleen `description`) krijgen elk hun eigen `*-form.tsx`. | +| `task-status-select.tsx` | `TaskStatus` enum met 4 specifieke waarden + dot-kleurmapping. `StoryStatus` (`OPEN | IN_SPRINT | DONE`) en `PbiStatus` (`OPEN | IN_SPRINT | DONE` + `BLOCKED`) hebben andere enums en horen bij eigen select-componenten. | + +### Wat **niet** abstraheren in v1 + +- **URL-state pattern** — `?newTask=1` / `?editTask=` per route. Een toekomstige PBI-dialog krijgt `?newPbi=1` / `?editPbi=` op zijn eigen routes. Copy-paste tussen 2-3 pages is goedkoper dan een generic helper die je later toch moet generaliseren. +- **Save/delete-flows** — auth-scoping, demo-checks en revalidatePath verschillen subtiel per entiteit (verschillende productAccessFilter-paden, verschillende context-routes). Per entiteit een eigen actions-file in `app/actions/.ts`. + +### Per-entiteit kostenplaatje + +Wanneer er straks een PbiDialog of StoryDialog gebouwd wordt, kost dat alleen: + +1. `components//-form.tsx` — de velden + zod-schema +2. `components//-status-select.tsx` — als de entiteit een status-veld heeft +3. `components//-dialog.tsx` — dunne wrapper rond `EntityDialog` met de juiste form en save/delete-handler +4. `app/actions/.ts` — server actions +5. URL-state uitbreiding op de relevante page(s) + +Geen herhaling van layout, motion, dirty-check, keyboard-shortcuts, of segmented/textarea-primitives. + +--- + +## Bewust NIET in v1 + +Om scope te bewaken: + +- ❌ Bulk-edit (meerdere taken tegelijk) +- ❌ Drag-and-drop herorderen +- ❌ Sub-tasks / parent-child relaties +- ❌ Tags / labels / categorieën +- ❌ Due dates / reminders +- ❌ Attachments / file uploads +- ❌ Comments / activity log +- ❌ Sharing / collaboration +- ❌ Undo na delete (toast met undo-actie) +- ❌ Cmd+K keyboard-driven creation zonder dialog +- ❌ Templates voor terugkerende taken +- ❌ Time tracking (uren-registratie) — wel relevant voor inspannings-monitor, maar apart feature +- ❌ Telemetrie / analytics +- ❌ Optimistic locking — niet geïmplementeerd in v1 (last-write-wins binnen scope) +- ❌ Tabs voor secties — alleen spacing-gebaseerde groepering +- ❌ Section-headers — implicit via spacing, geen labels + +> Heroverweeg deze keuzes pas als de app groeit. Niet om je te beperken, maar om elke "ja maar moeten we niet ook…"-impuls een bewuste afweging te maken. + +--- + +## File structuur (richtlijn) + +``` +app/ +├── sprint/ +│ └── [id]/ +│ └── page.tsx # leest searchParams, rendert TaskDialog +├── products/ +│ └── [id]/ +│ └── backlog/ +│ └── page.tsx # leest searchParams, rendert TaskDialog +├── actions/ +│ └── tasks.ts # saveTask, deleteTask server actions (auth-scoped) +components/ +├── ui/ +│ ├── dialog.tsx # bestaande @base-ui/react-wrapper +│ └── demo-tooltip.tsx # wrapper voor save/delete-knoppen in demo-mode +├── entity-dialog/ # GENERIC — geen kennis van Task/Story/PBI +│ ├── entity-dialog.tsx # shell: header/footer/motion/dirty-check/keyboard +│ ├── priority-segmented.tsx # P1-P4 segmented buttons +│ ├── auto-grow-textarea.tsx # textarea met counter + markdown-hint +│ └── dirty-close-guard.tsx # AlertDialog bij dirty close +├── tasks/ # ENTITY-SPECIFIEK +│ ├── task-dialog.tsx # dunne wrapper rond EntityDialog +│ ├── task-form.tsx # task-velden + react-hook-form binding +│ └── task-status-select.tsx # TaskStatus enum + dot-kleuren +lib/ +├── schemas/ +│ └── task.ts # gedeeld zod-schema (form + server action) +├── auth/ +│ └── product-access-filter.ts # scope-helper, gedeeld door page-fetches en actions +proxy.ts # demo-readonly middleware-guard (laag 1 van 3) +``` + +--- + +## Implementatie-volgorde (suggestie) + +1. Dependencies toevoegen aan `package.json` (zie "Dependency-impact"); commit als `chore(ST-XXX): add deps for task dialog` +2. zod-schema in `lib/schemas/task.ts` +3. `productAccessFilter` helper checken/uitbreiden in `lib/auth/` +4. Server actions (`saveTask`, `deleteTask`) met **auth-scoping én demo-check** (laag 2) — testen via thunk +5. `proxy.ts` middleware-guard voor demo-routes (laag 1) — alleen als nog niet aanwezig voor deze routes +6. Eventueel ontbrekende MD3-tokens aanvullen in `app/styles/theme.css` (geen dynamic color in v1) +7. ``-wrapper component (laag 3) +8. TaskDialog — create-mode eerst (minder edge cases), bovenop bestaande `components/ui/dialog.tsx`-wrapper +9. Edit-mode toevoegen (status field, delete-knop, `created_at`-metadata) +10. URL-state via native `searchParams` binnen sprint en backlog routes (geen `nuqs` in v1) +11. **Bestaande task-row / tasklist-trigger refactoren** — `components/sprint/task-list.tsx` (en backlog-equivalent) klikbaar maken zodat ze de dialog openen via query-param; oude inline-edit-paden verwijderen +12. Suspense + skeleton voor edit-mode loading + scope-check op fetch +13. Dirty-check + AlertDialog +14. Keyboard shortcuts (Cmd+Enter) +15. Markdown rendering elders (out-of-scope voor dialog zelf, maar related) diff --git a/lib/schemas/task.ts b/lib/schemas/task.ts new file mode 100644 index 0000000..b4c0c3e --- /dev/null +++ b/lib/schemas/task.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { TaskStatus } from '@prisma/client' + +export const taskSchema = z.object({ + title: z.string().trim().min(1, 'Verplicht').max(120), + description: z.string().max(2000).optional(), + implementation_plan: z.string().max(10000).optional(), + priority: z.number().int().min(1).max(4), + status: z.nativeEnum(TaskStatus).optional(), +}) + +export type TaskInput = z.infer diff --git a/lib/tasks-status-update.ts b/lib/tasks-status-update.ts new file mode 100644 index 0000000..ca273ca --- /dev/null +++ b/lib/tasks-status-update.ts @@ -0,0 +1,72 @@ +import type { Prisma, TaskStatus } from '@prisma/client' +import { prisma } from '@/lib/prisma' + +export type StoryStatusChange = 'promoted' | 'demoted' | null + +export interface UpdateTaskStatusResult { + task: { + id: string + title: string + status: TaskStatus + story_id: string + implementation_plan: string | null + } + storyStatusChange: StoryStatusChange + storyId: string +} + +// Update task.status atomically and auto-promote/demote the parent story: +// - All sibling tasks DONE → story.status = DONE +// - Story was DONE and a task moves out of DONE → story.status = IN_SPRINT +// Demote target is IN_SPRINT (not OPEN): OPEN means "back in product backlog", +// which is a sprint-management action, not a status side-effect. +export async function updateTaskStatusWithStoryPromotion( + taskId: string, + newStatus: TaskStatus, + client?: Prisma.TransactionClient, +): Promise { + const run = async (tx: Prisma.TransactionClient): Promise => { + const task = await tx.task.update({ + where: { id: taskId }, + data: { status: newStatus }, + select: { + id: true, + title: true, + status: true, + story_id: true, + implementation_plan: true, + }, + }) + + const siblings = await tx.task.findMany({ + where: { story_id: task.story_id }, + select: { status: true }, + }) + const allDone = siblings.every((s) => s.status === 'DONE') + + const story = await tx.story.findUniqueOrThrow({ + where: { id: task.story_id }, + select: { status: true }, + }) + + let storyStatusChange: StoryStatusChange = null + if (newStatus === 'DONE' && allDone && story.status !== 'DONE') { + await tx.story.update({ + where: { id: task.story_id }, + data: { status: 'DONE' }, + }) + storyStatusChange = 'promoted' + } else if (newStatus !== 'DONE' && story.status === 'DONE') { + await tx.story.update({ + where: { id: task.story_id }, + data: { status: 'IN_SPRINT' }, + }) + storyStatusChange = 'demoted' + } + + return { task, storyStatusChange, storyId: task.story_id } + } + + if (client) return run(client) + return prisma.$transaction(run) +} diff --git a/package-lock.json b/package-lock.json index ccbe18b..3a6f70f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@tanstack/react-table": "^8.21.3", @@ -30,6 +31,10 @@ "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-hook-form": "^7.74.0", + "react-markdown": "^10.1.0", + "react-textarea-autosize": "^8.5.9", + "remark-gfm": "^4.0.1", "shadcn": "^4.4.0", "sharp": "^0.34.5", "sonner": "^1.7.4", @@ -41,6 +46,7 @@ "devDependencies": { "@mermaid-js/mermaid-cli": "^11.12.0", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.19", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", @@ -1633,6 +1639,18 @@ "hono": "^4" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -3619,6 +3637,12 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -3911,6 +3935,33 @@ "tailwindcss": "4.2.4" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -4369,6 +4420,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4380,9 +4440,17 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", @@ -4390,6 +4458,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4404,6 +4481,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -4466,6 +4558,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -4779,6 +4877,12 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, "node_modules/@unrs/resolver-binding-android-arm-eabi": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", @@ -5986,6 +6090,16 @@ } } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -6430,6 +6544,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -6457,6 +6581,46 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chart.js": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", @@ -7031,6 +7195,16 @@ "dev": true, "license": "MIT" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -7902,6 +8076,19 @@ "node": ">=0.10.0" } }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -8081,6 +8268,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -8096,6 +8292,19 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1367902", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1367902.tgz", @@ -8982,6 +9191,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -9173,6 +9392,12 @@ "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -9989,6 +10214,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/headers-polyfill": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-5.0.1.tgz", @@ -10049,6 +10314,16 @@ "dev": true, "license": "MIT" }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -10228,6 +10503,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -10295,6 +10576,30 @@ "url": "https://github.com/sponsors/brc-dd" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -10472,6 +10777,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -10553,6 +10868,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -11777,6 +12102,16 @@ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "license": "Apache-2.0" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -11874,6 +12209,16 @@ "node": ">=10" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", @@ -11896,6 +12241,288 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -11975,6 +12602,569 @@ "node": ">= 20" } }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -12869,6 +14059,31 @@ "node": ">=6" } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -13630,6 +14845,16 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -13892,6 +15117,22 @@ "react": "^19.2.4" } }, + "node_modules/react-hook-form": { + "version": "7.74.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.74.0.tgz", + "integrity": "sha512-yR6wHr99p9wFv686jhRWVSFhUvDvNbdUf2dKlbno8/VKOCuoNobDGC6S+M2dua9A9Yo8vpcrp8assIYbsZCQ9g==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -13899,6 +15140,33 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-stately": { "version": "3.46.0", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz", @@ -13917,6 +15185,23 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/react-textarea-autosize": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", + "integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.13", + "use-composed-ref": "^1.3.0", + "use-latest": "^1.2.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -14027,6 +15312,72 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remeda": { "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", @@ -14917,6 +16268,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -15165,6 +16526,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", @@ -15231,6 +16606,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -15605,6 +16998,26 @@ "tree-kill": "cli.js" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -15960,6 +17373,93 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -16062,6 +17562,51 @@ "punycode": "^2.1.0" } }, + "node_modules/use-composed-ref": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz", + "integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-latest": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz", + "integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==", + "license": "MIT", + "dependencies": { + "use-isomorphic-layout-effect": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -16123,6 +17668,34 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", @@ -16858,6 +18431,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index a8f39df..bf99e24 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@tanstack/react-table": "^8.21.3", @@ -39,6 +40,10 @@ "qrcode.react": "^4.2.0", "react": "19.2.4", "react-dom": "19.2.4", + "react-hook-form": "^7.74.0", + "react-markdown": "^10.1.0", + "react-textarea-autosize": "^8.5.9", + "remark-gfm": "^4.0.1", "shadcn": "^4.4.0", "sharp": "^0.34.5", "sonner": "^1.7.4", @@ -56,6 +61,7 @@ "devDependencies": { "@mermaid-js/mermaid-cli": "^11.12.0", "@tailwindcss/postcss": "^4", + "@tailwindcss/typography": "^0.5.19", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", From 8877ea469d40a439d9992b72f91bd6cb5d0c4cb3 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Thu, 30 Apr 2026 18:16:07 +0200 Subject: [PATCH 020/282] =?UTF-8?q?feat(M14):=203-pane=20backlog=20?= =?UTF-8?q?=E2=80=94=20generic=20SplitPane,=20BacklogStore,=20SSE=20realti?= =?UTF-8?q?me,=20card-grid=20TaskPanel=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(split-pane): refactor to generic n-pane SplitPane with cookie persistence New API: panes[], defaultSplit[], cookieKey, tabLabels. Supports arbitrary number of panes with n-1 draggable dividers and JSON cookie persistence. Replaces TriplePane; mobile renders tabs. Co-Authored-By: Claude Sonnet 4.6 * feat(split-pane): migrate callers to new panes[] API Backlog page and sprint board now use generic SplitPane. TriplePane removed; sprint board uses 3-pane with defaultSplit=[28,35,37]. Co-Authored-By: Claude Sonnet 4.6 * test(split-pane): add unit tests for 2/3-pane, cookie-restore, mobile tabs Added jsdom + @testing-library/react devDeps for component testing. 7 cases: render, divider count, cookie restore, invalid cookie fallback, mobile tab render/switch, and no-dividers-on-mobile. Co-Authored-By: Claude Sonnet 4.6 * feat(backlog): add BacklogStore Zustand store with applyChange reducer State: pbis, storiesByPbi, tasksByStory. setInitialData for server hydration; applyChange(entity, op, data) handles I/U/D for SSE events. Co-Authored-By: Claude Sonnet 4.6 * feat(backlog): server-fetch tasks + hydrate BacklogStore on page load Page now fetches tasks parallel to stories and groups by story_id. BacklogHydrationWrapper calls setInitialData on mount so the store is ready for downstream SSE consumers. Co-Authored-By: Claude Sonnet 4.6 * feat(backlog): add EmptyPanel shared component, replace inline empty states EmptyPanel takes title?, message, and optional action with DemoTooltip. Replaces duplicate inline empty-state markup in pbi-list and story-panel. Co-Authored-By: Claude Sonnet 4.6 * feat(backlog): add TaskPanel with sortable rows and TaskDialog wiring Reads selectedStoryId + tasksByStory from stores. DnD reorder via reorderTasksAction. Row click → ?editTask, + button → ?newTask&storyId. DemoTooltip on drag handles and + button. Co-Authored-By: Claude Sonnet 4.6 * feat(backlog): wire TaskPanel + TaskDialog into backlog page 3-pane SplitPane [20,45,35]. searchParams for newTask/editTask. TaskDialog and EditTaskLoader render on ?newTask and ?editTask. Co-Authored-By: Claude Sonnet 4.6 * test(backlog): add TaskPanel tests for render states and click handlers 7 cases: no-story empty, no-tasks empty+action, tasks render, + button router.push, row click router.push, demo disabled button, demo disabled handles. Co-Authored-By: Claude Sonnet 4.6 * feat(backlog): migrate PbiList to store-driven via useBacklogStore Removes pbis prop; reads from useBacklogStore(s => s.pbis) so SSE updates reflect in real-time without prop drilling. Co-Authored-By: Claude Sonnet 4.6 * feat(backlog): migrate StoryPanel to store-driven + selectStory on click Removes storiesByPbi prop; reads from useBacklogStore. Card click now dispatches selectStory(id) + shows isSelected highlight. Edit moved to inline pencil button. page.tsx drops pbis/storiesByPbi props. Co-Authored-By: Claude Sonnet 4.6 * test(backlog): add 3-pane integration tests for click-cascade flow Covers: empty states, PBI→stories, story→tasks, cascade-reset, isSelected highlight. localStorage mocked for sort-mode persistence. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1115): SSE backlog realtime — endpoint, hook, hydration mount, tests Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1116): mobile auto-switch tabs + back button in BacklogSplitPane Co-Authored-By: Claude Sonnet 4.6 * docs(ST-1116): update functional-spec (3-pane backlog + mobile) and architecture (backlog SSE + backlog-store) Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1117): TaskPanel card-grid — BacklogCard + rectSortingStrategy, tests updated Co-Authored-By: Claude Sonnet 4.6 * fix(tests): correct PbiStatusApi type and remove duplicate mock keys Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- __tests__/api/backlog-realtime.test.ts | 131 ++++ .../backlog/backlog-split-pane.test.tsx | 85 +++ .../components/backlog/integration.test.tsx | 133 ++++ .../components/backlog/task-panel.test.tsx | 136 ++++ __tests__/components/split-pane.test.tsx | 227 ++++++ app/(app)/products/[id]/page.tsx | 136 +++- app/api/realtime/backlog/route.ts | 129 ++++ .../backlog/backlog-hydration-wrapper.tsx | 30 + components/backlog/backlog-split-pane.tsx | 34 + components/backlog/empty-panel.tsx | 35 + components/backlog/pbi-list.tsx | 18 +- components/backlog/story-panel.tsx | 53 +- components/backlog/task-panel.tsx | 225 ++++++ components/split-pane/split-pane.tsx | 207 +++-- components/split-pane/triple-pane.tsx | 137 ---- components/sprint/sprint-board-client.tsx | 27 +- docs/scrum4me-architecture.md | 64 +- docs/scrum4me-functional-spec.md | 32 +- lib/realtime/use-backlog-realtime.ts | 92 +++ package-lock.json | 706 ++++++++++++++++++ package.json | 3 + stores/backlog-store.ts | 139 ++++ 22 files changed, 2474 insertions(+), 305 deletions(-) create mode 100644 __tests__/api/backlog-realtime.test.ts create mode 100644 __tests__/components/backlog/backlog-split-pane.test.tsx create mode 100644 __tests__/components/backlog/integration.test.tsx create mode 100644 __tests__/components/backlog/task-panel.test.tsx create mode 100644 __tests__/components/split-pane.test.tsx create mode 100644 app/api/realtime/backlog/route.ts create mode 100644 components/backlog/backlog-hydration-wrapper.tsx create mode 100644 components/backlog/backlog-split-pane.tsx create mode 100644 components/backlog/empty-panel.tsx create mode 100644 components/backlog/task-panel.tsx delete mode 100644 components/split-pane/triple-pane.tsx create mode 100644 lib/realtime/use-backlog-realtime.ts create mode 100644 stores/backlog-store.ts diff --git a/__tests__/api/backlog-realtime.test.ts b/__tests__/api/backlog-realtime.test.ts new file mode 100644 index 0000000..4898cda --- /dev/null +++ b/__tests__/api/backlog-realtime.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() })) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/product-access', () => ({ + getAccessibleProduct: vi.fn(), +})) + +import { getAccessibleProduct } from '@/lib/product-access' +import type { NextRequest } from 'next/server' +import { GET } from '@/app/api/realtime/backlog/route' +import { useBacklogStore } from '@/stores/backlog-store' + +const mockGetAccessibleProduct = getAccessibleProduct as ReturnType + +function makeReq(productId?: string): NextRequest { + const url = productId + ? `http://localhost/api/realtime/backlog?product_id=${productId}` + : 'http://localhost/api/realtime/backlog' + return { + signal: new AbortController().signal, + nextUrl: new URL(url), + } as unknown as NextRequest +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/realtime/backlog', () => { + it('401 when not authenticated', async () => { + mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(401) + expect(mockGetAccessibleProduct).not.toHaveBeenCalled() + }) + + it('400 when product_id is missing', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + const res = await GET(makeReq()) + expect(res.status).toBe(400) + }) + + it('403 when user has no access to the product', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockGetAccessibleProduct.mockResolvedValue(null) + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(403) + expect(mockGetAccessibleProduct).toHaveBeenCalledWith('prod-1', 'user-1') + }) + + it('500 when DIRECT_URL and DATABASE_URL are absent', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) + + const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } + delete process.env.DIRECT_URL + delete process.env.DATABASE_URL + try { + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(500) + } finally { + if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL + if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL + } + }) + + it('demo user is allowed (no 403) when product is accessible', async () => { + mockGetSession.mockResolvedValue({ userId: 'demo-user', isDemo: true }) + mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) + + const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } + delete process.env.DIRECT_URL + delete process.env.DATABASE_URL + try { + const res = await GET(makeReq('prod-1')) + // Fails at 500 (no DB URL) — not 403, confirming demo user is not blocked + expect(res.status).toBe(500) + } finally { + if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL + if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL + } + }) +}) + +// shouldEmit scope filter — white-box unit tests +describe('shouldEmit scope filter (via backlog-store reducer)', () => { + it('applyChange: pbi INSERT adds to pbis array', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.getState().applyChange('pbi', 'I', pbi) + expect(useBacklogStore.getState().pbis).toHaveLength(1) + expect(useBacklogStore.getState().pbis[0].id).toBe('pbi-1') + }) + + it('applyChange: pbi UPDATE patches existing pbi', () => { + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Old', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'New' }) + expect(useBacklogStore.getState().pbis[0].title).toBe('New') + }) + + it('applyChange: pbi DELETE removes pbi', () => { + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) + expect(useBacklogStore.getState().pbis).toHaveLength(0) + }) + + it('applyChange: story INSERT adds to storiesByPbi', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + useBacklogStore.getState().applyChange('story', 'I', story) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) + }) + + it('applyChange: story DELETE removes from correct pbi bucket', () => { + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) + }) + + it('applyChange: task UPDATE patches task across story buckets', () => { + const task = { id: 'task-1', title: 'Old', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: 'story-1', created_at: new Date() } + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [task] } }) + useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', status: 'IN_PROGRESS' }) + expect(useBacklogStore.getState().tasksByStory['story-1'][0].status).toBe('IN_PROGRESS') + }) +}) diff --git a/__tests__/components/backlog/backlog-split-pane.test.tsx b/__tests__/components/backlog/backlog-split-pane.test.tsx new file mode 100644 index 0000000..f57e53f --- /dev/null +++ b/__tests__/components/backlog/backlog-split-pane.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import { useSelectionStore } from '@/stores/selection-store' +import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' + +const PANES = [ +
PBI pane
, +
Stories pane
, +
Tasks pane
, +] + +function renderPane() { + return render( + + ) +} + +beforeEach(() => { + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) + // Force mobile viewport + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) +}) + +describe('BacklogSplitPane auto-switch', () => { + it('starts on tab 0 with no selection', () => { + renderPane() + expect(screen.getByText('PBI pane')).toBeTruthy() + expect(screen.queryByText('Stories pane')).toBeNull() + }) + + it('auto-switches to tab 1 when PBI is selected', () => { + const { rerender } = renderPane() + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null }) + rerender( + + ) + expect(screen.getByText('Stories pane')).toBeTruthy() + expect(screen.queryByText('PBI pane')).toBeNull() + }) + + it('auto-switches to tab 2 when story is selected', () => { + const { rerender } = renderPane() + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) + rerender( + + ) + expect(screen.getByText('Tasks pane')).toBeTruthy() + expect(screen.queryByText('PBI pane')).toBeNull() + }) + + it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => { + // Start with story selected (tab 2) + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) + const { rerender } = renderPane() + + // Cascade-reset: new PBI → story clears + useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null }) + rerender( + + ) + expect(screen.getByText('Stories pane')).toBeTruthy() + }) +}) diff --git a/__tests__/components/backlog/integration.test.tsx b/__tests__/components/backlog/integration.test.tsx new file mode 100644 index 0000000..928ccce --- /dev/null +++ b/__tests__/components/backlog/integration.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore } from '@/stores/backlog-store' + +// Mock next/navigation +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) + +// localStorage mock for StoryPanel sort mode persistence +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { store[k] = v }, + removeItem: (k: string) => { delete store[k] }, + clear: () => { store = {} }, + } +})() +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }) + +// Mock server actions +vi.mock('@/actions/stories', () => ({ + reorderStoriesAction: vi.fn().mockResolvedValue({ success: true }), + reorderPbisAction: vi.fn().mockResolvedValue({ success: true }), + updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }), +})) +vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) })) +vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +// Mock dnd-kit +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, + PointerSensor: class {}, + KeyboardSensor: class {}, + useSensor: vi.fn(), + useSensors: vi.fn(() => []), + closestCenter: vi.fn(), + DragOverlay: () => null, +})) +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, + useSortable: () => ({ + attributes: {}, listeners: {}, setNodeRef: vi.fn(), + transform: null, transition: undefined, isDragging: false, + }), + verticalListSortingStrategy: {}, + rectSortingStrategy: {}, + sortableKeyboardCoordinates: {}, + arrayMove: (arr: unknown[]) => arr, +})) +vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } })) + +import { StoryPanel } from '@/components/backlog/story-panel' +import { TaskPanel } from '@/components/backlog/task-panel' + +const PRODUCT_ID = 'prod-1' +const PBI_ID = 'pbi-1' +const ALT_PBI_ID = 'pbi-2' +const STORY_ID = 'story-1' + +const STORIES = [ + { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, created_at: new Date() }, +] +const TASKS = [ + { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, +] + +function resetStores() { + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) + useBacklogStore.setState({ + pbis: [], + storiesByPbi: { [PBI_ID]: STORIES }, + tasksByStory: { [STORY_ID]: TASKS }, + }) +} + +describe('Backlog 3-pane integration', () => { + beforeEach(() => { + mockPush.mockClear() + resetStores() + }) + + it('StoryPanel shows empty state when no PBI selected', () => { + render() + expect(screen.getByText('Selecteer een PBI om de stories te bekijken.')).toBeTruthy() + }) + + it('StoryPanel shows stories when PBI is selected', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) + render() + expect(screen.getByText('Eerste story')).toBeTruthy() + }) + + it('clicking a story dispatches selectStory to the store', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) + render() + fireEvent.click(screen.getByText('Eerste story')) + expect(useSelectionStore.getState().selectedStoryId).toBe(STORY_ID) + }) + + it('cascade-reset: selecting different PBI clears selectedStoryId', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + useSelectionStore.getState().selectPbi(ALT_PBI_ID) + expect(useSelectionStore.getState().selectedStoryId).toBeNull() + }) + + it('TaskPanel shows tasks after story is selected', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + render() + expect(screen.getByText('Eerste taak')).toBeTruthy() + }) + + it('TaskPanel shows empty state after cascade-reset', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + render() + // Reset via selectPbi + useSelectionStore.getState().selectPbi(ALT_PBI_ID) + // Re-render reflects new store state + render() + expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0) + }) + + it('selected story card has isSelected highlight class applied', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + const { container } = render() + // bg-primary-container is applied when isSelected + const selected = container.querySelector('.bg-primary-container') + expect(selected).toBeTruthy() + }) +}) diff --git a/__tests__/components/backlog/task-panel.test.tsx b/__tests__/components/backlog/task-panel.test.tsx new file mode 100644 index 0000000..97a5894 --- /dev/null +++ b/__tests__/components/backlog/task-panel.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore } from '@/stores/backlog-store' + +// Mock next/navigation +const mockPush = vi.fn() +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) + +// Mock reorderTasksAction +vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) + +// Mock dnd-kit to avoid jsdom drag complexity +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, + PointerSensor: class {}, + KeyboardSensor: class {}, + useSensor: vi.fn(), + useSensors: vi.fn(() => []), + closestCenter: vi.fn(), + DragOverlay: () => null, +})) +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, + useSortable: () => ({ + attributes: {}, listeners: {}, setNodeRef: vi.fn(), + transform: null, transition: undefined, isDragging: false, + }), + rectSortingStrategy: {}, + sortableKeyboardCoordinates: {}, + arrayMove: (arr: unknown[], from: number, to: number) => { + const next = [...arr] + next.splice(from, 1) + next.splice(to, 0, arr[from]) + return next + }, +})) +vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } })) + +import { TaskPanel } from '@/components/backlog/task-panel' + +const PRODUCT_ID = 'prod-1' +const STORY_ID = 'story-1' +const CLOSE_PATH = `/products/${PRODUCT_ID}` + +const TASKS = [ + { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-2', title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() }, +] + +function renderPanel(isDemo = false) { + return render() +} + +describe('TaskPanel', () => { + beforeEach(() => { + mockPush.mockClear() + useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null }) + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) + }) + + it('shows empty state when no story is selected', () => { + renderPanel() + expect(screen.getByText('Selecteer een story om de taken te bekijken.')).toBeTruthy() + }) + + it('shows empty state with action when story selected but no tasks', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + renderPanel() + expect(screen.getByText('Nog geen taken voor deze story.')).toBeTruthy() + expect(screen.getAllByText('+ Nieuwe taak').length).toBeGreaterThanOrEqual(1) + }) + + it('renders task cards when tasks are present', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + renderPanel() + expect(screen.getByText('Eerste taak')).toBeTruthy() + expect(screen.getByText('Tweede taak')).toBeTruthy() + }) + + it('renders status badges on task cards', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + renderPanel() + expect(screen.getByText('To Do')).toBeTruthy() + expect(screen.getByText('Bezig')).toBeTruthy() + }) + + it('task cards are rendered inside a grid container', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + const { container } = renderPanel() + const grid = container.querySelector('.grid') + expect(grid).toBeTruthy() + }) + + it('clicking + button calls router.push with newTask params', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + renderPanel() + const buttons = screen.getAllByText('+ Nieuwe taak') + fireEvent.click(buttons[0]) + expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?newTask=1&storyId=${STORY_ID}`) + }) + + it('clicking task card calls router.push with editTask param', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + renderPanel() + fireEvent.click(screen.getByText('Eerste taak')) + expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?editTask=task-1`) + }) + + it('+ button is disabled in demo mode', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + renderPanel(true) + const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button') + expect(btn).toBeTruthy() + expect((btn as HTMLButtonElement).disabled).toBe(true) + }) + + it('cards have no drag listeners in demo mode (whole-card drag disabled)', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + // In demo mode, listeners ({} from useSortable mock) are not spread onto the card. + // The mock always returns empty listeners, so we just verify the cards render without error. + renderPanel(true) + expect(screen.getByText('Eerste taak')).toBeTruthy() + expect(screen.getByText('Tweede taak')).toBeTruthy() + }) +}) diff --git a/__tests__/components/split-pane.test.tsx b/__tests__/components/split-pane.test.tsx new file mode 100644 index 0000000..cd166c0 --- /dev/null +++ b/__tests__/components/split-pane.test.tsx @@ -0,0 +1,227 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { SplitPane } from '@/components/split-pane/split-pane' + +// Helper to set a cookie +function setCookie(key: string, value: string) { + Object.defineProperty(document, 'cookie', { + writable: true, + configurable: true, + value: `sp:${key}=${encodeURIComponent(value)}`, + }) +} + +function clearCookies() { + Object.defineProperty(document, 'cookie', { + writable: true, + configurable: true, + value: '', + }) +} + +describe('SplitPane', () => { + beforeEach(() => { + clearCookies() + // Default: desktop viewport + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 }) + window.dispatchEvent(new Event('resize')) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('renders 2 panes', () => { + render( + Pane A
,
Pane B
]} + defaultSplit={[30, 70]} + cookieKey="test-2pane" + /> + ) + expect(screen.getByText('Pane A')).toBeTruthy() + expect(screen.getByText('Pane B')).toBeTruthy() + }) + + it('renders 3 panes with 2 dividers', () => { + const { container } = render( + Left
, +
Middle
, +
Right
, + ]} + defaultSplit={[28, 35, 37]} + cookieKey="test-3pane" + /> + ) + expect(screen.getByText('Left')).toBeTruthy() + expect(screen.getByText('Middle')).toBeTruthy() + expect(screen.getByText('Right')).toBeTruthy() + // 2 dividers: cursor-col-resize elements + const dividers = container.querySelectorAll('.cursor-col-resize') + expect(dividers).toHaveLength(2) + }) + + it('restores splits from cookie on mount', () => { + const stored = JSON.stringify([40, 60]) + setCookie('test-restore', stored) + + const { container } = render( + A
,
B
]} + defaultSplit={[20, 80]} + cookieKey="test-restore" + /> + ) + + // Left pane should have width 40%, not the default 20% + const paneDiv = container.querySelector('[style*="40%"]') + expect(paneDiv).toBeTruthy() + }) + + it('falls back to defaultSplit when cookie is invalid', () => { + setCookie('test-invalid', 'not-valid-json') + + const { container } = render( + A,
B
]} + defaultSplit={[25, 75]} + cookieKey="test-invalid" + /> + ) + + const paneDiv = container.querySelector('[style*="25%"]') + expect(paneDiv).toBeTruthy() + }) + + it('renders tabs on mobile viewport', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 768 }) + window.dispatchEvent(new Event('resize')) + + render( + Content A,
Content B
]} + defaultSplit={[50, 50]} + cookieKey="test-mobile" + tabLabels={['Tab A', 'Tab B']} + /> + ) + + expect(screen.getByText('Tab A')).toBeTruthy() + expect(screen.getByText('Tab B')).toBeTruthy() + // Only first tab content visible by default + expect(screen.getByText('Content A')).toBeTruthy() + expect(screen.queryByText('Content B')).toBeNull() + }) + + it('switches tab content on mobile', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + render( + Content A,
Content B
]} + defaultSplit={[50, 50]} + cookieKey="test-mobile-switch" + tabLabels={['Tab A', 'Tab B']} + /> + ) + + // Click second tab + fireEvent.click(screen.getByText('Tab B')) + expect(screen.queryByText('Content A')).toBeNull() + expect(screen.getByText('Content B')).toBeTruthy() + }) + + it('back button not visible on tab 0 in mobile', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + render( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-back-hidden" + tabLabels={['T1', 'T2', 'T3']} + /> + ) + + // On tab 0, no back button + expect(screen.queryByLabelText('Terug')).toBeNull() + }) + + it('back button visible on tab > 0 and navigates back', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + render( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-back-nav" + tabLabels={['T1', 'T2', 'T3']} + /> + ) + + // Switch to tab 2 + fireEvent.click(screen.getByText('T3')) + expect(screen.getByText('C')).toBeTruthy() + expect(screen.getByLabelText('Terug')).toBeTruthy() + + // Click back → tab 1 + fireEvent.click(screen.getByLabelText('Terug')) + expect(screen.getByText('B')).toBeTruthy() + + // Click back again → tab 0, no back button + fireEvent.click(screen.getByLabelText('Terug')) + expect(screen.getByText('A')).toBeTruthy() + expect(screen.queryByLabelText('Terug')).toBeNull() + }) + + it('controlled activeTab prop switches the active pane', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + const { rerender } = render( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-controlled" + tabLabels={['T1', 'T2', 'T3']} + activeTab={0} + onActiveTabChange={vi.fn()} + /> + ) + expect(screen.getByText('A')).toBeTruthy() + + rerender( + A,
B
,
C
]} + defaultSplit={[33, 33, 34]} + cookieKey="test-controlled" + tabLabels={['T1', 'T2', 'T3']} + activeTab={2} + onActiveTabChange={vi.fn()} + /> + ) + expect(screen.getByText('C')).toBeTruthy() + }) + + it('does not render dividers on mobile', () => { + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + + const { container } = render( + A,
B
]} + defaultSplit={[50, 50]} + cookieKey="test-no-dividers" + /> + ) + + const dividers = container.querySelectorAll('.cursor-col-resize') + expect(dividers).toHaveLength(0) + }) +}) diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 426cd03..647d8c6 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -1,22 +1,31 @@ +import { Suspense } from 'react' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' import { pbiStatusToApi } from '@/lib/task-status' -import { SplitPane } from '@/components/split-pane/split-pane' +import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' import { PbiList } from '@/components/backlog/pbi-list' import { StoryPanel } from '@/components/backlog/story-panel' import type { Story } from '@/components/backlog/story-panel' +import { TaskPanel } from '@/components/backlog/task-panel' +import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper' +import { TaskDialog } from '@/app/_components/tasks/task-dialog' +import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' +import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' import { StartSprintButton } from '@/components/sprint/start-sprint-button' import { ActivateProductButton } from '@/components/shared/activate-product-button' import Link from 'next/link' interface Props { params: Promise<{ id: string }> + searchParams: Promise<{ newTask?: string; storyId?: string; editTask?: string }> } -export default async function ProductBacklogPage({ params }: Props) { +export default async function ProductBacklogPage({ params, searchParams }: Props) { const { id } = await params + const { newTask, storyId: storyIdParam, editTask } = await searchParams + const closePath = `/products/${id}` const session = await getSession() if (!session.userId) redirect('/login') @@ -33,21 +42,37 @@ export default async function ProductBacklogPage({ params }: Props) { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }) - const stories = await prisma.story.findMany({ - where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - status: true, - pbi_id: true, - created_at: true, - }, - }) + const [stories, tasks] = await Promise.all([ + prisma.story.findMany({ + where: { product_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + select: { + id: true, + code: true, + title: true, + description: true, + acceptance_criteria: true, + priority: true, + status: true, + pbi_id: true, + created_at: true, + }, + }), + prisma.task.findMany({ + where: { story: { pbi: { product_id: id } } }, + select: { + id: true, + title: true, + description: true, + priority: true, + status: true, + sort_order: true, + story_id: true, + created_at: true, + }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + }), + ]) // Group stories by PBI id const storiesByPbi: Record = {} @@ -56,6 +81,13 @@ export default async function ProductBacklogPage({ params }: Props) { storiesByPbi[story.pbi_id].push(story) } + // Group tasks by story id + const tasksByStory: Record = {} + for (const task of tasks) { + if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = [] + tasksByStory[task.story_id].push(task) + } + const isDemo = session.isDemo ?? false return ( @@ -90,24 +122,60 @@ export default async function ProductBacklogPage({ params }: Props) { {/* Split pane */}
- ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) }))} - isDemo={isDemo} - /> - } - right={ - - } - /> + ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), + storiesByPbi, + tasksByStory, + }} + > + , + , + , + ]} + /> +
+ + {newTask && ( + + )} + + {editTask && !newTask && ( + }> + + + )} ) } diff --git a/app/api/realtime/backlog/route.ts b/app/api/realtime/backlog/route.ts new file mode 100644 index 0000000..1736710 --- /dev/null +++ b/app/api/realtime/backlog/route.ts @@ -0,0 +1,129 @@ +// SSE endpoint for the backlog 3-pane (PBI / story / task changes). +// Simpler than /api/realtime/solo — no sprint or user scoping, just product_id filter. +// Auth: iron-session cookie. Demo users may read (no 403 for demo). + +import { NextRequest } from 'next/server' +import { Client } from 'pg' +import { getSession } from '@/lib/auth' +import { getAccessibleProduct } from '@/lib/product-access' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const CHANNEL = 'scrum4me_changes' +const HEARTBEAT_MS = 25_000 +const HARD_CLOSE_MS = 240_000 + +type NotifyPayload = Record + +function shouldEmit(payload: NotifyPayload, productId: string): boolean { + if ('type' in payload) return false // job / worker events — not relevant here + const entity = payload.entity as string | undefined + if (!entity || !['pbi', 'story', 'task'].includes(entity)) return false + return payload.product_id === productId +} + +export async function GET(request: NextRequest) { + const session = await getSession() + if (!session.userId) { + return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) + } + + const productId = request.nextUrl.searchParams.get('product_id') + if (!productId) { + return Response.json({ error: 'product_id is verplicht' }, { status: 400 }) + } + + const product = await getAccessibleProduct(productId, session.userId) + if (!product) { + return Response.json({ error: 'Geen toegang tot dit product' }, { status: 403 }) + } + + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl) { + return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, { status: 500 }) + } + + const encoder = new TextEncoder() + const pgClient = new Client({ connectionString: directUrl }) + + let heartbeatTimer: ReturnType | null = null + let hardCloseTimer: ReturnType | null = null + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(chunk)) + } catch { + // stream already closed + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + if (hardCloseTimer) clearTimeout(hardCloseTimer) + try { await pgClient.end() } catch { /* ignore */ } + try { controller.close() } catch { /* already closed */ } + if (process.env.NODE_ENV !== 'production') { + console.log(`[realtime/backlog] closed: ${reason}`) + } + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + } catch (err) { + console.error('[realtime/backlog] pg connect/listen failed:', err) + enqueue(`event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`) + await cleanup('pg connect failed') + return + } + + pgClient.on('notification', (msg) => { + if (!msg.payload) return + let payload: NotifyPayload + try { + payload = JSON.parse(msg.payload) as NotifyPayload + } catch { + return + } + if (!shouldEmit(payload, productId)) return + enqueue(`data: ${msg.payload}\n\n`) + }) + + pgClient.on('error', async (err) => { + console.error('[realtime/backlog] pg client error:', err) + await cleanup('pg error') + }) + + enqueue(`event: ready\ndata: ${JSON.stringify({ product_id: productId })}\n\n`) + + heartbeatTimer = setInterval(() => { + enqueue(`: heartbeat\n\n`) + }, HEARTBEAT_MS) + + hardCloseTimer = setTimeout(() => { + cleanup('hard close 240s') + }, HARD_CLOSE_MS) + + request.signal.addEventListener('abort', () => { + cleanup('client aborted') + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/components/backlog/backlog-hydration-wrapper.tsx b/components/backlog/backlog-hydration-wrapper.tsx new file mode 100644 index 0000000..4bc5731 --- /dev/null +++ b/components/backlog/backlog-hydration-wrapper.tsx @@ -0,0 +1,30 @@ +'use client' + +import { useEffect } from 'react' +import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store' +import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime' + +interface InitialData { + pbis: BacklogPbi[] + storiesByPbi: Record + tasksByStory: Record +} + +interface BacklogHydrationWrapperProps { + initialData: InitialData + productId: string + children: React.ReactNode +} + +export function BacklogHydrationWrapper({ initialData, productId, children }: BacklogHydrationWrapperProps) { + const setInitialData = useBacklogStore((s) => s.setInitialData) + + useEffect(() => { + setInitialData(initialData) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useBacklogRealtime(productId) + + return <>{children} +} diff --git a/components/backlog/backlog-split-pane.tsx b/components/backlog/backlog-split-pane.tsx new file mode 100644 index 0000000..882f13b --- /dev/null +++ b/components/backlog/backlog-split-pane.tsx @@ -0,0 +1,34 @@ +'use client' + +import { useState } from 'react' +import { useSelectionStore } from '@/stores/selection-store' +import { SplitPane, type SplitPaneProps } from '@/components/split-pane/split-pane' + +type Props = Omit + +export function BacklogSplitPane(props: Props) { + const { selectedPbiId, selectedStoryId } = useSelectionStore() + const [activeTab, setActiveTab] = useState(0) + + // React-recommended "derived state from props" pattern: update state during render + // instead of useEffect to avoid cascading renders. + const [prevPbiId, setPrevPbiId] = useState(selectedPbiId) + const [prevStoryId, setPrevStoryId] = useState(selectedStoryId) + + if (selectedStoryId !== prevStoryId) { + setPrevStoryId(selectedStoryId) + if (selectedStoryId) setActiveTab(2) + } + if (selectedPbiId !== prevPbiId) { + setPrevPbiId(selectedPbiId) + if (selectedPbiId && !selectedStoryId) setActiveTab(1) + } + + return ( + + ) +} diff --git a/components/backlog/empty-panel.tsx b/components/backlog/empty-panel.tsx new file mode 100644 index 0000000..e48f688 --- /dev/null +++ b/components/backlog/empty-panel.tsx @@ -0,0 +1,35 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' + +interface EmptyPanelProps { + title?: string + message: string + action?: { + label: string + onClick: () => void + disabled?: boolean + } +} + +export function EmptyPanel({ title, message, action }: EmptyPanelProps) { + return ( +
+ {title &&

{title}

} +

{message}

+ {action && ( + + + + )} +
+ ) +} diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index cb6a721..8f79138 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -27,11 +27,13 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { useSelectionStore } from '@/stores/selection-store' import { usePlannerStore } from '@/stores/planner-store' +import { useBacklogStore } from '@/stores/backlog-store' import { deletePbiAction } from '@/actions/pbis' import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories' import { cn } from '@/lib/utils' import { PbiDialog, type PbiDialogState } from './pbi-dialog' import { BacklogCard } from './backlog-card' +import { EmptyPanel } from './empty-panel' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { PRIORITY_COLORS } from '@/components/shared/priority-select' import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select' @@ -115,7 +117,6 @@ interface Pbi { interface PbiListProps { productId: string - pbis: Pbi[] isDemo: boolean } @@ -194,7 +195,8 @@ function SortablePbiRow({ } // --- Main component --- -export function PbiList({ productId, pbis, isDemo }: PbiListProps) { +export function PbiList({ productId, isDemo }: PbiListProps) { + const pbis = useBacklogStore((s) => s.pbis) const { selectedPbiId, selectPbi } = useSelectionStore() const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore() // Defaults match SSR; persisted values applied post-mount in the loader effect below. @@ -417,14 +419,10 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
{pbis.length === 0 ? ( -
-

Nog geen PBI's aangemaakt.

- - - -
+ setDialogState({ mode: 'create', productId, defaultPriority: 2 }), disabled: isDemo }} + /> ) : ( isDemo: boolean } // --- Sortable story block --- function SortableStoryBlock({ story, - onClick, + isSelected, + onSelect, + onEdit, }: { story: Story - onClick: () => void + isSelected: boolean + onSelect: () => void + onEdit: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: story.id, @@ -91,19 +96,33 @@ function SortableStoryBlock({ code={story.code} priority={story.priority} isDragging={isDragging} - onClick={onClick} + isSelected={isSelected} + onClick={onSelect} badge={ {STATUS_LABELS[story.status] ?? story.status} } + actions={ + + } /> ) } // --- Main component --- -export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps) { - const { selectedPbiId } = useSelectionStore() +export function StoryPanel({ productId, isDemo }: StoryPanelProps) { + const { selectedPbiId, selectedStoryId, selectStory } = useSelectionStore() + const storiesByPbi = useBacklogStore((s) => s.storiesByPbi) const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore() const [filterStatus, setFilterStatus] = useState(null) const [filterPriority, setFilterPriority] = useState(null) @@ -242,20 +261,12 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
{selectedPbiId === null ? ( -

- Selecteer een PBI om de stories te bekijken. -

+ ) : rawStories.length === 0 ? ( -
-

Nog geen stories voor dit PBI.

- {selectedPbiId && ( - - - - )} -
+ setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }} + /> ) : ( setStoryDialogState({ mode: 'edit', story, productId })} + isSelected={selectedStoryId === story.id} + onSelect={() => selectStory(story.id)} + onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} /> ))}
diff --git a/components/backlog/task-panel.tsx b/components/backlog/task-panel.tsx new file mode 100644 index 0000000..c3d7526 --- /dev/null +++ b/components/backlog/task-panel.tsx @@ -0,0 +1,225 @@ +'use client' + +import { useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + closestCenter, +} from '@dnd-kit/core' +import { + SortableContext, + useSortable, + rectSortingStrategy, + arrayMove, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { toast } from 'sonner' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { PanelNavBar } from '@/components/shared/panel-nav-bar' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore, type BacklogTask } from '@/stores/backlog-store' +import { reorderTasksAction } from '@/actions/tasks' +import { BacklogCard } from './backlog-card' +import { EmptyPanel } from './empty-panel' +import { cn } from '@/lib/utils' + +const STATUS_COLORS: Record = { + 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', + REVIEW: 'bg-status-review/15 text-status-review border-status-review/30', + DONE: 'bg-status-done/15 text-status-done border-status-done/30', +} +const STATUS_LABELS: Record = { + TO_DO: 'To Do', + IN_PROGRESS: 'Bezig', + REVIEW: 'Review', + DONE: 'Klaar', +} + +function SortableTaskCard({ + task, + isDemo, + onClick, +}: { + task: BacklogTask + isDemo: boolean + onClick: () => void +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: task.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( + + {STATUS_LABELS[task.status] ?? task.status} + + } + /> + ) +} + +interface TaskPanelProps { + productId: string + isDemo: boolean + closePath: string +} + +export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { + const router = useRouter() + const [, startTransition] = useTransition() + const selectedStoryId = useSelectionStore((s) => s.selectedStoryId) + const tasksByStory = useBacklogStore((s) => s.tasksByStory) + const [activeDragId, setActiveDragId] = useState(null) + const [localOrder, setLocalOrder] = useState(null) + + const rawTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : null + + // Merge local order with rawTasks for optimistic reorder + const tasks: BacklogTask[] | null = rawTasks === null + ? null + : localOrder + ? localOrder.map((id) => rawTasks.find((t) => t.id === id)).filter(Boolean) as BacklogTask[] + : rawTasks + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + function handleDragStart(event: DragStartEvent) { + setActiveDragId(event.active.id as string) + } + + function handleDragEnd(event: DragEndEvent) { + setActiveDragId(null) + if (!selectedStoryId || !tasks) return + const { active, over } = event + if (!over || active.id === over.id) return + + const ids = tasks.map((t) => t.id) + const oldIndex = ids.indexOf(active.id as string) + const newIndex = ids.indexOf(over.id as string) + if (oldIndex === -1 || newIndex === -1) return + + const newOrder = arrayMove(ids, oldIndex, newIndex) + setLocalOrder(newOrder) + + startTransition(async () => { + const result = await reorderTasksAction(selectedStoryId, newOrder) + if (result?.error) { + setLocalOrder(null) + toast.error(result.error) + } + }) + } + + const navActions = ( + + + + ) + + if (tasks === null) { + return ( +
+ + +
+ ) + } + + if (tasks.length === 0) { + return ( +
+ + router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`), + disabled: isDemo, + }} + /> +
+ ) + } + + const activeTask = activeDragId ? tasks.find((t) => t.id === activeDragId) : null + + return ( +
+ +
+ + t.id)} strategy={rectSortingStrategy}> +
+ {tasks.map((task) => ( + router.push(`${closePath}?editTask=${task.id}`)} + /> + ))} +
+
+ + + {activeTask && ( + + )} + +
+
+
+ ) +} diff --git a/components/split-pane/split-pane.tsx b/components/split-pane/split-pane.tsx index 58cc8d9..13dac6f 100644 --- a/components/split-pane/split-pane.tsx +++ b/components/split-pane/split-pane.tsx @@ -1,70 +1,117 @@ 'use client' -import { useRef, useState, useEffect, useCallback } from 'react' +import { Fragment, useRef, useState, useEffect, useCallback } from 'react' import { cn } from '@/lib/utils' -const COOKIE_PREFIX = 'split-pane:' +const COOKIE_PREFIX = 'sp:' const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 -function readSplitCookie(key: string): number | null { +function readSplits(cookieKey: string, n: number): number[] | null { if (typeof document === 'undefined') return null - const match = document.cookie.match(new RegExp(`(?:^|; )${COOKIE_PREFIX}${key}=([^;]+)`)) + const match = document.cookie.match( + new RegExp(`(?:^|; )${COOKIE_PREFIX}${cookieKey}=([^;]+)`) + ) if (!match) return null - const val = parseFloat(decodeURIComponent(match[1])) - return !isNaN(val) && val > 0 && val < 100 ? val : null + try { + const parsed: unknown = JSON.parse(decodeURIComponent(match[1])) + if ( + !Array.isArray(parsed) || + parsed.length !== n || + parsed.some((v) => typeof v !== 'number') || + Math.abs((parsed as number[]).reduce((a, b) => a + b, 0) - 100) > 1 + ) return null + return parsed as number[] + } catch { + return null + } } -function writeSplitCookie(key: string, value: number) { - document.cookie = `${COOKIE_PREFIX}${key}=${value}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax` +function writeSplits(cookieKey: string, splits: number[]) { + document.cookie = `${COOKIE_PREFIX}${cookieKey}=${encodeURIComponent( + JSON.stringify(splits) + )}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax` } -interface SplitPaneProps { - left: React.ReactNode - right: React.ReactNode - storageKey: string - defaultSplit?: number // percentage for left pane - minSize?: number // minimum px per pane, default 200 +export interface SplitPaneProps { + panes: React.ReactNode[] + defaultSplit: number[] // length n, values sum to 100 + cookieKey: string + tabLabels?: string[] // mobile tab labels, defaults to "Pane N" + minSize?: number // minimum px per pane, default 200 + mobileBreakpoint?: number // default 1024 + activeTab?: number // controlled: parent manages which tab is active + onActiveTabChange?: (index: number) => void } export function SplitPane({ - left, - right, - storageKey, - defaultSplit = 20, + panes, + defaultSplit, + cookieKey, + tabLabels, minSize = 200, + mobileBreakpoint = 1024, + activeTab: activeTabProp, + onActiveTabChange, }: SplitPaneProps) { + const isControlled = activeTabProp !== undefined + const n = panes.length const containerRef = useRef(null) - const [split, setSplit] = useState(() => { - return readSplitCookie(storageKey) ?? defaultSplit - }) - const [isDragging, setIsDragging] = useState(false) - const [isMobile, setIsMobile] = useState(false) - const [activeTab, setActiveTab] = useState<'left' | 'right'>('left') + const splitsRef = useRef(defaultSplit) + + const [splits, setSplits] = useState(() => { + return readSplits(cookieKey, n) ?? defaultSplit + }) + const [dragging, setDragging] = useState(null) // divider index (0..n-2) + const [isMobile, setIsMobile] = useState(false) + const [internalTab, setInternalTab] = useState(0) + const activeTab = isControlled ? activeTabProp : internalTab + + const handleTabChange = (i: number) => { + if (!isControlled) setInternalTab(i) + onActiveTabChange?.(i) + } + + useEffect(() => { splitsRef.current = splits }, [splits]) - // Detect mobile useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 1024) + const check = () => setIsMobile(window.innerWidth < mobileBreakpoint) check() window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) - }, []) + }, [mobileBreakpoint]) const onMouseMove = useCallback((e: MouseEvent) => { - if (!isDragging || !containerRef.current) return + if (dragging === null || !containerRef.current) return const rect = containerRef.current.getBoundingClientRect() const containerWidth = rect.width - const offsetX = e.clientX - rect.left const minPct = (minSize / containerWidth) * 100 - const maxPct = 100 - minPct - const newSplit = Math.min(maxPct, Math.max(minPct, (offsetX / containerWidth) * 100)) - setSplit(newSplit) - writeSplitCookie(storageKey, newSplit) - }, [isDragging, minSize, storageKey]) - const onMouseUp = useCallback(() => setIsDragging(false), []) + const cursorPct = ((e.clientX - rect.left) / containerWidth) * 100 + const current = splitsRef.current + // Left edge of pane[dragging] in percentage + const leftEdge = current.slice(0, dragging).reduce((a, b) => a + b, 0) + const combinedWidth = current[dragging] + current[dragging + 1] + + const newLeft = Math.min(Math.max(cursorPct - leftEdge, minPct), combinedWidth - minPct) + const newRight = combinedWidth - newLeft + + setSplits((prev) => { + const next = [...prev] + next[dragging] = newLeft + next[dragging + 1] = newRight + return next + }) + }, [dragging, minSize]) + + const onMouseUp = useCallback(() => { + if (dragging !== null) { + writeSplits(cookieKey, splitsRef.current) + setDragging(null) + } + }, [dragging, cookieKey]) useEffect(() => { - if (isDragging) { + if (dragging !== null) { window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) } @@ -72,37 +119,38 @@ export function SplitPane({ window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) } - }, [isDragging, onMouseMove, onMouseUp]) + }, [dragging, onMouseMove, onMouseUp]) if (isMobile) { return (
-
- - +
+ {activeTab > 0 && ( + + )} + {panes.map((_, i) => ( + + ))}
- {activeTab === 'left' ? left : right} + {panes[activeTab]}
) @@ -110,24 +158,25 @@ export function SplitPane({ return (
- {/* Left pane */} -
- {left} -
- - {/* Divider */} -
setIsDragging(true)} - className={cn( - 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', - isDragging && 'bg-primary' - )} - /> - - {/* Right pane */} -
- {right} -
+ {panes.map((pane, i) => ( + + {i > 0 && ( +
setDragging(i - 1)} + className={cn( + 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', + dragging === i - 1 && 'bg-primary' + )} + /> + )} +
+ {pane} +
+ + ))}
) } diff --git a/components/split-pane/triple-pane.tsx b/components/split-pane/triple-pane.tsx deleted file mode 100644 index ee4a728..0000000 --- a/components/split-pane/triple-pane.tsx +++ /dev/null @@ -1,137 +0,0 @@ -'use client' - -import { useRef, useState, useEffect, useCallback } from 'react' -import { cn } from '@/lib/utils' - -interface TriplePaneProps { - left: React.ReactNode - middle: React.ReactNode - right: React.ReactNode - storageKey: string - defaultLeft?: number // % width for left pane - defaultMiddle?: number // % width for middle pane, right gets the rest - minSize?: number // minimum px per pane -} - -export function TriplePane({ - left, middle, right, storageKey, - defaultLeft = 28, defaultMiddle = 35, minSize = 180, -}: TriplePaneProps) { - const containerRef = useRef(null) - - const load = (key: string, def: number) => { - if (typeof window === 'undefined') return def - const stored = localStorage.getItem(`triple-pane:${storageKey}:${key}`) - if (stored) { - const val = parseFloat(stored) - if (!isNaN(val) && val > 0 && val < 100) return val - } - return def - } - - const [leftPct, setLeftPct] = useState(() => load('left', defaultLeft)) - const [midPct, setMidPct] = useState(() => load('mid', defaultMiddle)) - const [dragging, setDragging] = useState<'left' | 'right' | null>(null) - const [isMobile, setIsMobile] = useState(false) - const [activeTab, setActiveTab] = useState<'left' | 'middle' | 'right'>('left') - - useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 1024) - check() - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - - const onMouseMove = useCallback((e: MouseEvent) => { - if (!dragging || !containerRef.current) return - const rect = containerRef.current.getBoundingClientRect() - const width = rect.width - const minPct = (minSize / width) * 100 - const offsetPct = ((e.clientX - rect.left) / width) * 100 - - if (dragging === 'left') { - const clamped = Math.min(Math.max(offsetPct, minPct), 100 - midPct - minPct) - setLeftPct(clamped) - localStorage.setItem(`triple-pane:${storageKey}:left`, String(clamped)) - } else { - const clamped = Math.min(Math.max(offsetPct - leftPct, minPct), 100 - leftPct - minPct) - setMidPct(clamped) - localStorage.setItem(`triple-pane:${storageKey}:mid`, String(clamped)) - } - }, [dragging, leftPct, midPct, minSize, storageKey]) - - const onMouseUp = useCallback(() => setDragging(null), []) - - useEffect(() => { - if (dragging) { - window.addEventListener('mousemove', onMouseMove) - window.addEventListener('mouseup', onMouseUp) - } - return () => { - window.removeEventListener('mousemove', onMouseMove) - window.removeEventListener('mouseup', onMouseUp) - } - }, [dragging, onMouseMove, onMouseUp]) - - if (isMobile) { - const tabs = ['left', 'middle', 'right'] as const - const labels = ['Backlog', 'Sprint', 'Taken'] - return ( -
-
- {tabs.map((tab, i) => ( - - ))} -
-
- {activeTab === 'left' ? left : activeTab === 'middle' ? middle : right} -
-
- ) - } - - const rightPct = 100 - leftPct - midPct - - return ( -
-
- {left} -
- -
setDragging('left')} - className={cn( - 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', - dragging === 'left' && 'bg-primary' - )} - /> - -
- {middle} -
- -
setDragging('right')} - className={cn( - 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', - dragging === 'right' && 'bg-primary' - )} - /> - -
- {right} -
-
- ) -} diff --git a/components/sprint/sprint-board-client.tsx b/components/sprint/sprint-board-client.tsx index ce3e18d..d499767 100644 --- a/components/sprint/sprint-board-client.tsx +++ b/components/sprint/sprint-board-client.tsx @@ -7,7 +7,7 @@ import { } from '@dnd-kit/core' import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable' import { toast } from 'sonner' -import { TriplePane } from '@/components/split-pane/triple-pane' +import { SplitPane } from '@/components/split-pane/split-pane' import { SprintBacklogLeft, SprintBacklogRight } from './sprint-backlog' import type { SprintStory, PbiWithStories, ProductMember } from './sprint-backlog' import { TaskList } from './task-list' @@ -200,18 +200,20 @@ export function SprintBoardClient({ onDragStart={handleDragStart} onDragEnd={handleDragEnd} > - - } - middle={ + />, - } - right={ + />, selectedStoryId ? ( s.id === selectedStoryId)?.code ?? null} sprintId={sprintId} @@ -235,11 +236,11 @@ export function SprintBoardClient({ isDemo={isDemo} /> ) : ( -
+

Selecteer een story om de taken te bekijken.

- ) - } + ), + ]} /> {activeDragStory && ( diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md index 5197f99..6439d5f 100644 --- a/docs/scrum4me-architecture.md +++ b/docs/scrum4me-architecture.md @@ -733,8 +733,9 @@ scrum4me/ │ ├── product-access.ts # productAccessFilter helper (eigenaar of teamlid) │ └── env.ts # Zod-gevalideerde env vars ├── stores/ # Zustand stores +│ ├── backlog-store.ts # PBI/story/task hydration + applyChange (SSE) │ ├── planner-store.ts # Optimistische drag-and-drop volgorde -│ ├── selection-store.ts # Geselecteerd PBI / story +│ ├── selection-store.ts # Geselecteerd PBI / story (cascade-reset) │ ├── sprint-store.ts # Sprint Backlog taakvolgordes │ ├── solo-store.ts # Solo board optimistische taakstatus │ └── product-store.ts # Actief product (naam + id) voor navbar @@ -1003,6 +1004,67 @@ Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij d --- +## Realtime — Backlog SSE (ST-1115) + +De Product Backlog-pagina (`/products/[id]`) update live als PBI's, stories of taken worden gemuteerd. De pijplijn is gelijk aan de Solo-SSE (M8), maar met een eenvoudiger server-side filter: alleen `product_id`-scope, geen sprint- of user-scope. + +``` +┌─────────────────────────┐ +│ Mutatie (Prisma write) │ Server Action, MCP, etc. +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ Postgres row trigger │ AFTER INSERT/UPDATE/DELETE +│ scrum4me_notify_change()│ entity: 'pbi' | 'story' | 'task' +└────────────┬────────────┘ + ▼ pg_notify('scrum4me_changes', json) +┌─────────────────────────┐ +│ /api/realtime/backlog │ Node runtime, dedicated pg.Client +│ LISTEN scrum4me_changes │ filtert op entity ∈ {pbi,story,task} +│ │ én product_id matcht query-param +└────────────┬────────────┘ + ▼ text/event-stream +┌─────────────────────────┐ +│ EventSource (browser) │ beheerd door useBacklogRealtime +│ → backlog-store.apply │ via applyChange(entity, op, data) +│ Change(entity,op,data)│ +└────────────┬────────────┘ + ▼ +┌─────────────────────────┐ +│ PbiList / StoryPanel / │ re-render op basis van Zustand state +│ TaskPanel re-render │ +└─────────────────────────┘ +``` + +### Hydration en SSE-mount + +De pagina is een Server Component die alle data parallel fetcht. Resultaten worden doorgegeven aan `BacklogHydrationWrapper` (client component), die: +1. `useBacklogStore.setInitialData(...)` aanroept op mount (eenmalig). +2. `useBacklogRealtime(productId)` mount — opent de SSE-stream. + +Alle client-componenten (PbiList, StoryPanel, TaskPanel) lezen uitsluitend uit de Zustand store; ze accepteren geen data-props meer. + +### backlog-store en applyChange + +```ts +// stores/backlog-store.ts +applyChange(entity: 'pbi' | 'story' | 'task', op: 'I' | 'U' | 'D', data: Record) +``` + +- **I (Insert):** voegt het nieuwe object toe aan de juiste sub-array +- **U (Update):** patcht de bestaande entry in-place via spread (`{ ...existing, ...data }`) +- **D (Delete):** filtert de entry weg op `id`; doorzoekt alle sub-arrays omdat de parent-ID afwezig kan zijn in het delete-payload + +### Server-side filter (backlog) + +`/api/realtime/backlog?product_id=...` filtert op: +- `entity ∈ {pbi, story, task}` — job/worker-events en questions worden genegeerd +- `product_id` matcht de query-param + +Demo-gebruikers mogen lezen (geen 403). Overige lifecycle-kenmerken (heartbeat, hard-close, backoff, visibility-pause) zijn identiek aan de Solo SSE. + +--- + ## Demo-user policy (ST-1110) Demo-gebruikers (`is_demo = true` in de database, `isDemo: true` in de iron-session) hebben volledig read-only toegang. Bescherming is drielaags: diff --git a/docs/scrum4me-functional-spec.md b/docs/scrum4me-functional-spec.md index 9b5712e..69dc93a 100644 --- a/docs/scrum4me-functional-spec.md +++ b/docs/scrum4me-functional-spec.md @@ -194,27 +194,37 @@ Gebruikers kunnen producten aanmaken, bewerken en archiveren. Een product is het --- -### F-04: Product Backlog — gesplitst scherm +### F-04: Product Backlog — 3-paneels gesplitst scherm **Prioriteit:** v1 — Kritiek **Persona:** Lars, Dina, Remi **Omschrijving:** -De Product Backlog wordt weergegeven als een gesplitst scherm: links de PBI's gerangschikt op prioriteit en volgorde, rechts de stories van het geselecteerde PBI. De splitter is horizontaal versleepbaar. Elk paneel heeft een eigen navigatiebar met acties (aanmaken, filteren, verwijderen). +De Product Backlog wordt weergegeven als een 3-paneels gesplitst scherm: PBI's (links) | Stories (midden) | Taken (rechts). De splitters zijn versleepbaar. Selectie cascadeert: klikken op een PBI toont de bijbehorende stories; klikken op een story toont de bijbehorende taken. Elk paneel heeft een eigen navigatiebar met acties. **Acceptatiecriteria:** -- [ ] Standaard splitverhouding is 40/60 (PBI's / stories) -- [ ] Splitter is versleepbaar; positie wordt lokaal opgeslagen (localStorage) -- [ ] Selecteren van een PBI links toont de bijbehorende stories rechts +- [ ] Standaard splitverhouding is 20/45/35 (PBI's / Stories / Taken) +- [ ] Splitters zijn versleepbaar; positie wordt opgeslagen in een cookie (`sp:backlog-{id}`) +- [ ] Selecteren van een PBI toont de bijbehorende stories in het middenpaneel - [ ] Geselecteerd PBI is visueel gemarkeerd (achtergrondkleur of rand) -- [ ] Linkerpaneel navigatiebar bevat: [+ PBI aanmaken], [filter], [verwijderen] -- [ ] Rechterpaneel navigatiebar bevat: [+ Story aanmaken], [filter], [verwijderen] -- [ ] Lege staat links: prompt om eerste PBI aan te maken -- [ ] Lege staat rechts (geen PBI geselecteerd): instructie om een PBI te selecteren -- [ ] Lege staat rechts (PBI geselecteerd, geen stories): prompt om eerste story aan te maken +- [ ] Selecteren van een story toont de bijbehorende taken in het rechterpaneel +- [ ] Geselecteerde story is visueel gemarkeerd +- [ ] Cascade-reset: selecteren van een ander PBI wist de geselecteerde story en taken +- [ ] PBI-paneel navigatiebar bevat: [+ PBI aanmaken] +- [ ] Stories-paneel navigatiebar bevat: [+ Story aanmaken], [sorteer], [filter status] +- [ ] Taken-paneel navigatiebar bevat: [+ Nieuwe taak] +- [ ] Lege staat PBI-paneel: prompt om eerste PBI aan te maken +- [ ] Lege staat Stories-paneel (geen PBI geselecteerd): instructie om een PBI te selecteren +- [ ] Lege staat Stories-paneel (PBI geselecteerd, geen stories): prompt om eerste story aan te maken +- [ ] Lege staat Taken-paneel (geen story geselecteerd): instructie om een story te selecteren +- [ ] Lege staat Taken-paneel (story geselecteerd, geen taken): prompt om eerste taak aan te maken +- [ ] Taak aanmaken opent TaskDialog via `?newTask=1&storyId={id}` +- [ ] Taak bewerken opent TaskDialog via `?editTask={id}` **Randgevallen:** -- Scherm smaller dan 768px → gesplitst scherm schakelt over naar tabbladen (PBI's / Stories) +- Scherm smaller dan 1024px → 3-paneels scherm schakelt over naar 3 tabbladen (PBI's | Stories | Taken) +- Mobile tab-navigatie: klikken op PBI schakelt automatisch naar Stories-tab; klikken op story schakelt naar Taken-tab +- Mobile ← terug-knop in tab-header op tabs 2 en 3 navigeert naar het vorige tabblad --- diff --git a/lib/realtime/use-backlog-realtime.ts b/lib/realtime/use-backlog-realtime.ts new file mode 100644 index 0000000..272adac --- /dev/null +++ b/lib/realtime/use-backlog-realtime.ts @@ -0,0 +1,92 @@ +'use client' + +// ST-1115: Client hook for the backlog 3-pane SSE stream. +// Mounts in BacklogHydrationWrapper so it survives Server Action refreshes. +// Dispatches pbi/story/task change events into useBacklogStore.applyChange. + +import { useEffect, useRef } from 'react' +import { useBacklogStore } from '@/stores/backlog-store' + +const BACKOFF_START_MS = 1_000 +const BACKOFF_MAX_MS = 30_000 + +type EntityPayload = { + op: 'I' | 'U' | 'D' + entity: 'pbi' | 'story' | 'task' + [key: string]: unknown +} + +export function useBacklogRealtime(productId: string | null) { + const sourceRef = useRef(null) + const backoffRef = useRef(BACKOFF_START_MS) + const reconnectTimerRef = useRef | null>(null) + + useEffect(() => { + if (!productId) return + + const close = () => { + if (sourceRef.current) { + sourceRef.current.close() + sourceRef.current = null + } + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + } + + const connect = () => { + close() + const source = new EventSource( + `/api/realtime/backlog?product_id=${encodeURIComponent(productId)}`, + ) + sourceRef.current = source + + source.addEventListener('ready', () => { + backoffRef.current = BACKOFF_START_MS + }) + + source.onmessage = (e) => { + if (!e.data) return + try { + const payload = JSON.parse(e.data) as EntityPayload + useBacklogStore + .getState() + .applyChange(payload.entity, payload.op, payload as Record) + } catch (err) { + if (process.env.NODE_ENV !== 'production') { + console.error('[realtime/backlog] failed to parse event', err, e.data) + } + } + } + + source.onerror = () => { + if (sourceRef.current !== source) return + close() + if (document.visibilityState === 'hidden') return + const delay = backoffRef.current + backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS) + reconnectTimerRef.current = setTimeout(connect, delay) + } + } + + const onVisibility = () => { + if (document.visibilityState === 'hidden') { + close() + } else if (sourceRef.current === null) { + backoffRef.current = BACKOFF_START_MS + connect() + } + } + + if (document.visibilityState === 'visible') { + connect() + } + document.addEventListener('visibilitychange', onVisibility) + + return () => { + document.removeEventListener('visibilitychange', onVisibility) + close() + } + }, [productId]) +} diff --git a/package-lock.json b/package-lock.json index 3a6f70f..50921e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,8 @@ "@mermaid-js/mermaid-cli": "^11.12.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", @@ -58,6 +60,7 @@ "eslint": "^9", "eslint-config-next": "16.2.4", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^16.4.0", "prisma-erd-generator": "^2.4.2", "tailwindcss": "^4", @@ -66,6 +69,13 @@ "vitest": "^4.1.5" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -93,6 +103,57 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -584,6 +645,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", @@ -626,6 +700,146 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1523,6 +1737,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -4024,6 +4256,93 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -4118,6 +4437,14 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -6266,6 +6593,16 @@ "integrity": "sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -7390,6 +7727,27 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -7988,6 +8346,20 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -8076,6 +8448,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -8349,6 +8728,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dompurify": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", @@ -8474,6 +8861,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -10300,6 +10700,19 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -10497,6 +10910,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -11002,6 +11425,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -11346,6 +11776,57 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -12158,6 +12639,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -12523,6 +13015,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -13224,6 +13723,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -14114,6 +14623,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -14684,6 +15206,55 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/pretty-ms": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", @@ -15268,6 +15839,20 @@ "node": ">= 4" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -15733,6 +16318,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -16593,6 +17191,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -16713,6 +17324,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", @@ -16988,6 +17606,19 @@ "node": ">=16" } }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -17355,6 +17986,16 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -17952,6 +18593,19 @@ "dev": true, "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -17961,6 +18615,41 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -18202,6 +18891,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index bf99e24..2d3b7df 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "@mermaid-js/mermaid-cli": "^11.12.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", @@ -73,6 +75,7 @@ "eslint": "^9", "eslint-config-next": "16.2.4", "husky": "^9.1.7", + "jsdom": "^29.1.1", "lint-staged": "^16.4.0", "prisma-erd-generator": "^2.4.2", "tailwindcss": "^4", diff --git a/stores/backlog-store.ts b/stores/backlog-store.ts new file mode 100644 index 0000000..67daa62 --- /dev/null +++ b/stores/backlog-store.ts @@ -0,0 +1,139 @@ +import { create } from 'zustand' +import type { PbiStatusApi } from '@/lib/task-status' + +export interface BacklogPbi { + id: string + code: string | null + title: string + priority: number + description?: string | null + created_at: Date + status: PbiStatusApi +} + +export interface BacklogStory { + id: string + code: string | null + title: string + description: string | null + acceptance_criteria: string | null + priority: number + status: string + pbi_id: string + created_at: Date +} + +export interface BacklogTask { + id: string + title: string + description: string | null + priority: number + status: string + sort_order: number + story_id: string + created_at: Date +} + +type Entity = 'pbi' | 'story' | 'task' +type Op = 'I' | 'U' | 'D' + +interface InitialData { + pbis: BacklogPbi[] + storiesByPbi: Record + tasksByStory: Record +} + +interface BacklogStore extends InitialData { + setInitialData: (data: InitialData) => void + applyChange: (entity: Entity, op: Op, data: Record) => void +} + +export const useBacklogStore = create((set) => ({ + pbis: [], + storiesByPbi: {}, + tasksByStory: {}, + + setInitialData: (data) => set(data), + + applyChange: (entity, op, data) => + set((state) => { + if (entity === 'pbi') { + const id = data.id as string + if (op === 'D') { + return { pbis: state.pbis.filter((p) => p.id !== id) } + } + if (op === 'U') { + return { + pbis: state.pbis.map((p) => + p.id === id ? { ...p, ...(data as Partial) } : p + ), + } + } + // I + return { pbis: [...state.pbis, data as unknown as BacklogPbi] } + } + + if (entity === 'story') { + const id = data.id as string + if (op === 'D') { + const storiesByPbi = { ...state.storiesByPbi } + for (const pbiId of Object.keys(storiesByPbi)) { + storiesByPbi[pbiId] = storiesByPbi[pbiId].filter((s) => s.id !== id) + } + return { storiesByPbi } + } + if (op === 'U') { + const storiesByPbi = { ...state.storiesByPbi } + for (const pbiId of Object.keys(storiesByPbi)) { + const idx = storiesByPbi[pbiId].findIndex((s) => s.id === id) + if (idx !== -1) { + storiesByPbi[pbiId] = storiesByPbi[pbiId].map((s) => + s.id === id ? { ...s, ...(data as Partial) } : s + ) + break + } + } + return { storiesByPbi } + } + // I + const pbiId = data.pbi_id as string + return { + storiesByPbi: { + ...state.storiesByPbi, + [pbiId]: [...(state.storiesByPbi[pbiId] ?? []), data as unknown as BacklogStory], + }, + } + } + + // task + const id = data.id as string + if (op === 'D') { + const tasksByStory = { ...state.tasksByStory } + for (const storyId of Object.keys(tasksByStory)) { + tasksByStory[storyId] = tasksByStory[storyId].filter((t) => t.id !== id) + } + return { tasksByStory } + } + if (op === 'U') { + const tasksByStory = { ...state.tasksByStory } + for (const storyId of Object.keys(tasksByStory)) { + const idx = tasksByStory[storyId].findIndex((t) => t.id === id) + if (idx !== -1) { + tasksByStory[storyId] = tasksByStory[storyId].map((t) => + t.id === id ? { ...t, ...(data as Partial) } : t + ) + break + } + } + return { tasksByStory } + } + // I + const storyId = data.story_id as string + return { + tasksByStory: { + ...state.tasksByStory, + [storyId]: [...(state.tasksByStory[storyId] ?? []), data as unknown as BacklogTask], + }, + } + }), +})) From 794f7afd2edfef63f468ef89fe28826a3b611d17 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Thu, 30 Apr 2026 19:43:47 +0200 Subject: [PATCH 021/282] feat: plan_snapshot field on ClaudeJob + architecture doc (#23) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add plan_snapshot field to ClaudeJob schema Nullable String? column on claude_jobs captures the task's implementation_plan at claim time — immutable baseline for drift detection. Co-Authored-By: Claude Sonnet 4.6 * docs: update ClaudeJob lifecycle with plan_snapshot Document state machine snapshot capture/reset, plan_snapshot field rationale, and drift-detection baseline semantics. Co-Authored-By: Claude Sonnet 4.6 * refactor: remove duplicate header labels on backlog page Both the product H1 + description in the page header and the "Product Backlog" panel-title in the PBI panel duplicated info already visible in the NavBar. Removed both, keeping the right-aligned action bars (activate/sprint/settings, plus filters/+PBI) intact. PanelNavBar component is unchanged — Stories and Taken panels keep their titles. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- app/(app)/products/[id]/page.tsx | 10 +- components/backlog/pbi-list.tsx | 158 +++++++++--------- docs/erd.svg | 2 +- docs/scrum4me-architecture.md | 10 +- .../migration.sql | 2 + prisma/schema.prisma | 1 + 6 files changed, 89 insertions(+), 94 deletions(-) create mode 100644 prisma/migrations/20260430171227_add_claude_job_plan_snapshot/migration.sql diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 647d8c6..85519dd 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -92,14 +92,8 @@ export default async function ProductBacklogPage({ params, searchParams }: Props return (
- {/* Product header */} -
-
-

{product.name}

- {product.description && ( -

{product.description}

- )} -
+ {/* Product header — actions only; product-naam zit al in NavBar */} +
{user?.active_product_id !== id && ( diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index 8f79138..3995102 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -24,7 +24,6 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { useSelectionStore } from '@/stores/selection-store' import { usePlannerStore } from '@/stores/planner-store' import { useBacklogStore } from '@/stores/backlog-store' @@ -330,92 +329,87 @@ export function PbiList({ productId, isDemo }: PbiListProps) { return (
- - {filterPriority !== 'all' && ( - - )} - {filterStatus !== 'all' && ( - - )} - - - {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} - - } - /> - - - - -
- -
-
-
- +
+ {filterPriority !== 'all' && ( + + )} + {filterStatus !== 'all' && ( + + )} + + + {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} + + } + /> + + + + +
- - - } - /> +
+
+
+ + + +
{pbis.length === 0 ? ( diff --git a/docs/erd.svg b/docs/erd.svg index fd4b3eb..9c88b70 100644 --- a/docs/erd.svg +++ b/docs/erd.svg @@ -1 +1 @@ -

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

sprint

enum:status

user

product

task

enum:status

claimed_by_token

user

token

product

user

user

product

user

story

task

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

String

branch

String

summary

String

error

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file +

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

sprint

enum:status

user

product

task

enum:status

claimed_by_token

user

token

product

user

user

product

user

story

task

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Bytes

avatar_data

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

String

plan_snapshot

String

branch

String

summary

String

error

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

product_members

String

id

🗝️

DateTime

created_at

todos

String

id

🗝️

String

title

String

description

Boolean

done

Boolean

archived

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file diff --git a/docs/scrum4me-architecture.md b/docs/scrum4me-architecture.md index 6439d5f..4d0a2ce 100644 --- a/docs/scrum4me-architecture.md +++ b/docs/scrum4me-architecture.md @@ -1120,12 +1120,15 @@ Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie ins ### State machine ``` -QUEUED → CLAIMED → RUNNING → DONE - → FAILED +QUEUED → CLAIMED (snapshot capture) → RUNNING → DONE + → FAILED → CANCELLED (door user) -CLAIMED → QUEUED (stale claim cleanup, >30min) +CLAIMED → QUEUED (stale claim cleanup, >30min; snapshot gewist) +QUEUED → CLAIMED (re-claim na stale reset; snapshot refreshed) ``` +**Snapshot-rationale:** bij atomic claim schrijft `wait_for_job` de dan-actuele `task.implementation_plan` naar `claude_jobs.plan_snapshot`. Dit veld blijft bevroren terwijl de job loopt — ook als een gebruiker `update_task_plan` aanroept. Zo kan een toekomstige verify-tool drift detecteren tussen de baseline (snapshot) en de actuele plan. Jobs zonder snapshot (NULL) zijn aangemaakt vóór deze feature en worden als "no baseline" gemarkeerd. + ### ClaudeJob model ``` @@ -1134,6 +1137,7 @@ claude_jobs status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED) claimed_by_token_id (FK → api_tokens, nullable) claimed_at, started_at, finished_at + plan_snapshot: String? — bevroren snapshot van task.implementation_plan bij claim branch, summary, error @@index([user_id, status]) @@index([task_id, status]) diff --git a/prisma/migrations/20260430171227_add_claude_job_plan_snapshot/migration.sql b/prisma/migrations/20260430171227_add_claude_job_plan_snapshot/migration.sql new file mode 100644 index 0000000..b578c1a --- /dev/null +++ b/prisma/migrations/20260430171227_add_claude_job_plan_snapshot/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "claude_jobs" ADD COLUMN "plan_snapshot" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6586980..898b87e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -260,6 +260,7 @@ model ClaudeJob { claimed_at DateTime? started_at DateTime? finished_at DateTime? + plan_snapshot String? branch String? summary String? error String? From 3bb87f17ba581117a376a1fb7f1c90e327fa5628 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 11:41:35 +0200 Subject: [PATCH 022/282] Solo Paneel header refactor + agent-workflow hardening (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: SoloBoard layout naar SplitPane met cookie-persistentie en tab-collapse Co-Authored-By: Claude Sonnet 4.6 * feat: verplaats Live + agent-status indicators naar NavBar Live-dot (SSE-status) en "Agent verbonden / Geen agent" indicator zijn verhuisd van de SoloBoard-header naar de NavBar (rechts, voor de notifications-bell). Data blijft uit useSoloStore komen, gevoed door SoloRealtimeBridge in de (app)-layout. Indicators tonen alleen op /products/[id]/solo — buiten die route is de SSE-stream inactief. Co-Authored-By: Claude Opus 4.7 (1M context) * feat: open SoloRealtimeBridge globaal voor active product SoloRealtimeBridge gated nu op active-product i.p.v. /solo-pad. Live-dot en worker-presence werken daardoor op alle (app)-pagina's (Producten/PB/Sprint/Solo/Todo's). Buiten /solo is de solo-store leeg en zijn task-events no-ops, dus de stream gedraagt zich automatisch als lichte presence-stream tot SoloBoard mount. - realtime-bridge: productId-prop i.p.v. usePathname - (app)/layout: activeProduct?.id doorgegeven aan bridge - nav-status-indicators: pathname-check vervangen door hasActiveProduct prop - nav-bar: hasActiveProduct={!!activeProduct} doorgegeven - architecture-doc: realtime connection lifecycle bijgewerkt Co-Authored-By: Claude Opus 4.7 (1M context) * feat: enqueueAllTodoJobsAction voor batch-queueing van TO_DO-taken Nieuwe Server Action die alle TO_DO-taken van een product zonder actieve ClaudeJob in één $transaction als QUEUED jobs aanmaakt en voor elk een pg_notify('claude_job_enqueued') stuurt zodat de SSE- stream de UI live bijwerkt. - Auth + demo-blokkade + product-access via productAccessFilter - Idempotent: tasks met status QUEUED/CLAIMED/RUNNING worden overgeslagen - 4 nieuwe tests (happy path, count=0, demo-blokkade, geen toegang) Co-Authored-By: Claude Opus 4.7 (1M context) * feat: 'Start agents (n)'-knop in Solo header, productname weg SoloBoard-header toont nu een primary button die het aantal queueable TO_DO-taken telt (TO_DO zonder actieve ClaudeJob via claudeJobsByTaskId-store) en bij klik de nieuwe enqueueAllTodoJobsAction aanroept. Toast geeft het aantal gestarte agents terug. - productname-h1 verwijderd (staat al in NavBar-dropdown, dubbel) - sprintdoel blijft naast de knop - 'Toon openstaande stories'-link blijft rechts - demo-modus disabled met DemoTooltip - batch-pending state voorkomt dubbele klikken - productName-prop weg uit SoloBoard + page.tsx (was alleen voor h1) Co-Authored-By: Claude Opus 4.7 (1M context) * fix: scope enqueueAllTodoJobsAction op actieve sprint + assignee De action queue'de eerder ALLE TO_DO-taken van een product, ongeacht sprint of assignee — terwijl de 'Start agents (n)'-knop in de UI alleen de taken telt die de gebruiker ziet (actieve sprint, eigen stories). Daardoor kreeg een klik op de knop veel meer jobs aangemaakt dan de count suggereerde (62 i.p.v. de getoonde n). Server-filter komt nu overeen met page.tsx solo-query: story: { sprint_id: , assignee_id: userId } Edge case: geen actieve sprint → success met count=0 (geen error). Tests aangepast + nieuwe test voor 'geen actieve sprint'-pad. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(db): trigger sync_task_status_from_claude_job promote task naar DONE Postgres AFTER-trigger op claude_jobs.status zet de bijbehorende task automatisch op DONE zodra de job DONE wordt — werkt ongeacht welke client de update doet (MCP-server, Server Action, raw SQL). Idempotent: WHERE status <> 'DONE' voorkomt no-op updates die de bestaande notify_task_change-trigger zouden doen vuren. Die laatste verzorgt de pg_notify naar /api/realtime/solo zodat de UI synct. - migration: prisma/migrations/20260501110000_sync_task_status_from_claude_job - doc: nieuwe sectie 'Auto-promote task naar DONE' in architecture.md Co-Authored-By: Claude Opus 4.7 (1M context) * fix(ui): vul SoloColumn-kolommen volledige paneelhoogte Buitenste flex-container van SoloColumn miste h-full, waardoor het kader op content-hoogte bleef hangen i.p.v. de hele pane (binnen SplitPane) te vullen. Drop-target was daardoor ook beperkt tot het kleine kader bovenin een lege kolom. Auto-toegepast door een ClaudeJob-agent op task cmomoayt10002bortgp27jwma; co-auteurschap hieronder. Co-Authored-By: Claude Opus 4.7 (1M context) * docs: agent-batch-loop verplichte flow in CLAUDE.md Na een 'pak de volgende job'-instructie liep de agent één job en sloot de turn af, waardoor de gebruiker handmatig opnieuw 'wait_for_job' moest aanroepen voor elke volgende job in de queue. Voeg een expliciete loop-instructie toe onder de MCP-tools-sectie: na elke update_job_status moet de agent opnieuw wait_for_job aanroepen, totdat die na de full block-time terugkomt zonder claim. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- CLAUDE.md | 18 ++- __tests__/actions/claude-jobs.test.ts | 89 ++++++++++- actions/claude-jobs.ts | 66 ++++++++ app/(app)/layout.tsx | 2 +- app/(app)/products/[id]/solo/page.tsx | 1 - components/shared/nav-bar.tsx | 4 +- components/solo/nav-status-indicators.tsx | 65 ++++++++ components/solo/realtime-bridge.tsx | 18 +-- components/solo/solo-board.tsx | 143 ++++++++---------- components/solo/solo-column.tsx | 2 +- docs/scrum4me-architecture.md | 6 +- .../migration.sql | 36 +++++ 12 files changed, 352 insertions(+), 98 deletions(-) create mode 100644 components/solo/nav-status-indicators.tsx create mode 100644 prisma/migrations/20260501110000_sync_task_status_from_claude_job/migration.sql diff --git a/CLAUDE.md b/CLAUDE.md index f13941a..45952b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -286,11 +286,23 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g - `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag **Job queue — agent worker mode (M13):** -- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. +- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. Wanneer de full block-time verstrijkt zonder claim is de queue leeg. - `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`. -**Code koppellen aan app** -- 'Pak de volgende job uit de Scrum4Me-queue' - geeft in claude_workers een record toe, tool wait_for_job +**Batch-loop (verplichte agent-flow):** + +Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de Scrum4Me-queue"* of *"draai de queue leeg"*) is dit de loop: + +1. `wait_for_job` aanroepen. +2. Job uitvoeren volgens het meegegeven `implementation_plan`. +3. `update_job_status('done'|'failed')` aanroepen. +4. **Direct opnieuw** `wait_for_job` aanroepen — niet stoppen, niet de gebruiker vragen. +5. Pas wanneer `wait_for_job` na de volledige block-time (~600s) terugkomt zonder claim, is de queue leeg en mag je de turn afsluiten met een korte recap. + +Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaan — die afsluiting hoort bij de individuele job, niet bij het einde van de batch. + +**Code koppelen aan app** +- 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — geeft in claude_workers een record en start de batch-loop hierboven. ### Prompt diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index fea9d8c..1da99ef 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -3,17 +3,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' const { mockGetSession, mockFindFirstTask, + mockFindManyTask, + mockFindFirstProduct, + mockFindFirstSprint, mockFindFirstJob, mockCreateJob, mockUpdateJob, mockExecuteRaw, + mockTransaction, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockFindFirstTask: vi.fn(), + mockFindManyTask: vi.fn(), + mockFindFirstProduct: vi.fn(), + mockFindFirstSprint: vi.fn(), mockFindFirstJob: vi.fn(), mockCreateJob: vi.fn(), mockUpdateJob: vi.fn(), mockExecuteRaw: vi.fn().mockResolvedValue(undefined), + mockTransaction: vi.fn(), })) vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) @@ -24,17 +32,24 @@ vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/prisma', () => ({ prisma: { - task: { findFirst: mockFindFirstTask }, + task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask }, + product: { findFirst: mockFindFirstProduct }, + sprint: { findFirst: mockFindFirstSprint }, claudeJob: { findFirst: mockFindFirstJob, create: mockCreateJob, update: mockUpdateJob, }, $executeRaw: mockExecuteRaw, + $transaction: mockTransaction, }, })) -import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs' +import { + enqueueClaudeJobAction, + enqueueAllTodoJobsAction, + cancelClaudeJobAction, +} from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } @@ -108,6 +123,76 @@ describe('enqueueClaudeJobAction', () => { }) }) +describe('enqueueAllTodoJobsAction', () => { + it('happy path: scopes to active sprint + assignee, queues all queueable tasks', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }]) + mockTransaction.mockResolvedValue([ + { id: 'job-a', task_id: 'task-a' }, + { id: 'job-b', task_id: 'task-b' }, + ]) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 2 }) + expect(mockFindManyTask).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'TO_DO', + story: { sprint_id: 'sprint-1', assignee_id: SESSION_USER.userId }, + }), + }) + ) + expect(mockExecuteRaw).toHaveBeenCalledTimes(2) + }) + + it('returns count=0 when product has no active sprint', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue(null) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 0 }) + expect(mockFindManyTask).not.toHaveBeenCalled() + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('returns count=0 when no queueable tasks in sprint+assignee scope', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([]) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 0 }) + expect(mockTransaction).not.toHaveBeenCalled() + expect(mockExecuteRaw).not.toHaveBeenCalled() + }) + + it('blocks demo user', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('returns error when product not accessible', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue(null) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: 'Geen toegang tot dit product' }) + expect(mockTransaction).not.toHaveBeenCalled() + }) +}) + describe('cancelClaudeJobAction', () => { it('happy path: cancels QUEUED job', async () => { mockGetSession.mockResolvedValue(SESSION_USER) diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index fa9a1e8..17c55d3 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -10,6 +10,10 @@ type EnqueueResult = | { success: true; jobId: string } | { error: string; jobId?: string } +type EnqueueAllResult = + | { success: true; count: number } + | { error: string } + type CancelResult = { success: true } | { error: string } export async function enqueueClaudeJobAction(taskId: string): Promise { @@ -59,6 +63,68 @@ export async function enqueueClaudeJobAction(taskId: string): Promise { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + if (!productId) return { error: 'product_id is verplicht' } + + const product = await prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!product) return { error: 'Geen toegang tot dit product' } + + const userId = session.userId + + // Match het scope dat de gebruiker op het Solo Paneel ziet: + // alleen TO_DO-taken in de actieve sprint, in stories die aan deze + // gebruiker zijn toegewezen. Anders queue je per ongeluk taken die + // niet in de huidige sprint zitten of aan iemand anders toebehoren. + const sprint = await prisma.sprint.findFirst({ + where: { product_id: productId, status: 'ACTIVE' }, + select: { id: true }, + }) + if (!sprint) return { success: true, count: 0 } + + const tasks = await prisma.task.findMany({ + where: { + status: 'TO_DO', + story: { sprint_id: sprint.id, assignee_id: userId }, + claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } }, + }, + select: { id: true }, + }) + + if (tasks.length === 0) return { success: true, count: 0 } + + const created = await prisma.$transaction( + tasks.map(t => + prisma.claudeJob.create({ + data: { user_id: userId, product_id: productId, task_id: t.id, status: 'QUEUED' }, + select: { id: true, task_id: true }, + }) + ) + ) + + for (const job of created) { + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + task_id: job.task_id, + user_id: userId, + product_id: productId, + status: 'queued', + })}::text) + ` + } + + revalidatePath(`/products/${productId}/solo`) + return { success: true, count: created.length } +} + export async function cancelClaudeJobAction(jobId: string): Promise { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index fa41d4a..384828b 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -92,7 +92,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod {children} - + diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 995aee2..980bcc0 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -105,7 +105,6 @@ export default async function SoloProductPage({ params }: Props) { return ( - {/* Rechts: notifications + account-menu */} + {/* Rechts: solo-status + notifications + account-menu */}
+
diff --git a/components/solo/nav-status-indicators.tsx b/components/solo/nav-status-indicators.tsx new file mode 100644 index 0000000..e370540 --- /dev/null +++ b/components/solo/nav-status-indicators.tsx @@ -0,0 +1,65 @@ +'use client' + +import { useSoloStore } from '@/stores/solo-store' +import type { RealtimeStatus } from '@/stores/solo-store' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' + +function RealtimeIndicator({ + status, + showConnectingIndicator, +}: { + status: RealtimeStatus + showConnectingIndicator: boolean +}) { + let color = 'bg-status-done' + let label = 'Live' + if (showConnectingIndicator) { + if (status === 'disconnected') { + color = 'bg-priority-critical' + label = 'Verbroken — opnieuw proberen…' + } else { + color = 'bg-muted-foreground' + label = 'Verbinden…' + } + } + return ( + + + + } + /> + {label} + + + ) +} + +export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct: boolean }) { + const realtimeStatus = useSoloStore((s) => s.realtimeStatus) + const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) + const connectedWorkers = useSoloStore((s) => s.connectedWorkers) + + if (!hasActiveProduct) return null + + return ( +
+ +
+ 0 ? 'bg-status-done' : 'bg-muted-foreground/40' + )} /> + {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} +
+
+ ) +} diff --git a/components/solo/realtime-bridge.tsx b/components/solo/realtime-bridge.tsx index bd04f69..37cc14b 100644 --- a/components/solo/realtime-bridge.tsx +++ b/components/solo/realtime-bridge.tsx @@ -1,21 +1,17 @@ // SoloRealtimeBridge — mount in de (app)-layout zodat de SSE-verbinding -// blijft staan over Server Action-refreshes van de Solo-page heen. +// blijft staan over Server Action-refreshes heen. // -// Leest het huidige product-id uit de URL (`/products/[id]/solo`). -// Wanneer de gebruiker niet op het Solo Paneel zit, wordt de stream -// gesloten — geen onnodige verbinding open houden. +// Stream opent zodra er een actief product is (ongeacht het pad), zodat +// de Live-status-dot en worker-presence-indicator in de NavBar overal +// werken. Buiten /solo is de solo-store leeg en zijn task-events no-ops +// (zie stores/solo-store.ts handleRealtimeEvent), dus de stream gedraagt +// zich automatisch als lichte presence-stream tot SoloBoard mount. 'use client' -import { usePathname } from 'next/navigation' import { useSoloRealtime } from '@/lib/realtime/use-solo-realtime' -const SOLO_PATH_RE = /^\/products\/([^/]+)\/solo$/ - -export function SoloRealtimeBridge() { - const pathname = usePathname() - const match = pathname?.match(SOLO_PATH_RE) - const productId = match?.[1] ?? null +export function SoloRealtimeBridge({ productId }: { productId: string | null }) { useSoloRealtime(productId) return null } diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index fb50be8..83d220e 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -7,54 +7,16 @@ import { } from '@dnd-kit/core' import { toast } from 'sonner' import { useSoloStore } from '@/stores/solo-store' -import type { RealtimeStatus } from '@/stores/solo-store' import { taskStatusToApi } from '@/lib/task-status' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' +import { enqueueAllTodoJobsAction } from '@/actions/claude-jobs' +import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { SplitPane } from '@/components/split-pane/split-pane' import { SoloColumn, type ColumnStatus } from './solo-column' import { SoloTaskCardOverlay } from './solo-task-card' import { TaskDetailDialog } from './task-detail-dialog' import { UnassignedStoriesSheet, type UnassignedStory } from './unassigned-stories-sheet' -// ST-805: kleine status-dot in de header — groen wanneer SSE-stream open -// is, grijs/rood pas zichtbaar als de connectie >4s niet open is (animatie B -// zit in useSoloRealtime). Default groen tijdens de eerste 4s zodat micro- -// disconnects geen flikker geven. -function RealtimeIndicator({ - status, - showConnectingIndicator, -}: { - status: RealtimeStatus - showConnectingIndicator: boolean -}) { - let color = 'bg-status-done' - let label = 'Live' - if (showConnectingIndicator) { - if (status === 'disconnected') { - color = 'bg-priority-critical' - label = 'Verbroken — opnieuw proberen…' - } else { - color = 'bg-muted-foreground' - label = 'Verbinden…' - } - } - return ( - - - - } - /> - {label} - - - ) -} - export interface SoloTask { id: string title: string @@ -71,7 +33,6 @@ export interface SoloTask { export interface SoloBoardProps { productId: string - productName: string sprintGoal: string tasks: SoloTask[] unassignedStories: UnassignedStory[] @@ -87,17 +48,16 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus { } export function SoloBoard({ - productId, productName, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, + productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, }: SoloBoardProps) { const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore() - const realtimeStatus = useSoloStore((s) => s.realtimeStatus) - const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) - const connectedWorkers = useSoloStore((s) => s.connectedWorkers) + const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId) const [activeDragId, setActiveDragId] = useState(null) const [selectedTask, setSelectedTask] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) const [unassignedStories, setUnassignedStories] = useState(initialUnassigned) const [, startTransition] = useTransition() + const [batchPending, startBatchTransition] = useTransition() const taskKey = initialTasks.map(t => t.id).join(',') useEffect(() => { @@ -169,40 +129,40 @@ export function SoloBoard({ const activeTask = activeDragId ? tasks[activeDragId] : null - const columns = ( -
- {COLUMN_STATUSES.map(status => ( - setSelectedTask(t)} - /> - ))} -
- ) + const queueableCount = columnTasks.TO_DO.filter(t => { + const job = claudeJobsByTaskId[t.id] + return !job || (job.status !== 'queued' && job.status !== 'claimed' && job.status !== 'running') + }).length + + function handleStartAll() { + if (queueableCount === 0) return + startBatchTransition(async () => { + const result = await enqueueAllTodoJobsAction(productId) + if ('error' in result) { + toast.error(result.error) + } else if (result.count === 0) { + toast.info('Geen taken om te starten') + } else { + toast.success(`${result.count} ${result.count === 1 ? 'agent' : 'agents'} ingeschakeld`) + } + }) + } return (
-
-
-

{productName}

- -
- 0 ? 'bg-status-done' : 'bg-muted-foreground/40' - )} /> - {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} -
-
+
+ + + {sprintGoal && ( -

{sprintGoal}

+

{sprintGoal}

)}