devist 0.24.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
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;
}