import ActivityHeatmap from "@/components/ActivityHeatmap";
import { useI18n } from "@/i18n/I18nProvider";
import { dateFnsLocale } from "@/lib/dateFns";
import {
type AckSourceStats,
type DailyCount,
type InboxSnapshot,
type MemoryStats,
type Stats,
fetchActivitySparkline,
fetchDailyActivity,
fetchInboxSnapshot,
fetchMemoryStats,
fetchRecentAckSources,
fetchStats,
listEvents,
} from "@/lib/queries";
import { useRealtimeWorkerEvents } from "@/lib/realtime";
import type { Severity, WorkerEvent } from "@/types";
import { formatDistanceToNow } from "date-fns";
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
const SEV_DOT: Record<Severity, string> = {
info: "bg-muted-foreground/40",
suggest: "bg-cyan-500",
warn: "bg-yellow-500",
block: "bg-red-500",
};
export default function Overview() {
const { t } = useI18n();
const [stats, setStats] = useState<Stats | null>(null);
const [recent, setRecent] = useState<WorkerEvent[]>([]);
const [inbox, setInbox] = useState<InboxSnapshot | null>(null);
const [memory, setMemory] = useState<MemoryStats | null>(null);
const [acks, setAcks] = useState<AckSourceStats | null>(null);
const [sparkline, setSparkline] = useState<DailyCount[]>([]);
const [heatmap, setHeatmap] = useState<DailyCount[]>([]);
const [error, setError] = useState<string | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
useEffect(() => {
let alive = true;
Promise.all([
fetchStats(),
listEvents({ limit: 10 }),
fetchInboxSnapshot(),
fetchMemoryStats(),
fetchRecentAckSources(24),
fetchActivitySparkline(7),
fetchDailyActivity(364),
])
.then(([s, r, ix, mem, ack, spark, hm]) => {
if (!alive) return;
setStats(s);
setRecent(r);
setInbox(ix);
setMemory(mem);
setAcks(ack);
setSparkline(spark);
setHeatmap(hm);
})
.catch((e) => alive && setError(String(e?.message ?? e)));
return () => {
alive = false;
};
}, [refreshKey]);
useRealtimeWorkerEvents({
onInsert: (ev) => {
setRecent((prev) => [ev, ...prev].slice(0, 10));
setRefreshKey((k) => k + 1);
},
onUpdate: (ev) => {
setRecent((prev) => prev.map((e) => (e.id === ev.id ? { ...e, ...ev } : e)));
setRefreshKey((k) => k + 1);
},
});
return (
<div className="p-6 space-y-6">
<header>
<h1 className="text-xl font-semibold">{t("overview.title")}</h1>
</header>
{error && (
<div className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
<section className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Stat label={t("overview.stat.eventsToday")} value={stats?.events_today} />
<Stat label={t("overview.stat.adviceWeek")} value={stats?.advice_this_week} />
<Stat label={t("overview.stat.warnBlock")} value={stats?.warn_block_this_week} />
</section>
<InboxSnapshotCard data={inbox} />
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<ResoPulseCard memory={memory} acks={acks} />
<ActivitySparkCard data={sparkline} />
</section>
<ActivityHeatmap
data={heatmap}
title={t("overview.heatmap.title")}
trailing={`${heatmap.reduce((s, d) => s + d.count, 0)} ${t("overview.activity.events")}`}
/>
<section>
<h2 className="text-sm font-semibold mb-2">{t("overview.recent")}</h2>
<div className="rounded-md border bg-card divide-y">
{recent.length === 0 && (
<div className="p-4 text-sm text-muted-foreground">{t("overview.empty")}</div>
)}
{recent.map((ev) => (
<ActivityLine key={ev.id} event={ev} />
))}
</div>
</section>
</div>
);
}
function InboxSnapshotCard({ data }: { data: InboxSnapshot | null }) {
const { t } = useI18n();
const total = (data?.block ?? 0) + (data?.warn ?? 0) + (data?.suggest ?? 0);
return (
<section className="rounded-md border bg-card p-4">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
{t("overview.inbox.title")}
</div>
{data === null ? (
<div className="mt-2 text-sm text-muted-foreground">—</div>
) : total === 0 ? (
<div className="mt-2 text-sm text-green-600">
{t("overview.inbox.allClear")}
</div>
) : (
<div className="mt-2 flex items-center gap-4 text-sm">
<SeverityCount sev="block" count={data.block} />
<SeverityCount sev="warn" count={data.warn} />
<SeverityCount sev="suggest" count={data.suggest} />
</div>
)}
</div>
<Link
to="/dashboard/inbox"
className="text-xs font-medium text-foreground hover:underline whitespace-nowrap"
>
{t("overview.inbox.openInbox")}
</Link>
</div>
</section>
);
}
function SeverityCount({ sev, count }: { sev: Severity; count: number }) {
const { t } = useI18n();
return (
<span className="inline-flex items-center gap-2">
<span
className={`inline-block w-2 h-2 rounded-full ${SEV_DOT[sev]}`}
/>
<span className="font-semibold tabular-nums">{count}</span>
<span className="text-muted-foreground">{t(`severity.${sev}` as const)}</span>
</span>
);
}
function ResoPulseCard({
memory,
acks,
}: {
memory: MemoryStats | null;
acks: AckSourceStats | null;
}) {
const { t } = useI18n();
return (
<div className="rounded-md border bg-card p-4">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
{t("overview.reso.title")}
</div>
{memory === null ? (
<div className="mt-2 text-sm text-muted-foreground">—</div>
) : (
<>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-semibold tabular-nums">
{memory.total}
</span>
<span className="text-xs text-muted-foreground">
{t("overview.reso.activeMemories")}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<PriorityChip
count={memory.byPriority.constraint}
label={t("overview.reso.priority.constraint")}
accent="text-red-700"
/>
<PriorityChip
count={memory.byPriority.strong}
label={t("overview.reso.priority.strong")}
accent="text-yellow-700"
/>
<PriorityChip
count={memory.byPriority.preference}
label={t("overview.reso.priority.preference")}
accent="text-cyan-700"
/>
<PriorityChip
count={memory.byPriority.info}
label={t("overview.reso.priority.info")}
/>
</div>
</>
)}
<div className="mt-4 pt-3 border-t">
<div className="text-xs text-muted-foreground mb-1.5">
{t("overview.reso.last24h")}
</div>
{acks === null ? (
<div className="text-sm text-muted-foreground">—</div>
) : (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs">
<AckChip count={acks.audit} label={t("overview.reso.ack.audit")} />
<AckChip count={acks.verify} label={t("overview.reso.ack.verify")} />
<AckChip count={acks.apply} label={t("overview.reso.ack.apply")} />
<AckChip count={acks.user} label={t("overview.reso.ack.user")} />
</div>
)}
</div>
</div>
);
}
function PriorityChip({
count,
label,
accent,
}: {
count: number;
label: string;
accent?: string;
}) {
return (
<span className="inline-flex items-baseline gap-1">
<span className={`font-semibold tabular-nums ${accent ?? ""}`}>
{count}
</span>
<span>{label}</span>
</span>
);
}
function AckChip({ count, label }: { count: number; label: string }) {
return (
<span className="inline-flex items-baseline gap-1">
<span className="font-semibold tabular-nums text-foreground">{count}</span>
<span className="text-muted-foreground font-mono">{label}</span>
</span>
);
}
function ActivitySparkCard({ data }: { data: DailyCount[] }) {
const { t } = useI18n();
const max = Math.max(1, ...data.map((d) => d.count));
const total = data.reduce((s, d) => s + d.count, 0);
return (
<div className="rounded-md border bg-card p-4">
<div className="flex items-baseline justify-between">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
{t("overview.activity.title")}
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{total} {t("overview.activity.events")}
</div>
</div>
{data.length === 0 ? (
<div className="mt-3 text-sm text-muted-foreground">—</div>
) : (
<div className="mt-4 flex items-end gap-1.5 h-20">
{data.map((d) => {
const heightPct = Math.max(2, (d.count / max) * 100);
return (
<div
key={d.date}
className="flex-1 flex flex-col items-center gap-1.5 group"
title={`${d.date}: ${d.count}`}
>
<div className="w-full flex-1 flex items-end">
<div
className="w-full bg-foreground/15 group-hover:bg-foreground/40 rounded-sm transition-colors"
style={{ height: `${heightPct}%` }}
/>
</div>
<div className="text-[10px] text-muted-foreground tabular-nums">
{d.date.slice(8)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
function Stat({ label, value }: { label: string; value: number | undefined }) {
return (
<div className="rounded-md border bg-card p-4">
<div className="text-xs uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">{value ?? "—"}</div>
</div>
);
}
function ActivityLine({ event }: { event: WorkerEvent }) {
const { lang } = useI18n();
const ago = formatDistanceToNow(new Date(event.created_at), {
addSuffix: true,
locale: dateFnsLocale(lang),
});
const summary = summarize(event);
return (
<Link
to={`/dashboard/projects/${encodeURIComponent(event.project)}`}
className="flex items-center gap-3 px-3 py-2 text-xs hover:bg-muted/30"
>
<span
className={`shrink-0 inline-block w-1.5 h-1.5 rounded-full ${SEV_DOT[event.severity]}`}
/>
<span className="font-medium text-foreground shrink-0 truncate max-w-[140px]">
{event.project}
</span>
<span className="text-muted-foreground truncate flex-1 min-w-0">
{summary}
</span>
<span className="text-muted-foreground whitespace-nowrap shrink-0 tabular-nums">
{ago}
</span>
</Link>
);
}
function summarize(ev: WorkerEvent): string {
const p = (ev.payload ?? {}) as Record<string, unknown>;
const text =
typeof p.text === "string"
? p.text
: typeof p.error === "string"
? `error: ${p.error}`
: ev.path ?? ev.event_type;
// Single-line: take first line, trim, cap at ~120 chars.
const oneLine = text.split(/\n/, 1)[0]?.trim() ?? "";
return oneLine.length > 120 ? `${oneLine.slice(0, 120)}…` : oneLine;
}