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:
parent
3d52fe4958
commit
2a6386163c
10 changed files with 605 additions and 156 deletions
106
__tests__/app/api/jobs/job-by-id-route.test.ts
Normal file
106
__tests__/app/api/jobs/job-by-id-route.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
85
__tests__/components/jobs/job-card.test.tsx
Normal file
85
__tests__/components/jobs/job-card.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
147
__tests__/hooks/use-jobs-realtime.test.tsx
Normal file
147
__tests__/hooks/use-jobs-realtime.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<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 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<string, PriceRow>(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)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
app/api/jobs/[id]/route.ts
Normal file
30
app/api/jobs/[id]/route.ts
Normal 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))
|
||||
}
|
||||
|
|
@ -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<ClaudeJobKind, string> = {
|
|||
|
||||
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')}
|
||||
>
|
||||
<div className="flex justify-between 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">
|
||||
<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 shrink-0">
|
||||
{KIND_LABELS[kind]}
|
||||
</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]}
|
||||
</span>
|
||||
</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">
|
||||
<p className="text-xs text-muted-foreground truncate">{detailText}</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,22 @@ export default function useJobsRealtime() {
|
|||
let es: EventSource | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
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() {
|
||||
if (!active) return
|
||||
|
|
@ -34,13 +50,17 @@ 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
|
||||
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,
|
||||
|
|
@ -49,6 +69,7 @@ export default function useJobsRealtime() {
|
|||
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,
|
||||
|
|
|
|||
159
lib/jobs-mapper.ts
Normal file
159
lib/jobs-mapper.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue