Sprint: Jobs scherm (#209)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* test(jobs): JobCard breadcrumb + datum-fallback tests

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-15 01:25:20 +02:00 committed by GitHub
parent 3d52fe4958
commit 2a6386163c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 605 additions and 156 deletions

View file

@ -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',
})
})
})

View file

@ -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(<JobCard {...BASE_PROPS} />)
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(<JobCard {...BASE_PROPS} productCode={null} />)
expect(screen.getByText('Scrum4Me PBI-1 ST-1')).toBeInTheDocument()
})
it('TASK-job laat ontbrekende codes weg uit de breadcrumb', () => {
render(<JobCard {...BASE_PROPS} pbiCode={null} storyCode={null} />)
expect(screen.getByText('S4M')).toBeInTheDocument()
})
it('GRILL-job toont productCode en ideaCode', () => {
render(
<JobCard
{...BASE_PROPS}
kind="IDEA_GRILL"
productCode="S4M"
ideaCode="IDEA-5"
pbiCode={null}
storyCode={null}
/>,
)
expect(screen.getByText('S4M IDEA-5')).toBeInTheDocument()
})
it('SPRINT-job toont productCode en sprintCode', () => {
render(
<JobCard
{...BASE_PROPS}
kind="SPRINT_IMPLEMENTATION"
productCode="S4M"
sprintCode="SP-3"
pbiCode={null}
storyCode={null}
/>,
)
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(<JobCard {...BASE_PROPS} startedAt={new Date('2026-03-10T09:00:00Z')} finishedAt={finishedAt} />)
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(<JobCard {...BASE_PROPS} startedAt={startedAt} finishedAt={null} />)
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(<JobCard {...BASE_PROPS} createdAt={createdAt} startedAt={null} finishedAt={null} />)
const formatted = createdAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })
expect(screen.getByText(formatted)).toBeInTheDocument()
})
})

View file

@ -27,6 +27,9 @@ function makeJob(status: JobWithRelations['status']): JobWithRelations {
sprintGoal: null, sprintGoal: null,
sprintCode: null, sprintCode: null,
productName: 'Scrum4Me', productName: 'Scrum4Me',
productCode: null,
storyCode: null,
pbiCode: null,
modelId: null, modelId: null,
inputTokens: null, inputTokens: null,
outputTokens: null, outputTokens: null,

View file

@ -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<string, Listener[]> = {}
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)
})
})

View file

@ -2,146 +2,10 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth' 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 = { export type { JobWithRelations } from '@/lib/jobs-mapper'
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<string, PriceRow>): 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<string, PriceRow>): 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 async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelations[]; doneJobs: JobWithRelations[] } | null> { export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelations[]; doneJobs: JobWithRelations[] } | null> {
const session = await getSession() const session = await getSession()
@ -162,10 +26,10 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation
prisma.modelPrice.findMany(), prisma.modelPrice.findMany(),
]) ])
const priceMap = new Map<string, PriceRow>(prices.map((p) => [p.model_id, p as unknown as PriceRow])) const priceMap = buildPriceMap(prices as unknown as PriceRow[])
return { return {
activeJobs: active.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 RawJob, priceMap)), doneJobs: done.map((j) => mapJob(j as unknown as RawJob, priceMap)),
} }
} }

View file

@ -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))
}

View file

