feat(22-04): add TimelineCard chronological event renderer

- Merges reports/classifications/audit-events into one ascending list
  (relies on server ordering, no client-side sort)
- Per-kind icon/label/tint: FileText for reports, Sparkles tinted by
  verdict for classifications, event_type table for audit rows
  (remediation_approved/completed, campaign_marked_false_positive,
  campaign_classified, humanized fallback for anything else)
- 8px rail dot + border-l connector per UI-SPEC Timeline Spec
This commit is contained in:
lorentz 2026-07-16 14:29:15 -04:00
parent 14adddfdf0
commit e990a320b2

View file

@ -0,0 +1,153 @@
'use client';
import { FileText, Sparkles, CheckCircle2, ShieldCheck, XCircle, Circle } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
export type TimelineEntry =
| { kind: 'report'; at: string; reportId: string; ticketNumber: string | null; companyName: string | null }
| { kind: 'classification'; at: string; verdict: 'SPAM' | 'UNWANTED' | 'THREAT'; confidence: string | null }
| { kind: 'audit'; at: string; eventType: string; actor: string | null; payload: unknown };
interface TimelineCardProps {
timeline: TimelineEntry[];
}
const VERDICT_TINT: Record<'SPAM' | 'UNWANTED' | 'THREAT', string> = {
SPAM: 'bg-slate-500 text-slate-600',
UNWANTED: 'bg-amber-500 text-amber-600',
THREAT: 'bg-destructive text-destructive',
};
function humanizeEventType(eventType: string): string {
return eventType
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
function renderEntry(entry: TimelineEntry): {
label: string;
icon: React.ComponentType<{ className?: string }>;
dotClass: string;
textClass: string;
} {
if (entry.kind === 'report') {
const ticketLabel = entry.ticketNumber ? `Ticket #${entry.ticketNumber}` : 'Ticket';
const company = entry.companyName ? ` (${entry.companyName})` : '';
return {
label: `Report linked — ${ticketLabel}${company}`,
icon: FileText,
dotClass: 'bg-muted-foreground',
textClass: 'text-muted-foreground',
};
}
if (entry.kind === 'classification') {
const confidence = entry.confidence != null ? ` (${entry.confidence}%)` : '';
return {
label: `Classified as ${entry.verdict}${confidence}`,
icon: Sparkles,
dotClass: VERDICT_TINT[entry.verdict].split(' ')[0],
textClass: VERDICT_TINT[entry.verdict].split(' ')[1],
};
}
// entry.kind === 'audit'
const payload = (entry.payload ?? {}) as Record<string, unknown>;
switch (entry.eventType) {
case 'remediation_approved': {
const count =
(Array.isArray(payload.actionIds) && payload.actionIds.length) ||
(Array.isArray(payload.actions) && payload.actions.length) ||
0;
return {
label: `${count} action(s) approved by ${entry.actor ?? 'unknown'}`,
icon: CheckCircle2,
dotClass: 'bg-blue-500',
textClass: 'text-blue-600',
};
}
case 'remediation_completed':
return {
label: 'Remediation completed',
icon: ShieldCheck,
dotClass: 'bg-green-500',
textClass: 'text-green-600',
};
case 'campaign_marked_false_positive':
return {
label: 'Marked as false positive',
icon: XCircle,
dotClass: 'bg-slate-500',
textClass: 'text-slate-600',
};
case 'campaign_classified': {
const verdict = payload.verdict as 'SPAM' | 'UNWANTED' | 'THREAT' | undefined;
const tint = verdict ? VERDICT_TINT[verdict] : 'bg-muted-foreground text-muted-foreground';
return {
label: verdict ? `Classified as ${verdict}` : 'Campaign classified',
icon: Sparkles,
dotClass: tint.split(' ')[0],
textClass: tint.split(' ')[1],
};
}
default:
return {
label: humanizeEventType(entry.eventType),
icon: Circle,
dotClass: 'bg-muted-foreground',
textClass: 'text-muted-foreground',
};
}
}
export function TimelineCard({ timeline }: TimelineCardProps) {
return (
<Card>
<CardHeader>
<CardTitle className="font-bold">Timeline</CardTitle>
</CardHeader>
<CardContent>
{timeline.length === 0 ? (
<p className="text-sm text-muted-foreground">No timeline events yet.</p>
) : (
<div className="space-y-0">
{timeline.map((entry, idx) => {
const { label, icon: Icon, dotClass, textClass } = renderEntry(entry);
const isLast = idx === timeline.length - 1;
const actor = entry.kind === 'audit' ? entry.actor : null;
const absolute = new Date(entry.at).toLocaleString();
const relative = formatDistanceToNow(new Date(entry.at), { addSuffix: true });
return (
<div key={idx} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className={cn('h-2 w-2 rounded-full shrink-0 mt-1.5', dotClass)}
/>
{!isLast && <span className="border-l flex-1 w-0 min-h-4" />}
</div>
<div className="pb-4 min-w-0">
<div className="flex items-center gap-1.5 text-sm">
<Icon className={cn('h-3.5 w-3.5 shrink-0', textClass)} />
<span>{label}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="font-mono text-xs text-muted-foreground" title={absolute}>
{relative}
</span>
{actor && (
<span className="text-xs text-muted-foreground">{actor}</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}