Scrum4Me/components/ideas/idea-sync-tab.tsx

236 lines
8.5 KiB
TypeScript

'use client'
// Sync-tab op /ideas/[id] (PBI-36 ST-1219). Toont per Story onder de
// gekoppelde PBI: status, job-rij (ClaudeJobs incl. branch/pushed_at/pr_url),
// en de bestaande activity-log via <StoryLog>. Realtime refresh via
// notifications-SSE: bij elk story_log of relevant claude_job-event triggeren
// we router.refresh() (server-render verzorgt nieuwe data).
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { StoryLog } from '@/components/shared/story-log'
import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status'
import type { ClaudeJobStatusApi } from '@/lib/job-status'
import { debugProps } from '@/lib/debug'
import type { IdeaSyncData } from '@/app/(app)/ideas/[id]/sync-tab-server'
interface Props {
data: IdeaSyncData
}
const TASK_STATUS_LABEL: Record<string, string> = {
TO_DO: 'TO-DO',
IN_PROGRESS: 'Bezig',
REVIEW: 'Review',
DONE: 'Klaar',
}
const TASK_STATUS_COLOR: Record<string, string> = {
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
REVIEW: 'bg-warning/15 text-warning border-warning/30',
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
}
function formatRelative(iso: string | Date | null): string {
if (!iso) return '—'
const d = typeof iso === 'string' ? new Date(iso) : iso
const diffMs = Date.now() - d.getTime()
const min = Math.round(diffMs / 60_000)
if (min < 1) return 'zojuist'
if (min < 60) return `${min} min geleden`
const h = Math.round(min / 60)
if (h < 24) return `${h} u geleden`
return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short' })
}
function jobStatusKey(dbStatus: string): ClaudeJobStatusApi {
return dbStatus.toLowerCase() as ClaudeJobStatusApi
}
export function IdeaSyncTab({ data }: Props) {
const router = useRouter()
const pbi = data.pbi
const storyIdsKey = pbi ? pbi.stories.map((s) => s.id).join(',') : ''
// Realtime refresh op story_log inserts en idea-job updates.
// Listen op de bestaande user-scoped notifications stream (SSE-route filtert
// al op accessibleIdeaIds + accessibleProductIds).
useEffect(() => {
if (!storyIdsKey) return
const storyIds = new Set(storyIdsKey.split(','))
const es = new EventSource('/api/realtime/notifications')
es.addEventListener('message', (ev) => {
try {
const payload = JSON.parse(ev.data)
if (payload.entity === 'story_log' && storyIds.has(payload.story_id)) {
router.refresh()
return
}
if (payload.type === 'claude_job_status' && payload.idea_id === data.id) {
router.refresh()
}
} catch {
// niet-JSON of niet-relevant — negeren
}
})
es.addEventListener('error', () => {
// EventSource probeert zelf opnieuw te verbinden; geen actie nodig.
})
return () => {
es.close()
}
}, [data.id, storyIdsKey, router])
if (!pbi) return null
return (
<div className="space-y-4" {...debugProps('idea-sync-tab', 'IdeaSyncTab', 'components/ideas/idea-sync-tab.tsx')}>
{/* Header: PBI-link + PR-status */}
<div className="flex flex-wrap items-center gap-3 rounded-md border border-border bg-surface-container p-3" data-debug-id="idea-sync-tab__header">
<a
href={`/backlog/${pbi.id}`}
className="font-mono text-sm text-primary hover:underline"
>
{pbi.code}
</a>
<span className="text-sm font-medium">{pbi.title}</span>
<div className="ml-auto flex items-center gap-2">
{pbi.pr_url && (
<a
href={pbi.pr_url}
target="_blank"
rel="noreferrer"
className="text-xs text-primary underline"
>
PR open
</a>
)}
{pbi.pr_merged_at && (
<Badge className="bg-status-done/15 text-status-done border-status-done/30">
Gemerged {formatRelative(pbi.pr_merged_at)}
</Badge>
)}
</div>
</div>
{/* Stories */}
<div data-debug-id="idea-sync-tab__items">
{pbi.stories.length === 0 && (
<p className="text-sm text-muted-foreground italic">
Deze PBI heeft nog geen stories.
</p>
)}
{pbi.stories.map((story) => (
<details
key={story.id}
open
className="rounded-md border border-border bg-card"
>
<summary className="flex cursor-pointer flex-wrap items-center gap-2 px-3 py-2">
<span className="font-mono text-xs text-muted-foreground">
{story.code}
</span>
<span className="text-sm font-medium">{story.title}</span>
<Badge
className={`ml-auto ${TASK_STATUS_COLOR[story.status] ?? 'bg-muted'}`}
>
{TASK_STATUS_LABEL[story.status] ?? story.status}
</Badge>
</summary>
<div className="space-y-3 border-t border-border px-3 py-3">
{/* Tasks + jobs */}
{story.tasks.length === 0 ? (
<p className="text-xs text-muted-foreground">Geen taken.</p>
) : (
<ul className="space-y-2">
{story.tasks.map((task) => {
const latestJob = task.claude_jobs[0]
return (
<li
key={task.id}
className="flex flex-wrap items-center gap-2 rounded border border-border/60 bg-surface-container/40 px-2 py-1.5 text-xs"
>
<span className="font-mono text-muted-foreground">
{task.code}
</span>
<span className="flex-1 truncate">{task.title}</span>
<Badge
className={`${TASK_STATUS_COLOR[task.status] ?? 'bg-muted'}`}
>
{TASK_STATUS_LABEL[task.status] ?? task.status}
</Badge>
{latestJob ? (
<Badge
className={
JOB_STATUS_COLORS[jobStatusKey(latestJob.status)] ??
'bg-muted'
}
>
{JOB_STATUS_LABELS[jobStatusKey(latestJob.status)] ??
latestJob.status}
</Badge>
) : (
<Badge className="bg-muted/60 text-muted-foreground italic">
Geen job
</Badge>
)}
{latestJob?.branch && (
<span className="font-mono text-muted-foreground">
{latestJob.branch}
</span>
)}
{latestJob?.pushed_at && (
<span className="text-muted-foreground">
gepusht {formatRelative(latestJob.pushed_at)}
</span>
)}
{latestJob?.pr_url && (
<a
href={latestJob.pr_url}
target="_blank"
rel="noreferrer"
className="text-primary underline"
>
PR
</a>
)}
</li>
)
})}
</ul>
)}
{/* Activity log (StoryLog hergebruik) */}
<div>
<h4 className="mb-1 text-xs font-medium text-muted-foreground">
Activiteit
</h4>
<StoryLog
logs={story.logs.map((l) => ({
id: l.id,
type: l.type,
content: l.content,
status: l.status,
commit_hash: l.commit_hash,
commit_message: l.commit_message,
created_at:
typeof l.created_at === 'string'
? l.created_at
: l.created_at.toISOString(),
}))}
repoUrl={data.product?.repo_url ?? null}
/>
</div>
</div>
</details>
))}
</div>
</div>
)
}