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>
);
}