@ -17,10 +17,15 @@ interface JobCardProps {
sprintGoal?: string | null sprintGoal?: string | null
sprintCode?: string | null sprintCode?: string | null
productName: string productName: string
productCode?: string | null
pbiCode?: string | null
storyCode?: string | null
branch?: string | null branch?: string | null
error?: string | null error?: string | null
summary?: string | null summary?: string | null
createdAt: Date | string createdAt: Date | string
startedAt?: Date | string | null
finishedAt?: Date | string | null
isSelected?: boolean isSelected?: boolean
onClick?: () => void onClick?: () => void
} }
@ -36,7 +41,8 @@ const KIND_LABELS: Record<ClaudeJobKind, string> = {
export default function JobCard({ export default function JobCard({
kind, status, taskCode, taskTitle, ideaCode, ideaTitle, 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) { }: JobCardProps) {
let titleText: string let titleText: string
if (kind === 'TASK_IMPLEMENTATION') { if (kind === 'TASK_IMPLEMENTATION') {
@ -52,7 +58,19 @@ export default function JobCard({
titleText = 'Job' 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 detailText = branch || (error ? error.slice(0, 80) : null) || productName
const displayDate = finishedAt ?? startedAt ?? createdAt
const apiStatus = jobStatusToApi(status) const apiStatus = jobStatusToApi(status)
@ -65,11 +83,16 @@ export default function JobCard({
)} )}
{...debugProps('job-card', 'JobCard', 'components/jobs/job-card.tsx')} {...debugProps('job-card', 'JobCard', 'components/jobs/job-card.tsx')}
> >
<div className="flex justify-between items-center gap-2" data-debug-id="job-card__status"> <div className="flex items-center gap-2" data-debug-id="job-card__status">
<span className="text-[10px] px-1.5 py-0.5 rounded border bg-muted text-muted-foreground font-mono"> <span className="text-[10px] px-1.5 py-0.5 rounded border bg-muted text-muted-foreground font-mono shrink-0">
{KIND_LABELS[kind]} {KIND_LABELS[kind]}
</span> </span>
<span className={cn('text-xs px-2 py-0.5 rounded-full border font-medium', JOB_STATUS_COLORS[apiStatus])}> {breadcrumb && (
<span className="truncate font-mono text-[10px] text-muted-foreground flex-1 min-w-0">
{breadcrumb}
</span>
)}
<span className={cn('text-xs px-2 py-0.5 rounded-full border font-medium shrink-0 ml-auto', JOB_STATUS_COLORS[apiStatus])}>
{JOB_STATUS_LABELS[apiStatus]} {JOB_STATUS_LABELS[apiStatus]}
</span> </span>
</div> </div>
@ -77,7 +100,7 @@ export default function JobCard({
<div className="flex items-end justify-between gap-2 mt-0.5" data-debug-id="job-card__actions"> <div className="flex items-end justify-between gap-2 mt-0.5" data-debug-id="job-card__actions">
<p className="text-xs text-muted-foreground truncate">{detailText}</p> <p className="text-xs text-muted-foreground truncate">{detailText}</p>
<span className="text-[10px] text-muted-foreground shrink-0 tabular-nums"> <span className="text-[10px] text-muted-foreground shrink-0 tabular-nums">
{new Date(createdAt).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} {new Date(displayDate).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })}
</span> </span>
</div> </div>
</div> </div>

View file

@ -246,10 +246,15 @@ export default function JobsColumn({
sprintGoal={j.sprintGoal} sprintGoal={j.sprintGoal}
sprintCode={j.sprintCode} sprintCode={j.sprintCode}
productName={j.productName} productName={j.productName}
productCode={j.productCode}
pbiCode={j.pbiCode}
storyCode={j.storyCode}
branch={j.branch} branch={j.branch}
error={j.error} error={j.error}
summary={j.summary} summary={j.summary}
createdAt={j.createdAt} createdAt={j.createdAt}
startedAt={j.startedAt}
finishedAt={j.finishedAt}
isSelected={j.id === selectedJobId} isSelected={j.id === selectedJobId}
onClick={() => onSelect(j.id)} onClick={() => onSelect(j.id)}
/> />

View file

@ -24,6 +24,22 @@ export default function useJobsRealtime() {
let es: EventSource | null = null let es: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null let reconnectTimer: ReturnType<typeof setTimeout> | null = null
let active = true let active = true
const inFlight = new Set<string>()
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() { function connect() {
if (!active) return if (!active) return
@ -34,20 +50,25 @@ export default function useJobsRealtime() {
// De server stuurt JobPayload[] (met `job_id`), niet JobWithRelations[]. // De server stuurt JobPayload[] (met `job_id`), niet JobWithRelations[].
// Daarom geen initJobs-overwrite — de SSR-fetch heeft de volledige // Daarom geen initJobs-overwrite — de SSR-fetch heeft de volledige
// shape al in de store geplaatst. We reconcileren alleen status/branch // shape al in de store geplaatst. We reconcileren alleen status/branch
// van bekende jobs en pushen onbekende jobs (nieuw aangemaakt tussen // van bekende jobs en fetchen onbekende jobs volledig via REST.
// SSR en SSE-connect) als partials.
try { try {
const payload = JSON.parse(event.data) const payload = JSON.parse(event.data)
if (!Array.isArray(payload)) return if (!Array.isArray(payload)) return
const { activeJobs, doneJobs } = useJobsStore.getState()
for (const p of payload as JobStatusPayload[]) { for (const p of payload as JobStatusPayload[]) {
if (!p.job_id) continue if (!p.job_id) continue
upsertJob({ const known = activeJobs.some(j => j.id === p.job_id) || doneJobs.some(j => j.id === p.job_id)
id: p.job_id, if (!known) {
status: p.status as ClaudeJobStatus, void fetchAndUpsert(p.job_id)
branch: p.branch ?? null, } else {
error: p.error ?? null, upsertJob({
summary: p.summary ?? null, id: p.job_id,
}) status: p.status as ClaudeJobStatus,
branch: p.branch ?? null,
error: p.error ?? null,
summary: p.summary ?? null,
})
}
} }
} catch { } catch {
// malformed JSON // malformed JSON
@ -58,6 +79,12 @@ export default function useJobsRealtime() {
try { try {
const payload = JSON.parse(event.data) as JobStatusPayload const payload = JSON.parse(event.data) as JobStatusPayload
if (!payload.job_id) return 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({ upsertJob({
id: payload.job_id, id: payload.job_id,
status: payload.status as ClaudeJobStatus, status: payload.status as ClaudeJobStatus,

159
lib/jobs-mapper.ts Normal file
View file

@ -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<string, PriceRow> {
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<string, PriceRow>): 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<string, PriceRow>): 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,
}
}