Retour au catalogue
Dashboard Widget Activity Graph
Graphe d'activite style GitHub avec grille de contribution, tooltips et statistiques annuelles.
dashboard-widgetsmedium Both Responsive a11y
minimalboldsaasportfoliofullscreen
Theme
"use client";
import { useState, useRef, useMemo } from "react";
import { motion, useInView } from "framer-motion";
import { Activity, TrendingUp, Calendar, Flame } from "lucide-react";
interface ActivityDay {
date: string;
count: number;
}
interface DashboardWidgetActivityGraphProps {
data?: ActivityDay[];
title?: string;
totalLabel?: string;
streakLabel?: string;
averageLabel?: string;
totalCount?: number;
currentStreak?: number;
averagePerDay?: number;
}
const ease: [number, number, number, number] = [0.16, 1, 0.3, 1];
const MONTHS = [
"Jan", "Fev", "Mar", "Avr", "Mai", "Juin",
"Juil", "Aou", "Sep", "Oct", "Nov", "Dec",
];
const DAYS = ["", "Lun", "", "Mer", "", "Ven", ""];
export default function DashboardWidgetActivityGraph({
data = [],
title = "Activite",
totalLabel = "contributions cette annee",
streakLabel = "jours consecutifs",
averageLabel = "par jour en moyenne",
totalCount = 0,
currentStreak = 0,
averagePerDay = 0,
}: DashboardWidgetActivityGraphProps) {
const [hoveredCell, setHoveredCell] = useState<{
date: string;
count: number;
x: number;
y: number;
} | null>(null);
const sectionRef = useRef<HTMLDivElement>(null);
const isInView = useInView(sectionRef, { once: true, amount: 0.3 });
// Build a 52-week x 7-day grid
const weeks = useMemo(() => {
const grid: ActivityDay[][] = [];
const totalWeeks = 52;
for (let w = 0; w < totalWeeks; w++) {
const week: ActivityDay[] = [];
for (let d = 0; d < 7; d++) {
const index = w * 7 + d;
if (index < data.length) {
week.push(data[index]);
} else {
week.push({ date: "", count: 0 });
}
}
grid.push(week);
}
return grid;
}, [data]);
const maxCount = Math.max(...data.map((d) => d.count), 1);
const getLevel = (count: number) => {
if (count === 0) return 0;
const ratio = count / maxCount;
if (ratio <= 0.25) return 1;
if (ratio <= 0.5) return 2;
if (ratio <= 0.75) return 3;
return 4;
};
const levelColors = [
"var(--color-background-alt)",
"var(--color-accent-subtle)",
"var(--color-accent)",
"var(--color-accent)",
"var(--color-accent)",
];
const levelOpacities = [1, 0.5, 0.6, 0.8, 1];
return (
<section ref={sectionRef} className="py-12 px-6">
<div className="max-w-4xl mx-auto">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, ease }}
className="rounded-3xl overflow-hidden p-6"
style={{
background: "var(--color-background-card)",
border: "1px solid var(--color-border)",
}}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Activity
size={20}
style={{ color: "var(--color-accent)" }}
/>
<h2
className="text-lg font-bold"
style={{ color: "var(--color-foreground)" }}
>
{title}
</h2>
</div>
{/* Stats pills */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<TrendingUp
size={14}
style={{ color: "var(--color-accent)" }}
/>
<span
className="text-sm"
style={{ color: "var(--color-foreground-muted)" }}
>
<strong
style={{ color: "var(--color-foreground)" }}
>
{totalCount}
</strong>{" "}
{totalLabel}
</span>
</div>
<div className="flex items-center gap-2">
<Flame
size={14}
style={{ color: "#F59E0B" }}
/>
<span
className="text-sm"
style={{ color: "var(--color-foreground-muted)" }}
>
<strong
style={{ color: "var(--color-foreground)" }}
>
{currentStreak}
</strong>{" "}
{streakLabel}
</span>
</div>
</div>
</div>
{/* Graph */}
<div className="relative overflow-x-auto">
{/* Month labels */}
<div className="flex ml-8 mb-2">
{MONTHS.map((m, i) => (
<div
key={m}
className="text-[10px] font-medium"
style={{
width: `${100 / 12}%`,
color: "var(--color-foreground-light)",
}}
>
{m}
</div>
))}
</div>
<div className="flex gap-1">
{/* Day labels */}
<div className="flex flex-col gap-1 shrink-0 pr-1">
{DAYS.map((day, i) => (
<div
key={i}
className="h-3 text-[9px] flex items-center"
style={{ color: "var(--color-foreground-light)" }}
>
{day}
</div>
))}
</div>
{/* Grid */}
<div className="flex gap-[3px] flex-1">
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col gap-[3px]">
{week.map((day, di) => {
const level = getLevel(day.count);
return (
<motion.div
key={`${wi}-${di}`}
initial={{ opacity: 0, scale: 0 }}
animate={
isInView
? { opacity: 1, scale: 1 }
: {}
}
transition={{
duration: 0.2,
delay: wi * 0.01 + di * 0.005,
}}
className="w-3 h-3 rounded-sm cursor-pointer"
style={{
background: levelColors[level],
opacity: levelOpacities[level],
}}
onMouseEnter={(e) => {
if (day.date) {
const rect =
e.currentTarget.getBoundingClientRect();
setHoveredCell({
date: day.date,
count: day.count,
x: rect.left + rect.width / 2,
y: rect.top,
});
}
}}
onMouseLeave={() => setHoveredCell(null)}
whileHover={{ scale: 1.5 }}
/>
);
})}
</div>
))}
</div>
</div>
{/* Tooltip */}
{hoveredCell && (
<div
className="fixed z-50 px-3 py-2 rounded-lg text-xs pointer-events-none"
style={{
left: hoveredCell.x,
top: hoveredCell.y - 40,
transform: "translateX(-50%)",
background: "var(--color-foreground)",
color: "var(--color-background)",
}}
>
<strong>{hoveredCell.count} contributions</strong> le{" "}
{hoveredCell.date}
</div>
)}
</div>
{/* Legend */}
<div className="flex items-center justify-between mt-6">
<div
className="text-xs"
style={{ color: "var(--color-foreground-muted)" }}
>
~{averagePerDay} {averageLabel}
</div>
<div className="flex items-center gap-1.5 text-[10px]">
<span
style={{ color: "var(--color-foreground-light)" }}
>
Moins
</span>
{[0, 1, 2, 3, 4].map((level) => (
<div
key={level}
className="w-3 h-3 rounded-sm"
style={{
background: levelColors[level],
opacity: levelOpacities[level],
}}
/>
))}
<span
style={{ color: "var(--color-foreground-light)" }}
>
Plus
</span>
</div>
</div>
</motion.div>
</div>
</section>
);
}