'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 . 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 = { TO_DO: 'TO-DO', IN_PROGRESS: 'Bezig', REVIEW: 'Review', DONE: 'Klaar', } const TASK_STATUS_COLOR: Record = { 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 (
{/* Header: PBI-link + PR-status */}
{pbi.code} {pbi.title}
{pbi.pr_url && ( PR open )} {pbi.pr_merged_at && ( Gemerged {formatRelative(pbi.pr_merged_at)} )}
{/* Stories */}
{pbi.stories.length === 0 && (

Deze PBI heeft nog geen stories.

)} {pbi.stories.map((story) => (
{story.code} {story.title} {TASK_STATUS_LABEL[story.status] ?? story.status}
{/* Tasks + jobs */} {story.tasks.length === 0 ? (

Geen taken.

) : (
    {story.tasks.map((task) => { const latestJob = task.claude_jobs[0] return (
  • {task.code} {task.title} {TASK_STATUS_LABEL[task.status] ?? task.status} {latestJob ? ( {JOB_STATUS_LABELS[jobStatusKey(latestJob.status)] ?? latestJob.status} ) : ( Geen job )} {latestJob?.branch && ( {latestJob.branch} )} {latestJob?.pushed_at && ( gepusht {formatRelative(latestJob.pushed_at)} )} {latestJob?.pr_url && ( PR )}
  • ) })}
)} {/* Activity log (StoryLog hergebruik) */}

Activiteit

({ 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} />
))}
) }