import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { fetchDailyCounts } from '../api/client'; const CELL_SIZE = 12; const GAP = 3; const RADIUS = CELL_SIZE / 2; const ROWS = 7; // days per week const LEFT_LABEL_WIDTH = 28; const TOP_LABEL_HEIGHT = 16; const DAY_LABELS = ['', 'mon', '', 'wed', '', 'fri', '']; const MONTH_LABELS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const COLORS = [ 'rgba(255,255,255,0.05)', // 0: empty '#0e4429', // 1: low '#006d32', // 2: medium-low '#26a641', // 3: medium '#39d353', // 4: high ]; export function ContributionGraph() { const to = new Date(); const from = new Date(to); from.setFullYear(from.getFullYear() - 1); const fromStr = fmt(from); const toStr = fmt(to); const dailyQ = useQuery({ queryKey: ['daily-counts', fromStr, toStr], queryFn: () => fetchDailyCounts(fromStr, toStr), staleTime: 5 * 60_000, }); const navigate = useNavigate(); const { weeks, monthMarkers, thresholds, totalCount } = useMemo(() => { const counts = dailyQ.data ?? []; const countMap = new Map(counts.map((d) => [d.date, d.count])); // Start from the Sunday before `from` const start = new Date(from); start.setDate(start.getDate() - start.getDay()); const weeks: { date: string; count: number; col: number; row: number }[][] = []; const monthMarkers: { col: number; label: string }[] = []; let col = 0; let prevMonth = -1; const cursor = new Date(start); while (cursor <= to) { const week: typeof weeks[0] = []; for (let row = 0; row < ROWS; row++) { const dateStr = fmt(cursor); const count = countMap.get(dateStr) ?? 0; week.push({ date: dateStr, count, col, row }); // Track month transitions (on the first day of each week) if (row === 0) { const m = cursor.getMonth(); if (m !== prevMonth) { monthMarkers.push({ col, label: MONTH_LABELS[m] }); prevMonth = m; } } cursor.setDate(cursor.getDate() + 1); } weeks.push(week); col++; } // Compute quantile thresholds from non-zero counts const nonZero = counts.map((d) => d.count).filter((c) => c > 0).sort((a, b) => a - b); const thresholds = computeThresholds(nonZero); const totalCount = counts.reduce((sum, d) => sum + d.count, 0); return { weeks, monthMarkers, thresholds, totalCount }; }, [dailyQ.data]); const cols = weeks.length; const svgWidth = LEFT_LABEL_WIDTH + cols * (CELL_SIZE + GAP); const svgHeight = TOP_LABEL_HEIGHT + ROWS * (CELL_SIZE + GAP); if (dailyQ.isLoading) return

loading contribution graph...

; if (dailyQ.isError) return null; return (

{totalCount} contributions in the last year

{/* Day-of-week labels */} {DAY_LABELS.map((label, i) => label ? ( {label} ) : null, )} {/* Month labels */} {monthMarkers.map(({ col, label }, i) => ( {label} ))} {/* Circles */} {weeks.flatMap((week) => week.map(({ date, count, col, row }) => ( navigate(`/activity/${date}`)} > {`${date}: ${count} ${count === 1 ? 'contribution' : 'contributions'}`} )), )}
); } function fmt(d: Date): string { return d.toISOString().slice(0, 10); } function colorFor(count: number, thresholds: number[]): string { if (count === 0) return COLORS[0]; if (count <= thresholds[0]) return COLORS[1]; if (count <= thresholds[1]) return COLORS[2]; if (count <= thresholds[2]) return COLORS[3]; return COLORS[4]; } function computeThresholds(sorted: number[]): number[] { if (sorted.length === 0) return [1, 2, 3]; const p = (pct: number) => sorted[Math.min(Math.floor(pct * sorted.length), sorted.length - 1)]; return [p(0.25), p(0.5), p(0.75)]; }