import { EventRow } from "@/components/EventRow";
import ProjectHistory from "@/components/ProjectHistory";
import { Button } from "@/components/ui/button";
import { useI18n } from "@/i18n/I18nProvider";
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";
// Activity tab shows actionable severities only. Info goes to History tab.
const ACTIONABLE_SEVS: Severity[] = ["suggest", "warn", "block"];
type Tab = "activity" | "history";
export default function ProjectTimeline() {
const { t } = useI18n();
const { name } = useParams<{ name: string }>();
const project = useMemo(() => decodeURIComponent(name ?? ""), [name]);
const [tab, setTab] = useState<Tab>("activity");
const [events, setEvents] = useState<WorkerEvent[]>([]);
const [includeAcked, setIncludeAcked] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
listEventsWithAck({
project,
severities: ACTIONABLE_SEVS,
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]);
useRealtimeWorkerEvents(
{
onInsert: (ev) => {
if (!ACTIONABLE_SEVS.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));
}
}, []);
async function loadMore() {
if (events.length === 0) return;
const before = events[events.length - 1].created_at;
const more = await listEventsWithAck({
project,
severities: ACTIONABLE_SEVS,
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="space-y-3">
<div className="flex items-center justify-between gap-4 flex-wrap">
<h1 className="text-xl font-semibold font-mono">{project}</h1>
{tab === "activity" && (
<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)}
/>
{t("timeline.showAcked")}
</label>
</div>
)}
</div>
{/* Tabs */}
<div className="flex border-b">
{(["activity", "history"] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setTab(k)}
className={`px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === k
? "border-foreground text-foreground font-medium"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{t(`timeline.tab.${k}` as const)}
</button>
))}
</div>
</header>
{error && tab === "activity" && (
<div className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
{tab === "activity" ? (
<>
<div className="rounded-md border bg-card">
{loading && events.length === 0 && (
<div className="p-4 text-sm text-muted-foreground">{t("common.loading")}</div>
)}
{!loading && visible.length === 0 && (
<div className="p-4 text-sm text-muted-foreground">{t("timeline.empty")}</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}>
{t("timeline.loadMore")}
</Button>
</div>
)}
</>
) : (
<ProjectHistory project={project} />
)}
</div>
);
}