feat(PBI-59): add Detail/Usage view-switch on /jobs (#152)

Splits het middenpaneel van de jobs-pagina in twee views (zoals admin/jobs):

- **Detail** — alle metadata (status, kind, product, branch, PR, dates,
  errors, summary, verify-result) plus een kind-aware beschrijving:
  TASK → implementation_plan, IDEA_GRILL → grill_md, IDEA_MAKE_PLAN →
  plan_md, PLAN_CHAT → idea.description.
- **Usage** — model, tokens (in/uit/cache/totaal), berekende kosten in
  USD via ModelPrice-tabel, en duur (started→finished).

SprintSubTasksPane blijft als sticky header boven beide views.

Server action `fetchJobsPageData` haalt nu ook ModelPrices op en
selecteert task.{description,implementation_plan} +
idea.{description,grill_md,plan_md} zodat de description en costUsd in
JobWithRelations gevuld kunnen worden.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-07 19:51:53 +02:00 committed by GitHub
parent a7e9ca1c35
commit 16f01283ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 207 additions and 22 deletions

View file

@ -20,10 +20,12 @@ export type JobWithRelations = {
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
@ -32,13 +34,13 @@ export type JobWithRelations = {
}
const JOB_INCLUDE = {
task: { select: { code: true, title: true } },
idea: { select: { code: true, title: true } },
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 } } } },
} as const
function mapJob(j: {
type RawJob = {
id: string
kind: ClaudeJobKind
status: ClaudeJobStatus
@ -56,11 +58,61 @@ function mapJob(j: {
finished_at: Date | null
created_at: Date
sprint_run_id: string | null
task: { code: string | null; title: string } | null
idea: { code: string | null; title: string } | null
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 } } | null
}): JobWithRelations {
}
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,
@ -77,10 +129,12 @@ function mapJob(j: {
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,
@ -93,7 +147,7 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation
const session = await getSession()
if (!session.userId) return null
const [active, done] = await Promise.all([
const [active, done, prices] = await Promise.all([
prisma.claudeJob.findMany({
where: { user_id: session.userId, status: { notIn: ['DONE'] } },
include: JOB_INCLUDE,
@ -105,10 +159,13 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation
orderBy: { finished_at: 'desc' },
take: 100,
}),
prisma.modelPrice.findMany(),
])
const priceMap = new Map<string, PriceRow>(prices.map((p) => [p.model_id, p as unknown as PriceRow]))
return {
activeJobs: active.map(mapJob),
doneJobs: done.map(mapJob),
activeJobs: active.map((j) => mapJob(j as RawJob, priceMap)),
doneJobs: done.map((j) => mapJob(j as RawJob, priceMap)),
}
}