diff --git a/__tests__/components/solo/solo-task-card.test.tsx b/__tests__/components/solo/solo-task-card.test.tsx new file mode 100644 index 0000000..f7a8493 --- /dev/null +++ b/__tests__/components/solo/solo-task-card.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom' +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import type { SoloTask } from '@/components/solo/solo-board' + +vi.mock('@/components/ui/tooltip', () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children?: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => {children}, +})) +vi.mock('@dnd-kit/core', () => ({ + useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: vi.fn(), transform: null, isDragging: false }), +})) +vi.mock('@/stores/solo-store', () => ({ + useSoloStore: () => null, +})) +vi.mock('@/components/shared/code-badge', () => ({ + CodeBadge: ({ code }: { code: string }) => {code}, +})) + +import { SoloTaskCard, SoloTaskCardOverlay } from '@/components/solo/solo-task-card' + +function makeSoloTask(overrides: Partial = {}): SoloTask { + return { + id: 'task-1', + title: 'Taak titel', + description: 'Omschrijving van de taak die langer is dan tachtig tekens voor test', + implementation_plan: null, + priority: 2, + sort_order: 0, + status: 'TO_DO', + verify_only: false, + verify_required: 'ALIGNED', + story_id: 'story-1', + story_code: 'ST-1', + story_title: 'Story titel', + task_code: 'T-1', + pbi_code: 'PBI-1', + pbi_title: 'PBI titel', + pbi_description: 'PBI omschrijving', + ...overrides, + } +} + +describe('SoloTaskCard', () => { + it('toont taaknaam, task_code, pbi_code, story_code, story_title', () => { + render() + expect(screen.getAllByText('Taak titel').length).toBeGreaterThan(0) + expect(screen.getAllByText('T-1').length).toBeGreaterThan(0) + expect(screen.getAllByText('PBI-1').length).toBeGreaterThan(0) + expect(screen.getByText('ST-1')).toBeInTheDocument() + expect(screen.getByText('Story titel')).toBeInTheDocument() + }) + + it('verbergt pbi_code badge als pbi_code null is', () => { + render() + const badges = screen.queryAllByTestId('code-badge') + const codes = badges.map(b => b.textContent) + expect(codes).not.toContain('PBI-1') + }) + + it('verbergt description als description null is', () => { + const task = makeSoloTask({ description: null }) + render() + expect(screen.queryByText(/Omschrijving/)).toBeNull() + }) + + it('toont description als tekst', () => { + render() + expect(screen.getAllByText('Omschrijving van de taak die langer is dan tachtig tekens voor test').length).toBeGreaterThan(0) + }) +}) + +describe('SoloTaskCardOverlay', () => { + it('toont taaknaam en codes zonder tooltip-wrappers', () => { + render() + expect(screen.getByText('Taak titel')).toBeInTheDocument() + expect(screen.getByText('T-1')).toBeInTheDocument() + expect(screen.getByText('PBI-1')).toBeInTheDocument() + expect(screen.queryAllByTestId('tooltip-content')).toHaveLength(0) + }) +}) diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts new file mode 100644 index 0000000..ae58804 --- /dev/null +++ b/actions/jobs-page.ts @@ -0,0 +1,114 @@ +'use server' + +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +import type { ClaudeJobKind, ClaudeJobStatus, VerifyResult } from '@prisma/client' + +export type JobWithRelations = { + id: string + kind: ClaudeJobKind + status: ClaudeJobStatus + taskCode: string | null + taskTitle: string | null + ideaCode: string | null + ideaTitle: string | null + sprintGoal: string | null + sprintCode: string | null + productName: string + modelId: string | null + inputTokens: number | null + outputTokens: number | null + cacheReadTokens: number | null + cacheWriteTokens: number | null + branch: string | null + prUrl: string | null + error: string | null + summary: string | null + verifyResult: VerifyResult | null + startedAt: Date | null + finishedAt: Date | null + createdAt: Date + sprintRunId: string | null +} + +const JOB_INCLUDE = { + task: { select: { code: true, title: true } }, + idea: { select: { code: true, title: true } }, + product: { select: { name: true } }, + sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } }, +} as const + +function mapJob(j: { + id: string + kind: ClaudeJobKind + status: ClaudeJobStatus + model_id: string | null + input_tokens: number | null + output_tokens: number | null + cache_read_tokens: number | null + cache_write_tokens: number | null + branch: string | null + pr_url: string | null + error: string | null + summary: string | null + verify_result: VerifyResult | null + started_at: Date | null + finished_at: Date | null + created_at: Date + sprint_run_id: string | null + task: { code: string | null; title: string } | null + idea: { code: string | null; title: string } | null + product: { name: string } + sprint_run: { sprint: { sprint_goal: string; code: string | null } } | null +}): JobWithRelations { + return { + id: j.id, + kind: j.kind, + status: j.status, + taskCode: j.task?.code ?? null, + taskTitle: j.task?.title ?? null, + ideaCode: j.idea?.code ?? null, + ideaTitle: j.idea?.title ?? null, + sprintGoal: j.sprint_run?.sprint.sprint_goal ?? null, + sprintCode: j.sprint_run?.sprint.code ?? null, + productName: j.product.name, + modelId: j.model_id, + inputTokens: j.input_tokens, + outputTokens: j.output_tokens, + cacheReadTokens: j.cache_read_tokens, + cacheWriteTokens: j.cache_write_tokens, + branch: j.branch, + prUrl: j.pr_url, + error: j.error, + summary: j.summary, + verifyResult: j.verify_result, + startedAt: j.started_at, + finishedAt: j.finished_at, + createdAt: j.created_at, + sprintRunId: j.sprint_run_id, + } +} + +export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelations[]; doneJobs: JobWithRelations[] } | null> { + const session = await getSession() + if (!session.userId) return null + + const [active, done] = await Promise.all([ + prisma.claudeJob.findMany({ + where: { user_id: session.userId, status: { notIn: ['DONE'] } }, + include: JOB_INCLUDE, + orderBy: { created_at: 'asc' }, + }), + prisma.claudeJob.findMany({ + where: { user_id: session.userId, status: 'DONE' }, + include: JOB_INCLUDE, + orderBy: { finished_at: 'desc' }, + take: 100, + }), + ]) + + return { + activeJobs: active.map(mapJob), + doneJobs: done.map(mapJob), + } +} diff --git a/app/api/jobs/[id]/sub-tasks/route.ts b/app/api/jobs/[id]/sub-tasks/route.ts new file mode 100644 index 0000000..7e90822 --- /dev/null +++ b/app/api/jobs/[id]/sub-tasks/route.ts @@ -0,0 +1,39 @@ +import type { NextRequest } from 'next/server' +import { getSession } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await getSession() + if (!session.userId) { + return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) + } + const userId = session.userId + const { id } = await params + + const job = await prisma.claudeJob.findFirst({ + where: { id, user_id: userId }, + select: { kind: true }, + }) + + if (!job || job.kind !== 'SPRINT_IMPLEMENTATION') { + return Response.json([], { status: 200 }) + } + + const executions = await prisma.sprintTaskExecution.findMany({ + where: { sprint_job_id: id }, + include: { task: { select: { code: true, title: true } } }, + orderBy: { order: 'asc' }, + }) + + return Response.json( + executions.map(e => ({ + id: e.id, + taskCode: e.task.code, + taskTitle: e.task.title, + status: e.status, + })) + ) +} diff --git a/app/api/realtime/jobs/route.ts b/app/api/realtime/jobs/route.ts new file mode 100644 index 0000000..67edefd --- /dev/null +++ b/app/api/realtime/jobs/route.ts @@ -0,0 +1,170 @@ +import { NextRequest } from 'next/server' +import { Client } from 'pg' +import { getSession } from '@/lib/auth' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' + +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 JobPayload = { + type: 'claude_job_enqueued' | 'claude_job_status' + job_id: string + task_id?: string | null + idea_id?: string | null + sprint_run_id?: string | null + kind?: string + user_id: string + status: string + branch?: string + pushed_at?: string + pr_url?: string + verify_result?: string + summary?: string + error?: string +} + +function shouldEmit(raw: unknown, userId: string): boolean { + if (!raw || typeof raw !== 'object') return false + const p = raw as Record + return 'type' in p && typeof p.user_id === 'string' && p.user_id === userId +} + +export async function GET(request: NextRequest) { + const session = await getSession() + if (!session.userId) { + return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) + } + const userId = session.userId + + 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 al gesloten + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + if (hardCloseTimer) clearTimeout(hardCloseTimer) + await closePgClientSafely(pgClient, 'realtime/jobs') + try { + controller.close() + } catch { + // already closed + } + if (process.env.NODE_ENV !== 'production') { + console.log(`[realtime/jobs] closed: ${reason}`) + } + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + } catch (err) { + console.error('[realtime/jobs] 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: unknown + try { + payload = JSON.parse(msg.payload) + } catch { + return + } + if (!shouldEmit(payload, userId)) return + enqueue(`data: ${msg.payload}\n\n`) + }) + + pgClient.on('error', async (err) => { + console.error('[realtime/jobs] pg client error:', err) + await cleanup('pg error') + }) + + enqueue(`event: ready\ndata: ${JSON.stringify({ user_id: userId })}\n\n`) + + const activeJobs = await prisma_jobs_findActive(userId) + if (activeJobs.length > 0) { + enqueue(`event: jobs_initial\ndata: ${JSON.stringify(activeJobs)}\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', + }, + }) +} + +async function prisma_jobs_findActive(userId: string): Promise { + const { prisma } = await import('@/lib/prisma') + const jobs = await prisma.claudeJob.findMany({ + where: { user_id: userId, status: { notIn: ['DONE'] } }, + select: { + id: true, + kind: true, + status: true, + task_id: true, + idea_id: true, + sprint_run_id: true, + branch: true, + error: true, + summary: true, + }, + }) + return jobs.map(j => ({ + type: 'claude_job_status' as const, + job_id: j.id, + kind: j.kind, + user_id: userId, + status: j.status, + task_id: j.task_id, + idea_id: j.idea_id, + sprint_run_id: j.sprint_run_id, + branch: j.branch ?? undefined, + error: j.error ?? undefined, + summary: j.summary ?? undefined, + })) +} diff --git a/components/jobs/job-card.tsx b/components/jobs/job-card.tsx new file mode 100644 index 0000000..590e743 --- /dev/null +++ b/components/jobs/job-card.tsx @@ -0,0 +1,75 @@ +'use client' + +import { cn } from '@/lib/utils' +import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status' +import { jobStatusToApi } from '@/lib/job-status' +import type { ClaudeJobKind, ClaudeJobStatus } from '@prisma/client' + +interface JobCardProps { + id: string + kind: ClaudeJobKind + status: ClaudeJobStatus + taskCode?: string | null + taskTitle?: string | null + ideaCode?: string | null + ideaTitle?: string | null + sprintGoal?: string | null + sprintCode?: string | null + productName: string + branch?: string | null + error?: string | null + summary?: string | null + isSelected?: boolean + onClick?: () => void +} + +const KIND_LABELS: Record = { + TASK_IMPLEMENTATION: 'TAAK', + SPRINT_IMPLEMENTATION: 'SPRINT', + IDEA_GRILL: 'GRILL', + IDEA_MAKE_PLAN: 'PLAN', + PLAN_CHAT: 'CHAT', +} + +export default function JobCard({ + kind, status, taskCode, taskTitle, ideaCode, ideaTitle, + sprintGoal, sprintCode, productName, branch, error, isSelected, onClick, +}: JobCardProps) { + let titleText: string + if (kind === 'TASK_IMPLEMENTATION') { + titleText = taskCode && taskTitle ? `${taskCode} ${taskTitle}` : taskTitle || 'Taak' + } else if (kind === 'SPRINT_IMPLEMENTATION') { + titleText = sprintGoal || (sprintCode ? `Sprint ${sprintCode}` : 'Sprint') + } else if (kind === 'IDEA_GRILL' || kind === 'IDEA_MAKE_PLAN') { + titleText = ideaCode && ideaTitle ? `${ideaCode} ${ideaTitle}` : ideaTitle || 'Idee' + } else if (kind === 'PLAN_CHAT') { + titleText = ideaCode ? `Chat ${ideaCode}` : 'Chat' + } else { + titleText = 'Job' + } + + const detailText = branch || (error ? error.slice(0, 80) : null) || productName + + const apiStatus = jobStatusToApi(status) + + return ( +
+
+ + {KIND_LABELS[kind]} + + + {JOB_STATUS_LABELS[apiStatus]} + +
+

{titleText}

+

{detailText}

+
+ ) +} diff --git a/components/jobs/job-detail-pane.tsx b/components/jobs/job-detail-pane.tsx new file mode 100644 index 0000000..5f7b2cd --- /dev/null +++ b/components/jobs/job-detail-pane.tsx @@ -0,0 +1,76 @@ +'use client' + +import { cn } from '@/lib/utils' +import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status' +import { jobStatusToApi } from '@/lib/job-status' +import type { JobWithRelations } from '@/actions/jobs-page' + +interface FieldRowProps { + label: string + children: React.ReactNode +} + +function FieldRow({ label, children }: FieldRowProps) { + return ( +
+ {label} + {children} +
+ ) +} + +interface JobDetailPaneProps { + job: JobWithRelations | null +} + +export default function JobDetailPane({ job }: JobDetailPaneProps) { + if (!job) { + return ( +
+ Selecteer een job om details te zien +
+ ) + } + + const apiStatus = jobStatusToApi(job.status) + + return ( +
+ + + {JOB_STATUS_LABELS[apiStatus]} + + + {job.kind} + {job.productName} + {job.modelId || '—'} + {job.inputTokens?.toLocaleString() || '—'} + {job.outputTokens?.toLocaleString() || '—'} + {job.cacheReadTokens?.toLocaleString() || '—'} + {job.cacheWriteTokens?.toLocaleString() || '—'} + + {job.branch || '—'} + + + {job.prUrl ? ( + + PR openen ↗ + + ) : '—'} + + + {job.error ? ( +
+            {job.error}
+          
+ ) : '—'} +
+ + {job.startedAt ? new Date(job.startedAt).toLocaleString('nl-NL') : '—'} + + + {job.finishedAt ? new Date(job.finishedAt).toLocaleString('nl-NL') : '—'} + +
+ ) +}