{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "calendar-heatmap", "type": "registry:ui", "title": "CalendarHeatmap", "description": "GitHub-contributions-style activity heatmap, in squares or check-in dots.", "categories": [ "display" ], "registryDependencies": [ "https://whiskeyjack.net/r/utils.json" ], "files": [ { "path": "components/ui/calendar-heatmap.tsx", "type": "registry:ui", "target": "components/ui/calendar-heatmap.tsx", "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CalendarHeatmapCell {\n /** yyyy-MM-dd */\n date: string;\n /** activity count for that day (e.g. number of goals checked in) */\n count: number;\n}\n\nexport interface CalendarHeatmapProps {\n /** Contiguous daily cells, oldest first (one per day). */\n data: CalendarHeatmapCell[];\n /** Count that maps to the most intense color. Defaults to the data max\n * (so a single-goal heatmap reads as on/off). */\n maxCount?: number;\n /** Accessible summary for the whole graphic (role=\"img\"). The app passes a\n * translated string. */\n label?: string;\n /** Legend end labels (translated by the app). */\n lessLabel?: string;\n moreLabel?: string;\n /** Localized 3-letter month abbreviations, Jan..Dec (12). Falls back to en. */\n monthLabels?: string[];\n /** Cell shape: \"rounded\" (GitHub-style squares, default) or \"dot\" (circles,\n * to echo a dot-grid check-in motif). */\n shape?: \"rounded\" | \"dot\";\n className?: string;\n /** Inline styles for the root element. */\n style?: React.CSSProperties;\n}\n\nconst CELL = 12; // px\nconst GAP = 3; // px\nconst EN_MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n\nconst parseYMD = (s: string): Date => {\n const [y, m, d] = s.split(\"-\").map(Number);\n return new Date(y, m - 1, d);\n};\nconst fmtYMD = (d: Date): string =>\n `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, \"0\")}-${String(d.getDate()).padStart(2, \"0\")}`;\nconst addDays = (d: Date, n: number): Date => {\n const r = new Date(d);\n r.setDate(r.getDate() + n);\n return r;\n};\n/** Monday-based weekday index: Mon=0 .. Sun=6. */\nconst mondayIndex = (d: Date): number => (d.getDay() + 6) % 7;\nconst dayDiff = (a: Date, b: Date): number => Math.round((a.getTime() - b.getTime()) / 86_400_000);\n\n/** Faint track for empty days; filled days step up the accent. */\nfunction cellColor(count: number, max: number): string {\n if (count <= 0) return \"color-mix(in srgb, var(--color-accent-500) 10%, transparent)\";\n const ratio = max > 0 ? count / max : 1;\n const level = Math.min(4, Math.max(1, Math.ceil(ratio * 4)));\n const pct = [40, 60, 80, 100][level - 1];\n return `color-mix(in srgb, var(--color-accent-500) ${pct}%, transparent)`;\n}\n\n/**\n * A GitHub-contributions-style calendar heatmap. Lays the supplied daily cells\n * into a Monday-top week grid (columns = weeks), coloring each by its count\n * against `maxCount`. The grid is fluid: it caps at its natural cell size on wide\n * screens and shrinks the cells to fit narrower (mobile) containers, so a long\n * range never overflows or scrolls horizontally. The legend keeps a fixed cell\n * size. The whole grid is a single `role=\"img\"` with `label` as its description;\n * per-cell `title`s give sighted hover detail.\n */\nexport const CalendarHeatmap = React.forwardRef(function CalendarHeatmap({\n data,\n maxCount,\n label,\n lessLabel = \"Less\",\n moreLabel = \"More\",\n monthLabels = EN_MONTHS,\n shape = \"rounded\",\n className,\n style,\n}, ref) {\n const cellRadius = shape === \"dot\" ? \"50%\" : 3;\n if (data.length === 0) return null;\n\n const counts = new Map(data.map((c) => [c.date, c.count]));\n const firstDate = parseYMD(data[0].date);\n const lastDate = parseYMD(data[data.length - 1].date);\n // Pad back to the Monday on/before the first day so each column is a full week.\n const gridStart = addDays(firstDate, -mondayIndex(firstDate));\n const cols = Math.ceil((dayDiff(lastDate, gridStart) + 1) / 7);\n const max = maxCount ?? Math.max(1, ...data.map((c) => c.count));\n const total = data.reduce((s, c) => s + c.count, 0);\n const ariaLabel = label ?? `Activity heatmap: ${total} check-ins over ${data.length} days`;\n\n const cells: React.ReactNode[] = [];\n const months: { col: number; text: string }[] = [];\n let prevMonth = -1;\n for (let col = 0; col < cols; col++) {\n const colDate = addDays(gridStart, col * 7);\n const m = colDate.getMonth();\n if (m !== prevMonth) {\n months.push({ col, text: monthLabels[m] ?? EN_MONTHS[m] });\n prevMonth = m;\n }\n for (let row = 0; row < 7; row++) {\n const date = addDays(gridStart, col * 7 + row);\n const key = fmtYMD(date);\n const count = counts.get(key);\n const inRange = count !== undefined;\n cells.push(\n ,\n );\n }\n }\n\n const mutedText =\n \"text-[var(--color-text-secondary-light)] dark:text-[var(--color-text-secondary-dark)]\";\n\n // Desktop cap: the grid never grows past its natural CELL-sized footprint, so\n // it reads identically to before on wide screens. On narrow (mobile) widths the\n // 1fr columns shrink the cells to fit the container, so the whole range fits with\n // no horizontal scroll -- which would otherwise fight the tab-swipe gesture.\n const gridMaxWidth = cols * CELL + (cols - 1) * GAP;\n\n return (\n
\n
\n
\n {months.map((ml) => (\n \n {ml.text}\n \n ))}\n
\n \n {cells}\n
\n
\n
\n {lessLabel}\n {[0, 1, 2, 3, 4].map((l) => (\n \n ))}\n {moreLabel}\n
\n \n );\n});\n\nCalendarHeatmap.displayName = \"CalendarHeatmap\";\n" } ], "docs": "Feed it contiguous daily cells (oldest first) and it lays them into a Monday-top week grid. The grid is fluid, so a long range shrinks to fit a mobile container rather than scrolling horizontally and fighting tab-swipe gestures. Presentational and i18n-agnostic: pass translated label, lessLabel, moreLabel, and monthLabels.", "meta": { "group": "display", "related": [ "progress-bar" ], "exports": [ "CalendarHeatmap" ], "siteSlug": "calendar-heatmap" } }