import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { fetchDailyCounts, fetchHourlyAvgs, fetchSources } from '../api/client'; export function ContributionStats() { const sourcesQ = useQuery({ queryKey: ['sources'], queryFn: fetchSources, staleTime: 60_000, }); const earliest = useMemo(() => { if (!sourcesQ.data) return null; const dates = sourcesQ.data .map((s) => s.earliest) .filter((d): d is string => d != null) .map((d) => new Date(d)); return dates.length > 0 ? new Date(Math.min(...dates.map((d) => d.getTime()))) : null; }, [sourcesQ.data]); const to = new Date(); const from = earliest ?? new Date(to.getFullYear() - 5, 0, 1); const fromStr = fmt(from); const toStr = fmt(to); const dailyQ = useQuery({ queryKey: ['daily-counts-alltime', fromStr, toStr], queryFn: () => fetchDailyCounts(fromStr, toStr), enabled: !!earliest, staleTime: 10 * 60_000, }); // Bucket hour-of-day in the user's local timezone so the chart matches // the clock they see. Browser may report e.g. "Europe/Helsinki"; fall // back to UTC if the resolver returns something the server won't // accept (it validates the string before binding). const tz = useMemo( () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', [], ); const hourlyQ = useQuery({ queryKey: ['hourly-avgs-alltime', fromStr, toStr, tz], queryFn: () => fetchHourlyAvgs(fromStr, toStr, tz), enabled: !!earliest, staleTime: 10 * 60_000, }); const stats = useMemo(() => { const counts = dailyQ.data ?? []; if (counts.length === 0) return null; // Build a set of dates with contributions const countMap = new Map(counts.map((d) => [d.date, d.count])); // Current streak (consecutive days ending today or yesterday with contributions) let currentStreak = 0; const cursor = new Date(to); // Allow today to have 0 (day isn't over yet) — start from yesterday if today is 0 if ((countMap.get(fmt(cursor)) ?? 0) > 0) { currentStreak = 1; cursor.setDate(cursor.getDate() - 1); } else { cursor.setDate(cursor.getDate() - 1); if ((countMap.get(fmt(cursor)) ?? 0) > 0) { currentStreak = 1; cursor.setDate(cursor.getDate() - 1); } } if (currentStreak > 0) { while ((countMap.get(fmt(cursor)) ?? 0) > 0) { currentStreak++; cursor.setDate(cursor.getDate() - 1); } } // Longest streak let longestStreak = 0; let streak = 0; const sorted = [...counts].sort((a, b) => a.date.localeCompare(b.date)); for (let i = 0; i < sorted.length; i++) { if (sorted[i].count > 0) { streak++; if (streak > longestStreak) longestStreak = streak; } else { streak = 0; } } // Busiest day const busiest = sorted.reduce((best, d) => (d.count > best.count ? d : best), sorted[0]); // Day-of-week averages const dayTotals = [0, 0, 0, 0, 0, 0, 0]; const dayCounts = [0, 0, 0, 0, 0, 0, 0]; for (const d of sorted) { const dow = new Date(d.date + 'T00:00:00').getDay(); dayTotals[dow] += d.count; dayCounts[dow]++; } const dayNames = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; const dayAvgs = dayNames.map((name, i) => ({ name, avg: dayCounts[i] > 0 ? dayTotals[i] / dayCounts[i] : 0, })); const maxAvg = Math.max(...dayAvgs.map((d) => d.avg)); // Total active days const activeDays = sorted.filter((d) => d.count > 0).length; return { currentStreak, longestStreak, busiest, dayAvgs, maxAvg, activeDays }; }, [dailyQ.data]); const hourly = useMemo(() => { const data = hourlyQ.data ?? []; if (data.length === 0) return null; const byHour = new Array(24).fill(0); for (const { hour, avg } of data) { if (hour >= 0 && hour < 24) byHour[hour] = avg; } const max = Math.max(...byHour); return { hours: byHour, max }; }, [hourlyQ.data]); if (!stats) return null; return (

contribution stats

current streak {stats.currentStreak} {stats.currentStreak === 1 ? 'day' : 'days'}
longest streak {stats.longestStreak} {stats.longestStreak === 1 ? 'day' : 'days'}
busiest day {stats.busiest.count} on {stats.busiest.date}
active days {stats.activeDays.toLocaleString()}
avg by weekday
{stats.dayAvgs.map(({ name, avg }) => (
{avg.toFixed(1)}
0 ? `${(avg / stats.maxAvg) * 100}%` : '0%', borderRadius: 3, backgroundColor: '#39d353', opacity: 0.7, }} />
{name}
))}
{hourly && (
avg by hour ({tz})
{hourly.hours.map((avg, h) => (
0 ? `${(avg / hourly.max) * 100}%` : '0%', borderRadius: 2, backgroundColor: '#39d353', opacity: 0.7, }} title={`${h.toString().padStart(2, '0')}:00 — ${avg.toFixed(2)}/day`} />
{h % 4 === 0 ? h.toString().padStart(2, '0') : ''}
))}
)}
); } function fmt(d: Date): string { return d.toISOString().slice(0, 10); }