feat(observability): chronological timeline and agent productivity APIs

We added the third major surface to the BeadBoard workspace: the Chronological Timeline. This provides the 'Audit' layer of our operational hierarchy.

Triumphs:
- Built the /timeline route with sticky date grouping and polymorphic EventCards.
- Integrated the ActivityPersistence library to bridge the gap between ephemeral SSE events and persistent project history.
- Implemented real-time Agent Stats endpoints (/api/agents/[id]/stats) that derive throughput and 'Wins' from the project stream.

Raw Honest Moment:
We almost shipped this without persistence, which would have meant the project history would disappear every time the server restarted. Realizing that 'Observability' requires 'Survivability' led us to build the .beadboard/activity.json buffer, a small but vital piece of engineering that makes the timeline actually useful.
This commit is contained in:
zenchantlive 2026-02-14 00:21:02 -08:00
parent f3558dc0d1
commit bfe4f853f0
8 changed files with 428 additions and 0 deletions

View file

@ -0,0 +1,111 @@
'use client';
import { motion } from 'framer-motion';
import Link from 'next/link';
import type { ActivityEvent } from '../../lib/activity';
import { Chip } from '../shared/chip';
interface EventCardProps {
event: ActivityEvent;
}
export function EventCard({ event }: EventCardProps) {
return (
<motion.article
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="group relative flex gap-4 rounded-xl border border-white/5 bg-white/[0.02] p-4 transition-colors hover:bg-white/[0.04]"
>
<div className="flex-none pt-1">
<StatusIcon kind={event.kind} />
</div>
<div className="flex-1 min-w-0">
<header className="flex items-baseline justify-between gap-4">
<div className="flex items-center gap-2 text-sm text-text-body">
<span className="font-semibold text-text-strong">{event.actor || 'System'}</span>
<span className="text-text-muted">{getActionVerb(event.kind)}</span>
<Link
href={`/?focus=${event.beadId}`}
className="font-medium text-emerald-400 hover:underline hover:text-emerald-300"
>
{event.beadTitle}
</Link>
</div>
<time className="system-data text-xs text-text-muted whitespace-nowrap">
{new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</time>
</header>
<div className="mt-2 text-sm">
<EventPayload event={event} />
</div>
<div className="mt-3 flex items-center gap-2">
<Chip tone="default">{event.projectName}</Chip>
<span className="system-data text-xs text-text-muted/50">{event.beadId}</span>
</div>
</div>
</motion.article>
);
}
function StatusIcon({ kind }: { kind: string }) {
let color = 'bg-slate-500';
if (kind === 'created' || kind === 'reopened') color = 'bg-emerald-500';
if (kind === 'closed') color = 'bg-rose-500';
if (kind === 'status_changed') color = 'bg-amber-500';
if (kind.includes('comment')) color = 'bg-blue-500';
return (
<div className={`h-2 w-2 rounded-full ${color} shadow-[0_0_8px_currentColor]`} />
);
}
function getActionVerb(kind: string): string {
switch (kind) {
case 'created': return 'created';
case 'closed': return 'closed';
case 'reopened': return 'reopened';
case 'status_changed': return 'moved';
case 'comment_added': return 'commented on';
case 'assignee_changed': return 'assigned';
default: return 'updated';
}
}
function EventPayload({ event }: { event: ActivityEvent }) {
const { payload } = event;
if (event.kind === 'status_changed') {
return (
<div className="flex items-center gap-2 text-text-muted">
<span className="line-through">{payload.from}</span>
<span></span>
<span className="font-medium text-text-strong">{payload.to}</span>
</div>
);
}
if (event.kind === 'comment_added') {
return (
<div className="rounded-lg bg-white/5 p-3 text-text-body italic">
&quot;{payload.message}&quot;
</div>
);
}
if (payload.from !== undefined && payload.to !== undefined) {
return (
<div className="text-text-muted">
Changed {payload.field}: <span className="text-text-body">{String(payload.from)}</span> <span className="text-text-strong">{String(payload.to)}</span>
</div>
);
}
if (payload.message) {
return <div className="text-text-body">{payload.message}</div>;
}
return null;
}

View file

@ -0,0 +1,58 @@
'use client';
import { useMemo } from 'react';
import { useTimelineStore } from './timeline-store';
import { EventCard } from './event-card';
export function TimelineFeed() {
const { events, filterProject, filterActor, filterKind } = useTimelineStore();
const filteredEvents = useMemo(() => {
return events.filter(e => {
if (filterProject && e.projectId !== filterProject) return false;
if (filterActor && e.actor !== filterActor) return false;
if (filterKind && e.kind !== filterKind) return false;
return true;
});
}, [events, filterProject, filterActor, filterKind]);
const groups = useMemo(() => {
const grouped: Record<string, typeof events> = {};
filteredEvents.forEach(e => {
const date = new Date(e.timestamp).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
if (!grouped[date]) grouped[date] = [];
grouped[date].push(e);
});
return grouped;
}, [filteredEvents]);
if (filteredEvents.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-text-muted">
No activity found.
</div>
);
}
return (
<div className="space-y-8 pb-20">
{Object.entries(groups).map(([date, groupEvents]) => (
<section key={date} className="space-y-4">
<h3 className="sticky top-0 z-10 bg-bg-base/80 py-2 text-xs font-bold uppercase tracking-wider text-text-muted backdrop-blur-md">
{date}
</h3>
<div className="space-y-3">
{groupEvents.map(event => (
<EventCard key={event.id} event={event} />
))}
</div>
</section>
))}
</div>
);
}

