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 {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { useI18n } from "@/i18n/I18nProvider";
import { dateFnsLocale } from "@/lib/dateFns";
import { localDateKey, type DailyCount } from "@/lib/queries";
import { format, parseISO } from "date-fns";

type Props = {
  /** Last N days, sorted oldest → newest, every day represented (zero-padded). */
  data: DailyCount[];
  /** Title shown on top-left of the card. */
  title: string;
  /** Subtle right-aligned context (e.g. "30 events"). Optional. */
  trailing?: string;
};

const INTENSITY_CLASS: Record<0 | 1 | 2 | 3 | 4, string> = {
  0: "bg-foreground/[0.06]",
  1: "bg-foreground/[0.20]",
  2: "bg-foreground/[0.40]",
  3: "bg-foreground/[0.65]",
  4: "bg-foreground/[0.90]",
};

function intensity(count: number, max: number): 0 | 1 | 2 | 3 | 4 {
  if (count === 0) return 0;
  const r = count / max;
  if (r < 0.25) return 1;
  if (r < 0.5) return 2;
  if (r < 0.75) return 3;
  return 4;
}

// Index 0 = Sunday (week starts on Sunday). Anchor labels at Mon, Wed,
// Fri — matches GitHub. The Sun row is unlabeled but visually first.
const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""];

export default function ActivityHeatmap({ data, title, trailing }: Props) {
  const { t, lang } = useI18n();
  const max = Math.max(1, ...data.map((d) => d.count));
  const lookup = new Map(data.map((d) => [d.date, d.count]));

  const first = data[0]?.date
    ? new Date(`${data[0].date}T00:00:00`)
    : new Date();
  const last = data[data.length - 1]?.date
    ? new Date(`${data[data.length - 1].date}T00:00:00`)
    : new Date();
  const gridStart = new Date(first);
  gridStart.setDate(first.getDate() - first.getDay());

  type Cell = {
    iso: string;
    count: number;
    inRange: boolean;
    date: Date;
  };
  const columns: Cell[][] = [];
  const cur = new Date(gridStart);
  while (cur <= last) {
    const week: Cell[] = [];
    for (let i = 0; i < 7; i++) {
      const iso = localDateKey(cur);
      const inRange = cur >= first && cur <= last;
      week.push({
        iso,
        count: inRange ? (lookup.get(iso) ?? 0) : 0,
        inRange,
        date: new Date(cur),
      });
      cur.setDate(cur.getDate() + 1);
    }
    columns.push(week);
  }

  const monthLabels: { col: number; label: string }[] = [];
  let lastMonth = -1;
  columns.forEach((col, idx) => {
    const firstInRange = col.find((c) => c.inRange);
    if (!firstInRange) return;
    const m = firstInRange.date.getMonth();
    if (m !== lastMonth) {
      monthLabels.push({ col: idx, label: format(firstInRange.date, "MMM") });
      lastMonth = m;
    }
  });

  return (
    <TooltipProvider
      delayDuration={250}
      skipDelayDuration={500}
      disableHoverableContent
    >
      <div className="rounded-md border bg-card p-4">
        <div className="flex items-baseline justify-between mb-4">
          <div className="text-xs uppercase tracking-wide text-muted-foreground">
            {title}
          </div>
          {trailing && (
            <div className="text-xs text-muted-foreground tabular-nums">
              {trailing}
            </div>
          )}
        </div>

        {/* Outer 2x2 grid: (top-left empty) (month labels)
                              (day labels) (cells block).
            Cells block has an explicit `aspectRatio: N/7` so its height
            is derived from its width — this resolves the chicken-and-egg
            of "cell height needs column width which needs container".
            Day labels block stretches to the same height (auto row +
            grid default align-items: stretch) and distributes 7 1fr
            rows to match. */}
        <div
          className="grid gap-2"
          style={{ gridTemplateColumns: "auto minmax(0, 1fr)" }}
        >
          {/* Top-left empty (alignment placeholder) */}
          <div />

          {/* Top-right: month labels — same column template as cells */}
          <div
            className="grid"
            style={{
              gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))`,
              columnGap: "3px",
            }}
          >
            {Array.from({ length: columns.length }, (_, i) => {
              const m = monthLabels.find((ml) => ml.col === i);
              return (
                <div
                  key={`m-${i}`}
                  className="h-3 leading-3 text-[10px] text-muted-foreground whitespace-nowrap"
                >
                  {m?.label ?? ""}
                </div>
              );
            })}
          </div>

          {/* Bottom-left: day labels — 7 1fr rows that stretch to match
              the cells block's derived height. */}
          <div
            className="grid"
            style={{
              gridTemplateRows: "repeat(7, minmax(0, 1fr))",
              rowGap: "3px",
            }}
          >
            {DAY_LABELS.map((d, i) => (
              <div
                key={`d-${i}`}
                className="text-[10px] text-muted-foreground pr-2 self-center justify-self-end"
              >
                {d}
              </div>
            ))}
          </div>

          {/* Bottom-right: cells block. aspectRatio=N/7 so the grid
              shape is exactly cells_columns × cells_rows; with 1fr
              columns + 1fr rows, every cell is the same size — square
              modulo the small constant gap. */}
          <div
            className="grid gap-[3px]"
            style={{
              gridTemplateColumns: `repeat(${columns.length}, minmax(0, 1fr))`,
              gridTemplateRows: "repeat(7, minmax(0, 1fr))",
              aspectRatio: `${columns.length} / 7`,
              gridAutoFlow: "column",
            }}
          >
            {columns.flatMap((week, colIdx) =>
              week.map((cell) =>
                cell.inRange ? (
                  <Tooltip key={cell.iso}>
                    <TooltipTrigger asChild>
                      <div
                        className={`rounded-[2px] cursor-default ${INTENSITY_CLASS[intensity(cell.count, max)]}`}
                      />
                    </TooltipTrigger>
                    <TooltipContent
                      side="top"
                      align={
                        colIdx < 3
                          ? "start"
                          : colIdx > columns.length - 4
                            ? "end"
                            : "center"
                      }
                      avoidCollisions={false}
                      sideOffset={4}
                      className="px-2.5 py-1.5 text-sm leading-none !rounded-[3px] pointer-events-none select-none !animate-none [&]:transition-none data-[state=open]:!animate-none data-[state=closed]:!animate-none"
                    >
                      <span className="font-semibold tabular-nums">
                        {cell.count} {t("overview.activity.events")}
                      </span>
                      <span className="opacity-70 ml-1.5">
                        {format(parseISO(cell.iso), "MMM d, yyyy", {
                          locale: dateFnsLocale(lang),
                        })}
                      </span>
                    </TooltipContent>
                  </Tooltip>
                ) : (
                  <div
                    key={cell.iso}
                    className="rounded-[2px] bg-transparent"
                  />
                ),
              ),
            )}
          </div>
        </div>

        {/* Legend at bottom-right (GitHub style) */}
        <div className="flex justify-end items-center gap-1.5 mt-3 text-[10px] text-muted-foreground">
          <span>{t("overview.heatmap.less")}</span>
          {([0, 1, 2, 3, 4] as const).map((lv) => (
            <div
              key={lv}
              className={`w-2.5 h-2.5 rounded-[2px] ${INTENSITY_CLASS[lv]}`}
            />
          ))}
          <span>{t("overview.heatmap.more")}</span>
        </div>
      </div>
    </TooltipProvider>
  );
}