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

Avis

Dashboard Widget Activity Graph — React Dashboard-widgets Section — Incubator