View file

@ -0,0 +1,77 @@
import { create } from 'zustand';
import type { ActivityEvent, ActivityEventKind } from '../../lib/activity';
export interface TimelineState {
events: ActivityEvent[];
filterProject: string | null;
filterActor: string | null;
filterKind: ActivityEventKind | null;
// Selection states for Sessions UI
selectedAgentId: string | null;
selectedTaskId: string | null;
addEvent: (event: ActivityEvent) => void;
setHistory: (events: ActivityEvent[]) => void;
setFilterProject: (projectId: string | null) => void;
setFilterActor: (actor: string | null) => void;
setFilterKind: (kind: ActivityEventKind | null) => void;
// Selection actions
setSelectedAgentId: (agentId: string | null) => void;
setSelectedTaskId: (taskId: string | null) => void;
backToAgent: () => void;
clear: () => void;
}
export const useTimelineStore = create<TimelineState>((set) => ({
events: [],
filterProject: null,
filterActor: null,
filterKind: null,
selectedAgentId: null,
selectedTaskId: null,
addEvent: (event) => set((state) => {
// Avoid duplicates
if (state.events.some(e => e.id === event.id)) {
return state;
}
return { events: [event, ...state.events] };
}),
setHistory: (history) => set((state) => {
const existingIds = new Set(state.events.map(e => e.id));
const newEvents = history.filter(e => !existingIds.has(e.id));
// Merge and sort by timestamp desc
const merged = [...state.events, ...newEvents].sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
return { events: merged };
}),
setFilterProject: (projectId) => set({ filterProject: projectId }),
setFilterActor: (actor) => set({ filterActor: actor }),
setFilterKind: (kind) => set({ filterKind: kind }),
setSelectedAgentId: (agentId) => set({
selectedAgentId: agentId,
// When selecting a new agent, clear task selection to show agent scorecard
selectedTaskId: null
}),
setSelectedTaskId: (taskId) => set({ selectedTaskId: taskId }),
backToAgent: () => set({ selectedTaskId: null }),
clear: () => set({
events: [],
selectedAgentId: null,
selectedTaskId: null,
filterProject: null,
filterActor: null,
filterKind: null
}),
}));