import { EventRow } from "@/components/EventRow";
import { Button } from "@/components/ui/button";
import { ackEvent, listEventsWithAck, unackEvent } from "@/lib/queries";
import { useRealtimeWorkerEvents } from "@/lib/realtime";
import type { Severity, WorkerEvent } from "@/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
const ALL_SEVERITIES: Severity[] = ["info", "suggest", "warn", "block"];
export default function ProjectTimeline() {
const { name } = useParams<{ name: string }>();
const project = useMemo(() => decodeURIComponent(name ?? ""), [name]);
const [events, setEvents] = useState<WorkerEvent[]>([]);
const [filter, setFilter] = useState<Severity[]>(ALL_SEVERITIES);
const [includeAcked, setIncludeAcked] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
listEventsWithAck({
project,
severities: filter,
limit: 100,
includeAcked: true,
})
.then((r) => alive && setEvents(r))
.catch((e) => alive && setError(String(e?.message ?? e)))
.finally(() => alive && setLoading(false));
return () => {
alive = false;
};
}, [project, filter]);
useRealtimeWorkerEvents(
{
onInsert: (ev) => {
if (!filter.includes(ev.severity)) return;
setEvents((prev) => [ev, ...prev]);
},
onUpdate: (ev) => {
setEvents((prev) =>
prev.map((e) => (e.id === ev.id ? { ...e, ...ev } : e)),
);
},
},
project,
);
const handleAck = useCallback(async (ev: WorkerEvent) => {
setEvents((prev) =>
prev.map((e) =>
e.id === ev.id ? { ...e, acked_at: new Date().toISOString() } : e,
),
);
try {
await ackEvent(ev.id);
} catch (err) {
setError(String((err as Error)?.message ?? err));
}
}, []);
const handleUnack = useCallback(async (ev: WorkerEvent) => {
setEvents((prev) =>
prev.map((e) => (e.id === ev.id ? { ...e, acked_at: null } : e)),
);
try {
await unackEvent(ev.id);
} catch (err) {
setError(String((err as Error)?.message ?? err));
}
}, []);
function toggle(sev: Severity) {
setFilter((prev) =>
prev.includes(sev) ? prev.filter((s) => s !== sev) : [...prev, sev],
);
}
async function loadMore() {
if (events.length === 0) return;
const before = events[events.length - 1].created_at;
const more = await listEventsWithAck({
project,
severities: filter,
limit: 100,
before,
includeAcked: true,
});
setEvents([...events, ...more]);
}
const visible = includeAcked ? events : events.filter((e) => !e.acked_at);
return (
<div className="p-6 space-y-4">
<header className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold font-mono">{project}</h1>
<p className="text-sm text-muted-foreground">
{visible.length} of {events.length} loaded
</p>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={includeAcked}
onChange={(e) => setIncludeAcked(e.target.checked)}
/>
Show acked
</label>
<div className="flex gap-1">
{ALL_SEVERITIES.map((s) => (
<button
key={s}
type="button"
onClick={() => toggle(s)}
className={`text-xs px-2 py-1 rounded border ${
filter.includes(s)
? "bg-foreground text-background"
: "bg-background text-muted-foreground"
}`}
>
{s}
</button>
))}
</div>
</div>
</header>
{error && (
<div className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
<div className="rounded-md border bg-card">
{loading && events.length === 0 && (
<div className="p-4 text-sm text-muted-foreground">Loading…</div>
)}
{!loading && visible.length === 0 && (
<div className="p-4 text-sm text-muted-foreground">
No events match the current filter.
</div>
)}
{visible.map((ev) => (
<EventRow
key={ev.id}
event={ev}
onAck={ev.event_type === "advice" ? handleAck : undefined}
onUnack={ev.event_type === "advice" ? handleUnack : undefined}
/>
))}
</div>
{events.length >= 100 && (
<div className="flex justify-center">
<Button variant="ghost" size="sm" onClick={loadMore}>
Load more
</Button>
</div>
)}
</div>
);
}