Scrum4Me/components/ideas/idea-timeline.tsx
Janpeter Visser d292e445d9
Sprint: Verbeteren debug mode (#179)
* feat(PBI-49): add debugProps helper + Vitest test

Adds lib/debug.ts with debugProps(id, component, file) that returns
data-debug-id and data-debug-label attrs in dev mode, empty object in
production. Adds __tests__/lib/debug.test.ts covering both modes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(PBI-49): add debug-id pattern doc + CLAUDE.md reference

Adds docs/patterns/debug-id.md documenting the named-component boundary
rule (6 punten), helper-voorbeeld, skip-criteria en motivatie voor
handmatige pad-argumenten. Voegt verwijzing toe aan CLAUDE.md
patterns-tabel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(PBI-49): migrate 17 shared/ components to debugProps helper

Replace hardcoded data-debug-id + data-debug-label attribute pairs with
{...debugProps(id, component, file)} spread in all 17 components/shared/
files. Existing debug-ids preserved unchanged.

* feat(PBI-49): add debugProps to backlog/, sprint/, solo/ components

* feat(PBI-49): add debugProps to jobs/ + ideas/ components

* feat(PBI-49): add debugProps to products/ + settings/ + notifications/ components

* feat(PBI-49): add debugProps to admin/ + dashboard/ + dialogs/ + mobile/ + split-pane/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(PBI-49): use attr(data-debug-id) for debug tooltip in globals.css

* refactor(PBI-49): remove data-debug-label from debugProps helper + test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(PBI-49): strip unused component/file args from debugProps in shared/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(PBI-49): add BEM sub-element data-debug-id to StatusBar, NavBar, PanelNavBar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(PBI-49): add BEM sub-element data-debug-id to components/sprint/*

- new-sprint-dialog: __submit on submit button
- sprint-backlog: __list on SprintBacklogLeft + SprintBacklogRight scroll areas
- sprint-board-client: root wrapper div (display:contents) + __drag-overlay
- sprint-header: __title on goal button, __dates on dates button, __actions on action cluster
- sprint-run-controls: root on controls div, __start/__cancel on action buttons; __blockers-dialog on dialog content
- start-sprint-button: root on trigger button, __dialog on dialog content, __submit on submit button
- sync-active-sprint-cookie: no debug-id (returns null, side-effect only), comment added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(PBI-49): add BEM sub-element data-debug-id to components/backlog/*

* feat(PBI-49): add BEM sub-element data-debug-id to components/ideas/*

* feat(PBI-49): add BEM sub-element data-debug-id to components/dashboard/* + components/markdown.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(PBI-49): add BEM sub-element data-debug-id to new-product-button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(PBI-49): add BEM sub-element data-debug-id to components/solo/*

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(PBI-49): add BEM sub-elements to nav-status-indicators

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(PBI-49): add BEM sub-element data-debug-id to components/jobs/*

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(PBI-49): add BEM sub-element data-debug-id to components/products/*

* feat(PBI-49): add BEM sub-element data-debug-id to components/notifications/*

- answer-modal: __content (scroll area), __submit (footer)
- notifications-bridge: skip comment (bridge, non-rendering wrapper)
- notifications-realtime-mount: skip comment (returns null)
- notifications-sheet: __header, __items (questions list)
- push-toggle: __switch (button), __label (button text) on subscribed/unsubscribed states

* feat(PBI-49): add BEM sub-element data-debug-id to components/settings/*

- leave-product-button: root only (single-button component)
- min-quota-editor: __input (number input), __save (save button)
- profile-editor: __username (bio/short-description input), __save (submit)
- role-manager: __roles (checkbox list), __add (save button)
- token-manager: __tokens (active tokens list), __generate (create button)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(PBI-49): add BEM sub-element data-debug-id to admin, auth, dialogs, entity-dialog, mobile, split-pane

* docs(PBI-49): add debug-labels BEM pattern doc + CLAUDE.md entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:46:29 +02:00

344 lines
12 KiB
TypeScript

'use client'
// IdeaTimeline — chronologische merge van IdeaLog + ClaudeQuestion + UserQuestion entries.
// Server-component zou ook kunnen, maar we mounten dit binnen de client-side
// detail-layout dus client is simpler (geen rsc-boundary doorbreken).
//
// Iconen + kleur per log-type voor snelle herkenning.
// Open ClaudeQuestions krijgen een inline answer-form (M11).
// PBI-33: UserQuestions tonen vraag + (indien beantwoord) Claude's antwoord.
// Onderaan: UserChatInput om nieuwe vraag te stellen (alleen als plan_md aanwezig is).
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import {
ClipboardList,
FileText,
HelpCircle,
Lightbulb,
MessageCircle,
RefreshCw,
StickyNote,
Wrench,
} from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { debugProps } from '@/lib/debug'
import { answerQuestion } from '@/actions/questions'
import { UserChatInput } from '@/components/ideas/user-chat-input'
import type { IdeaLogType } from '@prisma/client'
export interface TimelineLog {
id: string
type: string
content: string
metadata: unknown
created_at: string
}
export interface TimelineQuestion {
id: string
question: string
options: string[] | null
status: 'open' | 'answered' | 'cancelled' | 'expired'
answer: string | null
created_at: string
expires_at: string
}
export interface TimelineUserQuestion {
id: string
question: string
answer: string | null
status: 'pending' | 'answered'
created_at: string
}
interface Props {
logs: TimelineLog[]
questions: TimelineQuestion[]
userQuestions: TimelineUserQuestion[]
planMd: string | null
ideaId: string
isDemo?: boolean
}
const LOG_ICON: Record<IdeaLogType, React.ReactNode> = {
DECISION: <Lightbulb className="size-4" />,
NOTE: <StickyNote className="size-4" />,
GRILL_RESULT: <FileText className="size-4" />,
PLAN_RESULT: <ClipboardList className="size-4" />,
STATUS_CHANGE: <RefreshCw className="size-4" />,
JOB_EVENT: <Wrench className="size-4" />,
}
const LOG_LABEL: Record<IdeaLogType, string> = {
DECISION: 'Beslissing',
NOTE: 'Notitie',
GRILL_RESULT: 'Grill-resultaat',
PLAN_RESULT: 'Plan-resultaat',
STATUS_CHANGE: 'Status',
JOB_EVENT: 'Job-event',
}
const QUESTION_STATUS_LABEL: Record<TimelineQuestion['status'], string> = {
open: 'Open',
answered: 'Beantwoord',
cancelled: 'Geannuleerd',
expired: 'Verlopen',
}
const USER_QUESTION_STATUS_LABEL: Record<TimelineUserQuestion['status'], string> = {
pending: 'In behandeling',
answered: 'Beantwoord',
}
export function IdeaTimeline({
logs,
questions,
userQuestions,
planMd,
ideaId,
isDemo = false,
}: Props) {
const merged = [
...logs.map((l) => ({
kind: 'log' as const,
created_at: l.created_at,
data: l,
})),
...questions.map((q) => ({
kind: 'question' as const,
created_at: q.created_at,
data: q,
})),
...userQuestions.map((uq) => ({
kind: 'user_question' as const,
created_at: uq.created_at,
data: uq,
})),
].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
const showChatInput = planMd !== null
return (
<div className="space-y-4" {...debugProps('idea-timeline', 'IdeaTimeline', 'components/ideas/idea-timeline.tsx')}>
{merged.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center italic">
Nog geen activiteit op dit idee.
</p>
) : (
<ol className="border-l-2 border-input pl-4 space-y-3 ml-2" data-debug-id="idea-timeline__items">
{merged.map((entry, i) => {
// Expliciete locale + format om SSR/CSR hydration-mismatch te voorkomen
// (server-locale verschilde van browser-locale).
const time = new Date(entry.created_at).toLocaleString('nl-NL', {
dateStyle: 'short',
timeStyle: 'short',
})
if (entry.kind === 'log') {
const type = entry.data.type as IdeaLogType
return (
<li key={`l-${entry.data.id}`} className="relative">
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-muted-foreground">
{LOG_ICON[type] ?? <StickyNote className="size-4" />}
</span>
<div className="rounded-md border border-input bg-surface-container p-3 space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">
{LOG_LABEL[type] ?? type}
</span>
<span>·</span>
<time>{time}</time>
</div>
<p className="text-sm whitespace-pre-wrap">{entry.data.content}</p>
{entry.data.metadata != null &&
typeof entry.data.metadata === 'object' &&
Object.keys(entry.data.metadata as object).length > 0 ? (
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">metadata</summary>
<pre className="mt-1 whitespace-pre-wrap font-mono text-[10px]">
{JSON.stringify(entry.data.metadata, null, 2)}
</pre>
</details>
) : null}
</div>
</li>
)
}
if (entry.kind === 'question') {
const q = entry.data
return (
<li key={`q-${q.id}-${i}`} className="relative">
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-status-review">
<HelpCircle className="size-4" />
</span>
<div className="rounded-md border border-input bg-surface-container p-3 space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Vraag</span>
<span>·</span>
<span>{QUESTION_STATUS_LABEL[q.status]}</span>
<span>·</span>
<time>{time}</time>
</div>
<p className="text-sm">{q.question}</p>
{q.status === 'open' ? (
<AnswerForm questionId={q.id} options={q.options} />
) : (
<>
{q.options && q.options.length > 0 ? (
<ul className="text-xs text-muted-foreground list-disc list-inside">
{q.options.map((o, ii) => (
<li key={ii}>{o}</li>
))}
</ul>
) : null}
{q.answer ? (
<p className="text-sm border-l-2 border-primary pl-2 text-foreground">
<span className="text-xs font-medium uppercase tracking-wide text-primary mr-2">
Antwoord
</span>
{q.answer}
</p>
) : null}
</>
)}
</div>
</li>
)
}
// user_question — gebruiker stelt vraag aan Claude (PBI-33 PLAN_CHAT)
const uq = entry.data
return (
<li key={`uq-${uq.id}`} className="relative">
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-primary">
<MessageCircle className="size-4" />
</span>
<div className="rounded-md border border-input bg-surface-container p-3 space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Jouw vraag</span>
<span>·</span>
<span>{USER_QUESTION_STATUS_LABEL[uq.status]}</span>
<span>·</span>
<time>{time}</time>
</div>
<p className="text-sm whitespace-pre-wrap">{uq.question}</p>
{uq.status === 'pending' ? (
<p className="text-xs text-muted-foreground italic">
Claude denkt na
</p>
) : uq.answer ? (
<div className="border-l-2 border-primary pl-2">
<span className="text-xs font-medium uppercase tracking-wide text-primary">
Antwoord van Claude
</span>
<p className="text-sm whitespace-pre-wrap text-foreground mt-1">
{uq.answer}
</p>
</div>
) : null}
</div>
</li>
)
})}
</ol>
)}
{showChatInput && <UserChatInput ideaId={ideaId} isDemo={isDemo} />}
</div>
)
}
// ---------------------------------------------------------------------------
// AnswerForm — inline antwoord op een open Claude-vraag.
// Voor multi-choice (options): knoppen die direct submitten met de gekozen
// optie. Anders: textarea + Submit knop.
function AnswerForm({
questionId,
options,
}: {
questionId: string
options: string[] | null
}) {
const router = useRouter()
const [text, setText] = useState('')
const [pending, startTransition] = useTransition()
function submit(answer: string) {
const trimmed = answer.trim()
if (!trimmed) {
toast.error('Antwoord mag niet leeg zijn')
return
}
startTransition(async () => {
const r = await answerQuestion(questionId, trimmed)
if (!r.ok) {
toast.error(r.error)
return
}
toast.success('Antwoord verzonden — agent gaat door.')
setText('')
router.refresh()
})
}
if (options && options.length > 0) {
return (
<div className="flex flex-col gap-1.5 pt-1">
{options.map((opt, i) => (
<Button
key={i}
size="sm"
variant="outline"
className="justify-start text-left h-auto py-2 whitespace-normal"
disabled={pending}
onClick={() => submit(opt)}
>
{opt}
</Button>
))}
<details className="text-xs text-muted-foreground pt-1">
<summary className="cursor-pointer">Of typ een eigen antwoord</summary>
<div className="space-y-2 pt-2">
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={3}
placeholder="Eigen antwoord…"
disabled={pending}
/>
<div className="flex justify-end">
<Button size="sm" disabled={pending || !text.trim()} onClick={() => submit(text)}>
Verzend
</Button>
</div>
</div>
</details>
</div>
)
}
return (
<div className="space-y-2 pt-1">
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={3}
placeholder="Antwoord…"
disabled={pending}
/>
<div className="flex justify-end">
<Button size="sm" disabled={pending || !text.trim()} onClick={() => submit(text)}>
Verzend
</Button>
</div>
</div>
)
}