"use client"; /** * ActivityGrid, from https://motion-brew.com/components/activity-grid * * One self contained file: the component, its animated icons and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. * * The animated icons are adapted from lucide-animated (https://lucide-animated.com), * MIT License, Copyright (c) 2024-2026 pqoqubbw. Icon shapes from Lucide * (https://lucide.dev), ISC License. Permission is hereby granted, free of * charge, to any person obtaining a copy of this software, to deal in it * without restriction, provided the above copyright notice and this * permission notice are included in all copies or substantial portions. */ import * as React from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { type ClassValue, clsx } from "clsx"; import { animate, AnimatePresence, motion, useAnimation, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, type Variants } from "motion/react"; import { twMerge } from "tailwind-merge"; // cn(): merges Tailwind classes, later ones win. /** Merge Tailwind classes, letting later classes win conflicts. */ function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // Animated icon: activity const ActivityIcon = (() => { interface ActivityIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ActivityIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, pathOffset: 0, transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], pathOffset: [1, 0], transition: { duration: 0.6, ease: "linear", opacity: { duration: 0.1 }, }, }, }; const ActivityIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); ActivityIcon.displayName = "ActivityIcon"; return ActivityIcon; })(); // Animated icon: flame const FlameIcon = (() => { interface FlameIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface FlameIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const PATH_VARIANTS: Variants = { normal: { pathLength: 1, opacity: 1, pathOffset: 0, }, animate: { opacity: [0, 1], pathLength: [0, 1], transition: { delay: 0.1, duration: 0.4, opacity: { duration: 0.1, delay: 0.1 }, }, }, }; const FlameIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); FlameIcon.displayName = "FlameIcon"; return FlameIcon; })(); // Animated icon: trending-up const TrendingUpIcon = (() => { interface TrendingUpIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface TrendingUpIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const SVG_VARIANTS: Variants = { animate: { x: 0, y: 0, translateX: [0, 2, 0], translateY: [0, -2, 0], transition: { duration: 0.5, }, }, }; const PATH_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], pathOffset: [1, 0], transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, }; const ARROW_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, transition: { delay: 0.3, duration: 0.3, opacity: { duration: 0.1, delay: 0.3 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], pathOffset: [0.5, 0], transition: { delay: 0.3, duration: 0.3, opacity: { duration: 0.1, delay: 0.3 }, }, }, }; const TrendingUpIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); TrendingUpIcon.displayName = "TrendingUpIcon"; return TrendingUpIcon; })(); // Animated icon: zap const ZapIcon = (() => { interface ZapHandle { startAnimation: () => void; stopAnimation: () => void; } interface ZapProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const PATH_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, transition: { duration: 0.6, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], transition: { duration: 0.6, opacity: { duration: 0.1 }, }, }, }; const ZapIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); ZapIcon.displayName = "ZapIcon"; return ZapIcon; })(); // ActivityGrid /** * ActivityGrid * * Trigger: in view, then hover, keys and resize. A white card with a year of * daily activity as a heatmap: one column per week, one square per day, in * tints and shades of one colour, dark ink by default. When the card scrolls into view the squares sweep * in as a diagonal wave from the oldest week to today while the total counts * up beside a change chip. Hover a day and its row and column stay lit while * the rest dims, with a tooltip naming the day and its count; hover a step of * the legend and only the days at that level stay lit. The grid measures its * own width and shows as many recent weeks as fit, so it sits in a small card * and stretches to the full year on a wide one; the total, the streak tiles * and the period chip count to the new numbers when the width changes. The * arrow keys move through the days. Reduced motion shows the final state at * once. * * Engine: motion/react (motion values counting the numbers, AnimatePresence * for the tooltip) with CSS transitions staggering the squares, a * ResizeObserver fitting the weeks, and animated icons from lucide-animated * (MIT), vendored in src/icons. */ /** * Ready made ramps: one colour each, from a light tint on quiet days to a * deep shade on the busiest. */ export const ACTIVITY_PALETTES = { ink: ["#dcdcda", "#a6a6a3", "#6e6e6b", "#242423"], green: ["#c9f2d5", "#7ddc96", "#2fbf5c", "#17803a"], violet: ["#ddd6ff", "#ad9bff", "#7b61ff", "#4a2fd1"], mint: ["#c6f4e3", "#6fe0b8", "#16c79a", "#0b8a6a"], amber: ["#ffe7b3", "#ffc85c", "#ffad1f", "#d98200"], sky: ["#cbeaff", "#7cc8ff", "#2aa8f2", "#0a72b8"], } as const satisfies Record; export interface ActivityGridProps { /** Card title. Default `"Focus sessions"`. */ title?: string; /** One value per day, oldest first, the last one is today. Default a year of sample sessions. */ data?: number[]; /** The last day in `data`. Default today. */ endDate?: Date; /** What is counted, singular. Default `"session"`. */ unit?: string; /** A ready made colour, in tints and shades. Default `"ink"`, which turns light in dark mode. */ palette?: keyof typeof ACTIVITY_PALETTES; /** Four colours for the activity levels, quietest to busiest; overrides `palette`. */ colors?: readonly [string, string, string, string]; /** Preferred square size in px; squares grow up to 16px when the whole year fits. Default `11`. */ cellSize?: number; /** Show the three streak tiles. Default `true`. */ showStats?: boolean; className?: string; } const WEEKS = 53; const GAP = 3; const DAY_LABEL_W = 26; const MAX_CELL = 16; const LEVELS = [0, 1, 2, 3, 4] as const; const EASE = [0.22, 1, 0.36, 1] as const; const EASE_CSS = "cubic-bezier(0.22,1,0.36,1)"; const DAY = 86_400_000; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; /** A year of believable sample sessions: busier on weekdays, waves of busy weeks, two quiet spells, a run at the end. */ function sampleData(days: number) { let seed = 11; const rand = () => { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }; const out: number[] = []; for (let i = 0; i < days; i++) { const weekday = (i + 2) % 7 < 5; // Slow waves of busier and calmer weeks across the year. const wave = 0.5 + 0.3 * Math.sin((i / days) * Math.PI * 6) + 0.2 * Math.sin((i / days) * Math.PI * 2 + 1.2); const quiet = (i > 96 && i < 106) || (i > 236 && i < 242); const run = i > days - 16; const r = rand(); let v = 0; if (run) v = 2 + Math.round(r * 6); else if (!quiet && r < (weekday ? 0.86 : 0.45)) v = Math.max(1, Math.round((0.25 + rand()) * 8 * wave)); out.push(v); } return out; } const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); const shortDate = (d: Date) => `${WEEKDAYS[(d.getDay() + 6) % 7]} ${d.getDate()} ${MONTHS[d.getMonth()]}`; const longDate = (d: Date) => `${MONTHS[d.getMonth()]} ${d.getDate()}`; /** A number that counts to its value on a motion value whenever the value changes. */ function Count({ value, run, still, delay = 0 }: { value: number; run: boolean; still: boolean; delay?: number }) { const mv = useMotionValue(still ? value : 0); const [text, setText] = React.useState(() => Math.round(still ? value : 0).toLocaleString("en-US")); useMotionValueEvent(mv, "change", (v) => setText(Math.round(v).toLocaleString("en-US"))); const first = React.useRef(true); React.useEffect(() => { if (!run) return; if (still) { mv.set(value); return; } const c = animate(mv, value, { duration: first.current ? 1.3 : 0.7, delay: first.current ? delay : 0, ease: EASE }); first.current = false; return () => c.stop(); }, [run, still, value, delay, mv]); return {text}; } export const ActivityGrid = React.forwardRef(function ActivityGrid( { title = "Focus sessions", data: dataProp, endDate, unit = "session", palette = "ink", colors: colorsProp, cellSize = 11, showStats = true, className, }, forwardedRef, ) { const ref = React.useRef(null); React.useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement); const colors = colorsProp ?? ACTIVITY_PALETTES[palette] ?? ACTIVITY_PALETTES.ink; const inView = useInView(ref, { once: true, amount: 0.3 }); const still = !!useReducedMotion(); const shown = still || inView; // The calendar: WEEKS columns of Monday to Sunday, the last column holding today. const [today] = React.useState(() => startOfDay(endDate ?? new Date())); const todayRow = (today.getDay() + 6) % 7; const totalDays = (WEEKS - 1) * 7 + todayRow + 1; const data = React.useMemo(() => { const src = dataProp ?? sampleData(totalDays); const tail = src.slice(-totalDays); return [...Array(Math.max(0, totalDays - tail.length)).fill(0), ...tail] as number[]; }, [dataProp, totalDays]); const firstDay = new Date(today.getTime() - (totalDays - 1) * DAY); // Steps by quartiles of the active days, so any scale of data reads well. const thresholds = React.useMemo(() => { const active = data.filter((v) => v > 0).sort((a, b) => a - b); if (!active.length) return [1, 1, 1]; const q = (p: number) => active[Math.min(active.length - 1, Math.floor(p * active.length))]!; return [q(0.25), q(0.5), q(0.75)]; }, [data]); const levelOf = (v: number) => (v <= 0 ? 0 : v <= thresholds[0]! ? 1 : v <= thresholds[1]! ? 2 : v <= thresholds[2]! ? 3 : 4); // Fit as many recent weeks as the width allows; when the whole year fits, grow the squares. const gridRef = React.useRef(null); const [width, setWidth] = React.useState(0); React.useLayoutEffect(() => { const el = gridRef.current; if (!el || typeof ResizeObserver === "undefined") return; const ro = new ResizeObserver(([e]) => setWidth(e!.contentRect.width)); ro.observe(el); setWidth(el.getBoundingClientRect().width); return () => ro.disconnect(); }, []); const area = Math.max(0, width - DAY_LABEL_W); const fit = Math.max(8, Math.floor((area + GAP) / (cellSize + GAP))); const cols = Math.min(WEEKS, fit); const cell = cols === WEEKS ? Math.max(cellSize, Math.min(MAX_CELL, Math.floor((area + GAP) / WEEKS) - GAP)) : cellSize; const firstCol = WEEKS - cols; // The visible window, and the same length just before it for the change chip. const dayIndex = (col: number, row: number) => col * 7 + row; const windowStart = firstCol * 7; const windowValues = data.slice(windowStart); const total = windowValues.reduce((a, b) => a + b, 0); const prevStart = windowStart - windowValues.length; const prevTotal = prevStart >= 0 ? data.slice(prevStart, windowStart).reduce((a, b) => a + b, 0) : null; const change = prevTotal ? Math.round(((total - prevTotal) / prevTotal) * 100) : null; // Streaks inside the visible window: the longest run of active days, and the one ending today. const streaks = React.useMemo(() => { let best = 0, bestEnd = -1, run = 0; windowValues.forEach((v, i) => { run = v > 0 ? run + 1 : 0; if (run > best) { best = run; bestEnd = i; } }); let current = 0; for (let i = windowValues.length - 1; i >= 0 && windowValues[i]! > 0; i--) current++; const at = (i: number) => new Date(firstDay.getTime() + (windowStart + i) * DAY); return { best, bestRange: best ? `${longDate(at(bestEnd - best + 1))} to ${longDate(at(bestEnd))}` : "No streak yet", current, currentRange: current ? `Since ${longDate(at(windowValues.length - current))}` : "Start one today", }; }, [windowValues, firstDay, windowStart]); const weeksLabel = cols >= 52 ? "Last 12 months" : `Last ${cols} weeks`; const plural = (n: number) => `${n.toLocaleString("en-US")} ${unit}${n === 1 ? "" : "s"}`; // Month labels where a new month starts, kept apart so they never collide. const months = React.useMemo(() => { const out: { col: number; label: string }[] = []; let last = -1, lastCol = -10; for (let c = 0; c < cols; c++) { const d = new Date(firstDay.getTime() + dayIndex(firstCol + c, 0) * DAY); if (d.getMonth() !== last) { if (c - lastCol >= 3 && !(c === 0 && d.getDate() > 20)) { out.push({ col: c, label: MONTHS[d.getMonth()]! }); lastCol = c; } last = d.getMonth(); } } return out; }, [cols, firstCol, firstDay]); // Hover and keys: one active day, or one legend level. const [active, setActive] = React.useState<{ col: number; row: number } | null>(null); const [level, setLevel] = React.useState(null); const activeIdx = active ? dayIndex(firstCol + active.col, active.row) : -1; const activeDate = active ? new Date(firstDay.getTime() + activeIdx * DAY) : null; const tip = active && activeIdx < totalDays ? `${plural(data[activeIdx]!)}, ${shortDate(activeDate!)}` : ""; const onKey = (e: React.KeyboardEvent) => { const moves: Record = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1], }; const m = moves[e.key]; if (e.key === "Escape") return setActive(null); if (!m) return; e.preventDefault(); setActive((a) => { const from = a ?? { col: cols - 1, row: todayRow }; let col = Math.max(0, Math.min(cols - 1, from.col + m[0])); let row = Math.max(0, Math.min(6, from.row + m[1])); if (col === cols - 1 && row > todayRow) row = todayRow; return { col, row }; }); }; const [hoverStat, setHoverStat] = React.useState(null); const stats = [ { label: `Total ${unit}s`, value: total, suffix: "", note: weeksLabel, Icon: ActivityIcon }, { label: "Longest streak", value: streaks.best, suffix: streaks.best === 1 ? " day" : " days", note: streaks.bestRange, Icon: FlameIcon }, { label: "Current streak", value: streaks.current, suffix: streaks.current === 1 ? " day" : " days", note: streaks.currentRange, Icon: ZapIcon }, ]; const gridH = 7 * cell + 6 * GAP; return ( {/* Header: the title, the period, and the total with its change. */}

