From 2a6386163c3f14ec3e448bee9d500ad8645ed687 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Fri, 15 May 2026 01:25:20 +0200 Subject: [PATCH] Sprint: Jobs scherm (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(jobs): extraheer job-mapper naar lib/jobs-mapper.ts + voeg breadcrumb-velden toe Verplaatst JobWithRelations, JOB_INCLUDE, RawJob, PriceRow, pickDescription, computeCost en mapJob naar lib/jobs-mapper.ts (zonder 'use server'). Voegt buildPriceMap helper toe en breidt de types uit met productCode, storyCode en pbiCode via task->story->pbi en product.code includes. Co-Authored-By: Claude Sonnet 4.6 * feat(jobs): voeg GET /api/jobs/[id] route toe + tests * feat(jobs): useJobsRealtime fetch-on-unknown met dedup-Set Wanneer een SSE-event een onbekend job_id bevat, haalt de hook de volledige job op via GET /api/jobs/[id] en upsert die in de store. Een inFlight-Set voorkomt gelijktijdige dubbele fetches voor hetzelfde job_id. Bekende jobs blijven de bestaande partial-upsert gebruiken. Zelfde logica in jobs_initial. Co-Authored-By: Claude Sonnet 4.6 * feat(jobs): JobCard breadcrumb + datum-fallback per kind Voeg productCode/pbiCode/storyCode/startedAt/finishedAt toe aan JobCardProps; bouw breadcrumb per job-kind en toon finishedAt → startedAt → createdAt als datum. JobsColumn geeft de nieuwe velden door. Co-Authored-By: Claude Sonnet 4.6 * test(jobs): JobCard breadcrumb + datum-fallback tests --------- Co-authored-by: Claude Sonnet 4.6 --- .../app/api/jobs/job-by-id-route.test.ts | 106 ++++++++++++ __tests__/components/jobs/job-card.test.tsx | 85 ++++++++++ .../components/jobs/job-detail-pane.test.tsx | 3 + __tests__/hooks/use-jobs-realtime.test.tsx | 147 ++++++++++++++++ actions/jobs-page.ts | 148 +--------------- app/api/jobs/[id]/route.ts | 30 ++++ components/jobs/job-card.tsx | 33 +++- components/jobs/jobs-column.tsx | 5 + hooks/use-jobs-realtime.ts | 45 ++++- lib/jobs-mapper.ts | 159 ++++++++++++++++++ 10 files changed, 605 insertions(+), 156 deletions(-) create mode 100644 __tests__/app/api/jobs/job-by-id-route.test.ts create mode 100644 __tests__/components/jobs/job-card.test.tsx create mode 100644 __tests__/hooks/use-jobs-realtime.test.tsx create mode 100644 app/api/jobs/[id]/route.ts create mode 100644 lib/jobs-mapper.ts diff --git a/__tests__/app/api/jobs/job-by-id-route.test.ts b/__tests__/app/api/jobs/job-by-id-route.test.ts new file mode 100644 index 0000000..a9d2c1a --- /dev/null +++ b/__tests__/app/api/jobs/job-by-id-route.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession, mockFindFirstJob, mockFindManyPrice } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockFindFirstJob: vi.fn(), + mockFindManyPrice: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/prisma', () => ({ + prisma: { + claudeJob: { findFirst: mockFindFirstJob }, + modelPrice: { findMany: mockFindManyPrice }, + }, +})) + +import { GET } from '@/app/api/jobs/[id]/route' + +function makeParams(id = 'job-1'): { params: Promise<{ id: string }> } { + return { params: Promise.resolve({ id }) } +} + +function makeRequest(id = 'job-1'): Request { + return new Request(`http://localhost/api/jobs/${id}`) +} + +const RAW_JOB = { + id: 'job-1', + kind: 'TASK_IMPLEMENTATION' as const, + status: 'DONE' as const, + model_id: 'claude-sonnet-4-6', + input_tokens: 100, + output_tokens: 50, + cache_read_tokens: 0, + cache_write_tokens: 0, + branch: 'feat/test', + pr_url: null, + error: null, + summary: 'Done', + verify_result: 'ALIGNED' as const, + started_at: new Date('2026-01-01T10:00:00Z'), + finished_at: new Date('2026-01-01T10:05:00Z'), + created_at: new Date('2026-01-01T09:59:00Z'), + sprint_run_id: null, + task: { + code: 'T-42', + title: 'Some task', + description: null, + implementation_plan: 'Do the thing', + story: { code: 'S-10', pbi: { code: 'PBI-5' } }, + }, + idea: null, + product: { name: 'Scrum4Me', code: 'SCR' }, + sprint_run: null, +} + +describe('GET /api/jobs/:id', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ userId: 'user-1' }) + mockFindFirstJob.mockResolvedValue(RAW_JOB) + mockFindManyPrice.mockResolvedValue([]) + }) + + it('returns 401 when not logged in', async () => { + mockGetSession.mockResolvedValue({ userId: undefined }) + const res = await GET(makeRequest() as never, makeParams()) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBeTruthy() + }) + + it('returns 404 when job not found', async () => { + mockFindFirstJob.mockResolvedValue(null) + const res = await GET(makeRequest() as never, makeParams()) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBeTruthy() + }) + + it('queries with user_id filter to prevent cross-user access', async () => { + await GET(makeRequest() as never, makeParams()) + expect(mockFindFirstJob).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'job-1', user_id: 'user-1' }, + }) + ) + }) + + it('returns 200 with mapped job shape including breadcrumb codes', async () => { + const res = await GET(makeRequest() as never, makeParams()) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toMatchObject({ + id: 'job-1', + kind: 'TASK_IMPLEMENTATION', + status: 'DONE', + taskCode: 'T-42', + taskTitle: 'Some task', + productCode: 'SCR', + storyCode: 'S-10', + pbiCode: 'PBI-5', + branch: 'feat/test', + }) + }) +}) diff --git a/__tests__/components/jobs/job-card.test.tsx b/__tests__/components/jobs/job-card.test.tsx new file mode 100644 index 0000000..09bc3a2 --- /dev/null +++ b/__tests__/components/jobs/job-card.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' +import JobCard from '@/components/jobs/job-card' + +const BASE_PROPS = { + id: 'job-1', + kind: 'TASK_IMPLEMENTATION' as const, + status: 'RUNNING' as const, + productName: 'Scrum4Me', + productCode: 'S4M', + pbiCode: 'PBI-1', + storyCode: 'ST-1', + createdAt: new Date('2026-01-01T10:00:00Z'), +} + +describe('JobCard breadcrumb', () => { + it('TASK-job toont productCode, pbiCode en storyCode in de breadcrumb', () => { + render() + const breadcrumb = screen.getByText('S4M PBI-1 ST-1') + expect(breadcrumb).toBeInTheDocument() + }) + + it('TASK-job zonder productCode valt terug op productName in de breadcrumb', () => { + render() + expect(screen.getByText('Scrum4Me PBI-1 ST-1')).toBeInTheDocument() + }) + + it('TASK-job laat ontbrekende codes weg uit de breadcrumb', () => { + render() + expect(screen.getByText('S4M')).toBeInTheDocument() + }) + + it('GRILL-job toont productCode en ideaCode', () => { + render( + , + ) + expect(screen.getByText('S4M IDEA-5')).toBeInTheDocument() + }) + + it('SPRINT-job toont productCode en sprintCode', () => { + render( + , + ) + expect(screen.getByText('S4M SP-3')).toBeInTheDocument() + }) +}) + +describe('JobCard datumweergave', () => { + it('toont finishedAt als die beschikbaar is', () => { + const finishedAt = new Date('2026-03-15T14:30:00Z') + render() + const formatted = finishedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) + expect(screen.getByText(formatted)).toBeInTheDocument() + }) + + it('toont startedAt als finishedAt ontbreekt', () => { + const startedAt = new Date('2026-03-10T09:00:00Z') + render() + const formatted = startedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) + expect(screen.getByText(formatted)).toBeInTheDocument() + }) + + it('toont createdAt als zowel finishedAt als startedAt ontbreken', () => { + const createdAt = new Date('2026-01-01T10:00:00Z') + render() + const formatted = createdAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) + expect(screen.getByText(formatted)).toBeInTheDocument() + }) +}) diff --git a/__tests__/components/jobs/job-detail-pane.test.tsx b/__tests__/components/jobs/job-detail-pane.test.tsx index 9e51f87..9a5d0f6 100644 --- a/__tests__/components/jobs/job-detail-pane.test.tsx +++ b/__tests__/components/jobs/job-detail-pane.test.tsx @@ -27,6 +27,9 @@ function makeJob(status: JobWithRelations['status']): JobWithRelations { sprintGoal: null, sprintCode: null, productName: 'Scrum4Me', + productCode: null, + storyCode: null, + pbiCode: null, modelId: null, inputTokens: null, outputTokens: null, diff --git a/__tests__/hooks/use-jobs-realtime.test.tsx b/__tests__/hooks/use-jobs-realtime.test.tsx new file mode 100644 index 0000000..49b9817 --- /dev/null +++ b/__tests__/hooks/use-jobs-realtime.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useJobsStore } from '@/stores/jobs-store' +import useJobsRealtime from '@/hooks/use-jobs-realtime' + +type Listener = (event: { data: string }) => void + +class MockEventSource { + static instance: MockEventSource | null = null + private listeners: Record = {} + onerror: (() => void) | null = null + + constructor(_url: string) { + MockEventSource.instance = this + } + + addEventListener(type: string, listener: Listener) { + if (!this.listeners[type]) this.listeners[type] = [] + this.listeners[type].push(listener) + } + + dispatch(type: string, data: unknown) { + for (const l of this.listeners[type] ?? []) { + l({ data: JSON.stringify(data) }) + } + } + + close() {} +} + +const fullJob = { + id: 'job-unknown-1', + kind: 'TASK_IMPLEMENTATION', + status: 'RUNNING', + taskCode: 'T-1', + taskTitle: 'Test', + ideaCode: null, + ideaTitle: null, + sprintGoal: null, + sprintCode: null, + productName: 'Scrum4Me', + productCode: null, + storyCode: null, + pbiCode: null, + modelId: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + costUsd: null, + branch: null, + prUrl: null, + error: null, + summary: null, + description: null, + verifyResult: null, + startedAt: null, + finishedAt: null, + createdAt: new Date('2026-01-01'), + sprintRunId: null, +} + +beforeEach(() => { + vi.stubGlobal('EventSource', MockEventSource) + MockEventSource.instance = null + + // Lege store + useJobsStore.setState({ activeJobs: [], doneJobs: [], selectedJobId: null }) + + // fetch resolveert naar de volledige job + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(async () => ({ + ok: true, + json: async () => fullJob, + })) + ) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('useJobsRealtime: fetch-on-unknown', () => { + it('haalt onbekende job op via REST bij message-event', async () => { + renderHook(() => useJobsRealtime()) + const es = MockEventSource.instance! + + // Dispatch twee events met hetzelfde onbekende job_id gelijktijdig + act(() => { + es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' }) + es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' }) + }) + + // Wacht op alle microtasks / fetch-promises + await act(async () => { + await Promise.resolve() + }) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1') + + const { activeJobs } = useJobsStore.getState() + expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true) + expect(activeJobs.find(j => j.id === 'job-unknown-1')?.taskTitle).toBe('Test') + }) + + it('gebruikt partial-upsert voor bekende jobs bij message-event', async () => { + // Zet een bekende job in de store + useJobsStore.setState({ + activeJobs: [{ ...fullJob, id: 'job-known-1', status: 'QUEUED' } as never], + doneJobs: [], + selectedJobId: null, + }) + + renderHook(() => useJobsRealtime()) + const es = MockEventSource.instance! + + act(() => { + es.dispatch('message', { job_id: 'job-known-1', status: 'RUNNING', branch: 'feat/x' }) + }) + + await act(async () => { await Promise.resolve() }) + + expect(fetch).not.toHaveBeenCalled() + const { activeJobs } = useJobsStore.getState() + expect(activeJobs.find(j => j.id === 'job-known-1')?.status).toBe('RUNNING') + }) + + it('haalt onbekende job op via REST bij jobs_initial-event', async () => { + renderHook(() => useJobsRealtime()) + const es = MockEventSource.instance! + + act(() => { + es.dispatch('jobs_initial', [{ job_id: 'job-unknown-1', status: 'RUNNING' }]) + }) + + await act(async () => { await Promise.resolve() }) + + expect(fetch).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1') + const { activeJobs } = useJobsStore.getState() + expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true) + }) +}) diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts index 271a187..22876a5 100644 --- a/actions/jobs-page.ts +++ b/actions/jobs-page.ts @@ -2,146 +2,10 @@ import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' -import type { ClaudeJobKind, ClaudeJobStatus, VerifyResult } from '@prisma/client' +import { JOB_INCLUDE, mapJob, buildPriceMap } from '@/lib/jobs-mapper' +import type { RawJob, JobWithRelations, PriceRow } from '@/lib/jobs-mapper' -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 - costUsd: number | null - branch: string | null - prUrl: string | null - error: string | null - summary: string | null - description: 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, description: true, implementation_plan: true } }, - idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } }, - product: { select: { name: true } }, - sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } }, -} as const - -type RawJob = { - 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 - description: string | null - implementation_plan: string | null - } | null - idea: { - code: string | null - title: string - description: string | null - grill_md: string | null - plan_md: string | null - } | null - product: { name: string } - sprint_run: { sprint: { sprint_goal: string; code: string } } | null -} - -type PriceRow = { - model_id: string - input_price_per_1m: { toString: () => string } - output_price_per_1m: { toString: () => string } - cache_read_price_per_1m: { toString: () => string } - cache_write_price_per_1m: { toString: () => string } -} - -function pickDescription(j: RawJob): string | null { - switch (j.kind) { - case 'TASK_IMPLEMENTATION': - return j.task?.implementation_plan ?? j.task?.description ?? null - case 'IDEA_GRILL': - return j.idea?.grill_md ?? j.idea?.description ?? null - case 'IDEA_MAKE_PLAN': - return j.idea?.plan_md ?? j.idea?.description ?? null - case 'PLAN_CHAT': - return j.idea?.description ?? null - case 'SPRINT_IMPLEMENTATION': - return null - default: - return null - } -} - -function computeCost(j: RawJob, priceMap: Map): number | null { - if (!j.model_id) return null - const p = priceMap.get(j.model_id) - if (!p || j.input_tokens == null) return null - return ( - ((j.input_tokens ?? 0) * Number(p.input_price_per_1m.toString())) / 1_000_000 + - ((j.output_tokens ?? 0) * Number(p.output_price_per_1m.toString())) / 1_000_000 + - ((j.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m.toString())) / 1_000_000 + - ((j.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m.toString())) / 1_000_000 - ) -} - -function mapJob(j: RawJob, priceMap: Map): 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, - costUsd: computeCost(j, priceMap), - branch: j.branch, - prUrl: j.pr_url, - error: j.error, - summary: j.summary, - description: pickDescription(j), - verifyResult: j.verify_result, - startedAt: j.started_at, - finishedAt: j.finished_at, - createdAt: j.created_at, - sprintRunId: j.sprint_run_id, - } -} +export type { JobWithRelations } from '@/lib/jobs-mapper' export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelations[]; doneJobs: JobWithRelations[] } | null> { const session = await getSession() @@ -162,10 +26,10 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation prisma.modelPrice.findMany(), ]) - const priceMap = new Map(prices.map((p) => [p.model_id, p as unknown as PriceRow])) + const priceMap = buildPriceMap(prices as unknown as PriceRow[]) return { - activeJobs: active.map((j) => mapJob(j as RawJob, priceMap)), - doneJobs: done.map((j) => mapJob(j as RawJob, priceMap)), + activeJobs: active.map((j) => mapJob(j as unknown as RawJob, priceMap)), + doneJobs: done.map((j) => mapJob(j as unknown as RawJob, priceMap)), } } diff --git a/app/api/jobs/[id]/route.ts b/app/api/jobs/[id]/route.ts new file mode 100644 index 0000000..fd11b89 --- /dev/null +++ b/app/api/jobs/[id]/route.ts @@ -0,0 +1,30 @@ +import type { NextRequest } from 'next/server' +import { getSession } from '@/lib/auth' +import { prisma } from '@/lib/prisma' +import { JOB_INCLUDE, buildPriceMap, mapJob } from '@/lib/jobs-mapper' +import type { PriceRow, RawJob } from '@/lib/jobs-mapper' + +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 { id } = await params + + const job = await prisma.claudeJob.findFirst({ + where: { id, user_id: session.userId }, + include: JOB_INCLUDE, + }) + + if (!job) { + return Response.json({ error: 'Job niet gevonden' }, { status: 404 }) + } + + const prices = await prisma.modelPrice.findMany() + const priceMap = buildPriceMap(prices as PriceRow[]) + return Response.json(mapJob(job as RawJob, priceMap)) +} diff --git a/components/jobs/job-card.tsx b/components/jobs/job-card.tsx index 99f5cc8..ab5184f 100644 --- a/components/jobs/job-card.tsx +++ b/components/jobs/job-card.tsx @@ -17,10 +17,15 @@ interface JobCardProps { sprintGoal?: string | null sprintCode?: string | null productName: string + productCode?: string | null + pbiCode?: string | null + storyCode?: string | null branch?: string | null error?: string | null summary?: string | null createdAt: Date | string + startedAt?: Date | string | null + finishedAt?: Date | string | null isSelected?: boolean onClick?: () => void } @@ -36,7 +41,8 @@ const KIND_LABELS: Record = { export default function JobCard({ kind, status, taskCode, taskTitle, ideaCode, ideaTitle, - sprintGoal, sprintCode, productName, branch, error, createdAt, isSelected, onClick, + sprintGoal, sprintCode, productName, productCode, pbiCode, storyCode, + branch, error, createdAt, startedAt, finishedAt, isSelected, onClick, }: JobCardProps) { let titleText: string if (kind === 'TASK_IMPLEMENTATION') { @@ -52,7 +58,19 @@ export default function JobCard({ titleText = 'Job' } + let breadcrumb: string + if (kind === 'TASK_IMPLEMENTATION') { + breadcrumb = [productCode ?? productName, pbiCode, storyCode].filter(Boolean).join(' ') + } else if (kind === 'IDEA_GRILL' || kind === 'IDEA_MAKE_PLAN' || kind === 'IDEA_REVIEW_PLAN' || kind === 'PLAN_CHAT') { + breadcrumb = [productCode ?? productName, ideaCode].filter(Boolean).join(' ') + } else if (kind === 'SPRINT_IMPLEMENTATION') { + breadcrumb = [productCode ?? productName, sprintCode].filter(Boolean).join(' ') + } else { + breadcrumb = productCode ?? productName + } + const detailText = branch || (error ? error.slice(0, 80) : null) || productName + const displayDate = finishedAt ?? startedAt ?? createdAt const apiStatus = jobStatusToApi(status) @@ -65,11 +83,16 @@ export default function JobCard({ )} {...debugProps('job-card', 'JobCard', 'components/jobs/job-card.tsx')} > -
- +
+ {KIND_LABELS[kind]} - + {breadcrumb && ( + + {breadcrumb} + + )} + {JOB_STATUS_LABELS[apiStatus]}
@@ -77,7 +100,7 @@ export default function JobCard({

{detailText}

- {new Date(createdAt).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} + {new Date(displayDate).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })}
diff --git a/components/jobs/jobs-column.tsx b/components/jobs/jobs-column.tsx index f535e40..ffde6c5 100644 --- a/components/jobs/jobs-column.tsx +++ b/components/jobs/jobs-column.tsx @@ -246,10 +246,15 @@ export default function JobsColumn({ sprintGoal={j.sprintGoal} sprintCode={j.sprintCode} productName={j.productName} + productCode={j.productCode} + pbiCode={j.pbiCode} + storyCode={j.storyCode} branch={j.branch} error={j.error} summary={j.summary} createdAt={j.createdAt} + startedAt={j.startedAt} + finishedAt={j.finishedAt} isSelected={j.id === selectedJobId} onClick={() => onSelect(j.id)} /> diff --git a/hooks/use-jobs-realtime.ts b/hooks/use-jobs-realtime.ts index b15cd0a..8b2cd15 100644 --- a/hooks/use-jobs-realtime.ts +++ b/hooks/use-jobs-realtime.ts @@ -24,6 +24,22 @@ export default function useJobsRealtime() { let es: EventSource | null = null let reconnectTimer: ReturnType | null = null let active = true + const inFlight = new Set() + + async function fetchAndUpsert(jobId: string) { + if (inFlight.has(jobId)) return + inFlight.add(jobId) + try { + const res = await fetch(`/api/jobs/${jobId}`) + if (!res.ok) return + const job = await res.json() + if (active) upsertJob(job) + } catch { + // netwerk-/parse-fout: stil + } finally { + inFlight.delete(jobId) + } + } function connect() { if (!active) return @@ -34,20 +50,25 @@ export default function useJobsRealtime() { // De server stuurt JobPayload[] (met `job_id`), niet JobWithRelations[]. // Daarom geen initJobs-overwrite — de SSR-fetch heeft de volledige // shape al in de store geplaatst. We reconcileren alleen status/branch - // van bekende jobs en pushen onbekende jobs (nieuw aangemaakt tussen - // SSR en SSE-connect) als partials. + // van bekende jobs en fetchen onbekende jobs volledig via REST. try { const payload = JSON.parse(event.data) if (!Array.isArray(payload)) return + const { activeJobs, doneJobs } = useJobsStore.getState() for (const p of payload as JobStatusPayload[]) { if (!p.job_id) continue - upsertJob({ - id: p.job_id, - status: p.status as ClaudeJobStatus, - branch: p.branch ?? null, - error: p.error ?? null, - summary: p.summary ?? null, - }) + const known = activeJobs.some(j => j.id === p.job_id) || doneJobs.some(j => j.id === p.job_id) + if (!known) { + void fetchAndUpsert(p.job_id) + } else { + upsertJob({ + id: p.job_id, + status: p.status as ClaudeJobStatus, + branch: p.branch ?? null, + error: p.error ?? null, + summary: p.summary ?? null, + }) + } } } catch { // malformed JSON @@ -58,6 +79,12 @@ export default function useJobsRealtime() { try { const payload = JSON.parse(event.data) as JobStatusPayload if (!payload.job_id) return + const { activeJobs, doneJobs } = useJobsStore.getState() + const known = activeJobs.some(j => j.id === payload.job_id) || doneJobs.some(j => j.id === payload.job_id) + if (!known) { + void fetchAndUpsert(payload.job_id) + return + } upsertJob({ id: payload.job_id, status: payload.status as ClaudeJobStatus, diff --git a/lib/jobs-mapper.ts b/lib/jobs-mapper.ts new file mode 100644 index 0000000..e9746cd --- /dev/null +++ b/lib/jobs-mapper.ts @@ -0,0 +1,159 @@ +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 + productCode: string | null + storyCode: string | null + pbiCode: string | null + modelId: string | null + inputTokens: number | null + outputTokens: number | null + cacheReadTokens: number | null + cacheWriteTokens: number | null + costUsd: number | null + branch: string | null + prUrl: string | null + error: string | null + summary: string | null + description: string | null + verifyResult: VerifyResult | null + startedAt: Date | null + finishedAt: Date | null + createdAt: Date + sprintRunId: string | null +} + +export const JOB_INCLUDE = { + task: { + select: { + code: true, + title: true, + description: true, + implementation_plan: true, + story: { select: { code: true, pbi: { select: { code: true } } } }, + }, + }, + idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } }, + product: { select: { name: true, code: true } }, + sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } }, +} as const + +export type RawJob = { + 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 + description: string | null + implementation_plan: string | null + story: { code: string; pbi: { code: string } } | null + } | null + idea: { + code: string | null + title: string + description: string | null + grill_md: string | null + plan_md: string | null + } | null + product: { name: string; code: string | null } + sprint_run: { sprint: { sprint_goal: string; code: string } } | null +} + +export type PriceRow = { + model_id: string + input_price_per_1m: { toString: () => string } + output_price_per_1m: { toString: () => string } + cache_read_price_per_1m: { toString: () => string } + cache_write_price_per_1m: { toString: () => string } +} + +export function buildPriceMap(prices: PriceRow[]): Map { + return new Map(prices.map((p) => [p.model_id, p])) +} + +export function pickDescription(j: RawJob): string | null { + switch (j.kind) { + case 'TASK_IMPLEMENTATION': + return j.task?.implementation_plan ?? j.task?.description ?? null + case 'IDEA_GRILL': + return j.idea?.grill_md ?? j.idea?.description ?? null + case 'IDEA_MAKE_PLAN': + return j.idea?.plan_md ?? j.idea?.description ?? null + case 'PLAN_CHAT': + return j.idea?.description ?? null + case 'SPRINT_IMPLEMENTATION': + return null + default: + return null + } +} + +export function computeCost(j: RawJob, priceMap: Map): number | null { + if (!j.model_id) return null + const p = priceMap.get(j.model_id) + if (!p || j.input_tokens == null) return null + return ( + ((j.input_tokens ?? 0) * Number(p.input_price_per_1m.toString())) / 1_000_000 + + ((j.output_tokens ?? 0) * Number(p.output_price_per_1m.toString())) / 1_000_000 + + ((j.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m.toString())) / 1_000_000 + + ((j.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m.toString())) / 1_000_000 + ) +} + +export function mapJob(j: RawJob, priceMap: Map): 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, + productCode: j.product.code ?? null, + storyCode: j.task?.story?.code ?? null, + pbiCode: j.task?.story?.pbi?.code ?? null, + modelId: j.model_id, + inputTokens: j.input_tokens, + outputTokens: j.output_tokens, + cacheReadTokens: j.cache_read_tokens, + cacheWriteTokens: j.cache_write_tokens, + costUsd: computeCost(j, priceMap), + branch: j.branch, + prUrl: j.pr_url, + error: j.error, + summary: j.summary, + description: pickDescription(j), + verifyResult: j.verify_result, + startedAt: j.started_at, + finishedAt: j.finished_at, + createdAt: j.created_at, + sprintRunId: j.sprint_run_id, + } +}