"use client"; import { motion, useReducedMotion } from "motion/react"; import { type ReactNode, useMemo } from "react"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface StatsBarProps { className?: string; children?: ReactNode; } /** Connected row of `StatsItem`s, hairline-divided between siblings. */ export function StatsBar({ className, children }: StatsBarProps) { return (
{children}
); } export interface StatsItemProps { /** Typically an `AnimatedNumber`/`NumberTicker` node so it counts in on mount. */ value: ReactNode; label: ReactNode; className?: string; } /** One metric cell inside a `StatsBar` — a value line over a muted label line, both truncating equally. */ export function StatsItem({ value, label, className }: StatsItemProps) { return (
{value}
{label}
); } /** Opacity bands for level 1–4, keyed by `value / max` thresholds (<=.25/.5/.75/1). */ const HEAT_ALPHA: Record<1 | 2 | 3 | 4, number> = { 1: 0.25, 2: 0.45, 3: 0.7, 4: 1 }; function heatLevel(value: number, max: number): 0 | 1 | 2 | 3 | 4 { if (value <= 0 || max <= 0) return 0; const ratio = value / max; if (ratio <= 0.25) return 1; if (ratio <= 0.5) return 2; if (ratio <= 0.75) return 3; return 4; } export interface ActivityHeatmapProps { /** * Column-major cell values: index `week * 7 + day`, top to bottom then * left to right. Shorter arrays are zero-padded up to `weeks * 7`. */ data: number[]; weeks?: number; /** Ceiling for the color scale; defaults to the highest value in `data`. */ max?: number; /** Month-axis labels under the grid, `week` is the column index they sit above. */ months?: { label: string; week: number }[]; /** Native `title` tooltip text; defaults to the raw value. */ cellTitle?: (index: number, value: number) => string; className?: string; } /** * GitHub-style contribution grid. Colors come from a single `--heat-accent` * CSS variable (blue, tuned separately for each color scheme) mixed at four * opacity bands via `color-mix`, so cells never need a per-scheme class list. * Entrance is a one-time column-cascade fade: each of the (up to) 52 columns * is its own `motion.div` fading in with a small stagger, while the 7 cells * inside a column mount statically — animating per-column instead of per-cell * keeps this to `weeks` motion nodes instead of `weeks * 7`. * `useReducedMotion()` skips the cascade and renders everything in place. */ export function ActivityHeatmap({ data, weeks = 52, max, months, cellTitle, className, }: ActivityHeatmapProps) { const reduce = useReducedMotion() ?? false; const total = weeks * 7; const padded = useMemo( () => Array.from({ length: total }, (_, i) => data[i] ?? 0), [data, total], ); const effectiveMax = useMemo(() => max ?? Math.max(1, ...padded), [max, padded]); const columns = useMemo( () => Array.from({ length: weeks }, (_, w) => padded.slice(w * 7, w * 7 + 7)), [padded, weeks], ); return (
{columns.map((column, weekIndex) => ( {column.map((value, day) => { const index = weekIndex * 7 + day; const level = heatLevel(value, effectiveMax); return ( ); })} ))}
{months && months.length > 0 ? (
{months.map((month) => ( {month.label} ))}
) : null}
); } export interface StatsPeriodTabsProps { options: { value: string; label: ReactNode }[]; value: string; onChange: (value: string) => void; className?: string; } /** Plain-text period switch (e.g. "Daily / Weekly / Total") — no pill, just color contrast on the active option. */ export function StatsPeriodTabs({ options, value, onChange, className }: StatsPeriodTabsProps) { return (
{options.map((option) => ( ))}
); }