{title}

{unit}s {change !== null && ( = 0 ? "bg-[#269a5a]/10 text-[#269a5a]" : "bg-[#242423]/[0.06] text-zinc-500 dark:bg-white/[0.08] dark:text-zinc-400", )} > {change >= 0 ? "+" : ""} {change}% )}

{weeksLabel}
{/* The grid: month labels, day labels and one square per day. */}
{width > 0 && ( <>
{months.map((m) => ( {m.label} ))}
{[0, 2, 4].map((r) => ( {WEEKDAYS[r]} ))}
setActive(null)} onPointerLeave={() => setActive(null)} className="relative grid rounded-md outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-4 dark:focus-visible:ring-offset-zinc-900" style={{ gridTemplateColumns: `repeat(${cols}, ${cell}px)`, gridTemplateRows: `repeat(7, ${cell}px)`, gridAutoFlow: "column", gap: GAP, }} > {Array.from({ length: cols * 7 }, (_, i) => { const col = Math.floor(i / 7); const row = i % 7; const idx = dayIndex(firstCol + col, row); if (idx >= totalDays) return ; const v = data[idx]!; const lv = levelOf(v); const lit = level !== null ? lv === level : active ? active.col === col || active.row === row : true; const isActive = active?.col === col && active.row === row; const isToday = idx === totalDays - 1; return ( setActive({ col, row })} className="relative transition-opacity duration-150" style={{ opacity: lit ? 1 : 0.35 }} > {lv > 0 && ( )} ); })}
{/* Tooltip over the active day. */} {active && tip && ( {tip} )} )} {width === 0 &&
}
{/* Legend: hover a step to light only the days at that level. */}
Less {LEVELS.map((lv) => ( ))} More
{/* The streak tiles, counting to their numbers. */} {showStats && (
{stats.map((s, i) => ( setHoverStat(i)} onPointerLeave={() => setHoverStat(null)} initial={still ? false : { opacity: 0, y: 10 }} animate={shown ? { opacity: 1, y: 0 } : undefined} transition={{ duration: 0.5, delay: still ? 0 : 0.5 + i * 0.08, ease: EASE }} className="min-w-0 rounded-2xl bg-[#242423]/[0.04] p-3 transition-colors duration-200 hover:bg-[#242423]/[0.07] dark:bg-white/[0.05] dark:hover:bg-white/[0.08]" >
{s.label}
{s.suffix}
{s.note}
))}
)} ); });