devist 0.26.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 ProjectMetrics,
  fetchDailyActivity,
  fetchProjectMetrics,
} from "@/lib/queries";
import type { DailyCount } from "@/lib/queries";
import {
  differenceInDays,
  formatDistanceToNow,
  parseISO,
} from "date-fns";
import { useEffect, useState } from "react";

const HEATMAP_DAYS = 364; // 52 weeks (one year) — GitHub-style

export default function ProjectHistory({ project }: { project: string }) {
  const { t, lang } = useI18n();
  const [metrics, setMetrics] = useState<ProjectMetrics | null>(null);
  const [heatmap, setHeatmap] = useState<DailyCount[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let alive = true;
    setLoading(true);
    setError(null);
    Promise.all([
      fetchProjectMetrics(project),
      fetchDailyActivity(HEATMAP_DAYS, project),
    ])
      .then(([m, hm]) => {
        if (!alive) return;
        setMetrics(m);
        setHeatmap(hm);
      })
      .catch((e) => alive && setError(String(e?.message ?? e)))
      .finally(() => alive && setLoading(false));
    return () => {
      alive = false;
    };
  }, [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>
    );
  }

  const totalHeatmap = heatmap.reduce((s, d) => s + d.count, 0);

  return (
    <div className="space-y-6">
      <MetricsHeader metrics={metrics} lang={lang} />
      <ActivityHeatmap
        data={heatmap}
        title={t("history.heatmap.title")}
        trailing={`${totalHeatmap} ${t("overview.activity.events")}`}
      />
    </div>
  );
}

function MetricsHeader({
  metrics,
  lang,
}: {
  metrics: ProjectMetrics | null;
  lang: ReturnType<typeof useI18n>["lang"];
}) {
  const { t } = useI18n();
  if (!metrics) return null;
  const days = metrics.first_event_at
    ? Math.max(
        1,
        differenceInDays(new Date(), parseISO(metrics.first_event_at)) + 1,
      )
    : 0;
  const lastAgo = metrics.last_event_at
    ? formatDistanceToNow(parseISO(metrics.last_event_at), {
        addSuffix: true,
        locale: dateFnsLocale(lang),
      })
    : "—";
  return (
    <div className="rounded-md border bg-card p-4">
      <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
        <Metric
          label={t("history.metric.days")}
          value={days > 0 ? `${days}` : "—"}
        />
        <Metric
          label={t("history.metric.totalEvents")}
          value={metrics.total_events.toLocaleString()}
        />
        <Metric
          label={t("history.metric.strongMemories")}
          value={metrics.strong_memory_count.toLocaleString()}
        />
        <Metric
          label={t("history.metric.lastActivity")}
          value={lastAgo}
          mono={false}
        />
      </div>
    </div>
  );
}

function Metric({
  label,
  value,
  mono = true,
}: {
  label: string;
  value: string;
  mono?: boolean;
}) {
  return (
    <div>
      <div className="text-[10px] uppercase tracking-widest text-muted-foreground">
        {label}
      </div>
      <div
        className={`mt-1 text-lg font-semibold ${
          mono ? "tabular-nums" : "text-base font-medium"
        }`}
      >
        {value}
      </div>
    </div>
  );
}