devist 0.23.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
import { useI18n } from "@/i18n/I18nProvider";
import { listProjectInfoHistory } from "@/lib/queries";
import { useRealtimeWorkerEvents } from "@/lib/realtime";
import type { WorkerEvent } from "@/types";
import { format, isSameDay, parseISO } from "date-fns";
import { useEffect, useState } from "react";

type Group = { day: string; events: WorkerEvent[] };

function groupByDay(events: WorkerEvent[]): Group[] {
  const out: Group[] = [];
  for (const ev of events) {
    const day = format(parseISO(ev.created_at), "yyyy-MM-dd");
    const last = out[out.length - 1];
    if (last && last.day === day) {
      last.events.push(ev);
    } else {
      out.push({ day, events: [ev] });
    }
  }
  return out;
}

export default function ProjectHistory({ project }: { project: string }) {
  const { t } = useI18n();
  const [events, setEvents] = useState<WorkerEvent[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let alive = true;
    setLoading(true);
    setError(null);
    listProjectInfoHistory(project)
      .then((r) => alive && setEvents(r))
      .catch((e) => alive && setError(String(e?.message ?? e)))
      .finally(() => alive && setLoading(false));
    return () => {
      alive = false;
    };
  }, [project]);

  // Realtime: append new info events as they arrive
  useRealtimeWorkerEvents(
    {
      onInsert: (ev) => {
        if (ev.event_type !== "advice" || ev.severity !== "info") return;
        setEvents((prev) => {
          // append while keeping order; if it's later than the last entry,
          // simply push; otherwise re-sort defensively.
          const last = prev[prev.length - 1];
          if (!last || ev.created_at >= last.created_at) {
            return [...prev, ev];
          }
          return [...prev, ev].sort((a, b) =>
            a.created_at.localeCompare(b.created_at),
          );
        });
      },
    },
    project,
  );

  if (loading) {
    return <div className="text-sm text-muted-foreground">{t("common.loading")}</div>;
  }
  if (error) {
    return (
      <div className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-700">
        {error}
      </div>
    );
  }
  if (events.length === 0) {
    return <div className="text-sm text-muted-foreground p-4">{t("history.empty")}</div>;
  }

  const groups = groupByDay(events);
  const today = new Date();

  return (
    <div className="space-y-6">
      <p className="text-sm text-muted-foreground">{t("history.subtitle")}</p>
      {groups.map((g) => {
        const d = parseISO(`${g.day}T00:00:00`);
        const label = isSameDay(d, today) ? "Today" : format(d, "MMM d, yyyy");
        return (
          <section key={g.day} className="space-y-2">
            <h3 className="text-xs uppercase tracking-wider text-muted-foreground">{label}</h3>
            <ol className="space-y-2 border-l-2 border-muted pl-4">
              {g.events.map((ev) => (
                <li key={ev.id} className="space-y-0.5">
                  <div className="text-xs text-muted-foreground">
                    {format(parseISO(ev.created_at), "HH:mm")}
                  </div>
                  <div className="text-sm leading-relaxed">{extractText(ev)}</div>
                  {ev.path && (
                    <div className="text-xs font-mono text-muted-foreground">{ev.path}</div>
                  )}
                </li>
              ))}
            </ol>
          </section>
        );
      })}
    </div>
  );
}

function extractText(ev: WorkerEvent): string {
  const p = ev.payload as Record<string, unknown>;
  if (typeof p?.text === "string") return p.text;
  return "";
}