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