diff --git a/src/components/ContributionHeatmap.tsx b/src/components/ContributionHeatmap.tsx index 4e4de232..e98e51b4 100644 --- a/src/components/ContributionHeatmap.tsx +++ b/src/components/ContributionHeatmap.tsx @@ -57,6 +57,10 @@ export interface ContributionHeatmapProps { showColorLegend?: boolean; scrollContainerRef?: React.RefObject; scrollContainerSx?: SxProps; + getBlockColor?: (activity: { + count: number; + date: string; + }) => string | undefined; } const ContributionHeatmap: React.FC = ({ @@ -80,6 +84,7 @@ const ContributionHeatmap: React.FC = ({ showColorLegend, scrollContainerRef, scrollContainerSx, + getBlockColor, }) => { const theme = useTheme(); const heatmapLevels = [...CONTRIBUTION_HEATMAP_SCALE]; @@ -168,13 +173,19 @@ const ContributionHeatmap: React.FC = ({ renderBlock={(block, activity) => { const clickable = interactive; const isSelected = selectedDate === activity.date; + const blockColor = getBlockColor?.(activity); + const coloredBlock = blockColor + ? React.cloneElement(block as React.ReactElement, { + fill: blockColor, + }) + : block; const highlighted = clickable && isSelected - ? React.cloneElement(block as React.ReactElement, { + ? React.cloneElement(coloredBlock as React.ReactElement, { stroke: theme.palette.text.primary, strokeWidth: 1.5, }) - : block; + : coloredBlock; const wrapped = clickable ? ( onDayClick?.(activity.date)} diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx index aef70c51..4ff8d9a9 100644 --- a/src/pages/dashboard/DashboardPage.tsx +++ b/src/pages/dashboard/DashboardPage.tsx @@ -10,6 +10,11 @@ import DashboardFeaturedWorkSection from './views/DashboardFeaturedWork'; import DashboardTopContributors from './views/DashboardTopContributors'; import LiveSidebar from './views/LiveSidebar'; +const getCurrentMonthKey = () => { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; +}; + const DashboardFeaturePage: React.FC = () => { const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isTablet = useMediaQuery(theme.breakpoints.between('sm', 'md')); @@ -17,6 +22,8 @@ const DashboardFeaturePage: React.FC = () => { const showSidebarRight = useMediaQuery(theme.breakpoints.up('xl')); const [range, setRange] = useState('35d'); + const [calendarMonth, setCalendarMonth] = + useState(getCurrentMonthKey); const { kpis, overview, @@ -28,7 +35,7 @@ const DashboardFeaturePage: React.FC = () => { featuredContributors, featuredDiscoveryContributors, isLoading, - } = useDashboardData(range); + } = useDashboardData(range, calendarMonth); const sidebarWidth = isMobile || isTablet ? '100%' : isLargeScreen ? '340px' : '300px'; @@ -81,6 +88,7 @@ const DashboardFeaturePage: React.FC = () => { kpis={kpis} isLoading={isLoading} onRangeChange={setRange} + onCalendarMonthChange={setCalendarMonth} /> { return date.getTime(); }; +const getLocalHourStart = (timestamp: number) => { + const date = new Date(timestamp); + date.setMinutes(0, 0, 0); + return date.getTime(); +}; + const addLocalDays = (dayStartMs: number, days: number) => { const date = new Date(dayStartMs); date.setDate(date.getDate() + days); @@ -380,11 +397,9 @@ const addLocalDays = (dayStartMs: number, days: number) => { return date.getTime(); }; -/** Sunday 00:00 local for the week containing `timestamp`. */ -const getLocalSundayWeekStart = (timestamp: number) => { - const date = new Date(timestamp); - date.setHours(0, 0, 0, 0); - date.setDate(date.getDate() - date.getDay()); +const addLocalHours = (hourStartMs: number, hours: number) => { + const date = new Date(hourStartMs); + date.setHours(date.getHours() + hours, 0, 0, 0); return date.getTime(); }; @@ -393,88 +408,302 @@ const parseCalendarDateKey = (dateKey: string) => { return getLocalDayStart(new Date(year, month - 1, day).getTime()); }; -const getContributionLevel = (count: number): 0 | 1 | 2 | 3 | 4 => { +const parseCalendarHourKey = (hourKey: string) => { + const [dateKey, hourString] = hourKey.split('T'); + const [year, month, day] = dateKey.split('-').map(Number); + return getLocalHourStart( + new Date(year, month - 1, day, Number(hourString)).getTime(), + ); +}; + +const formatMonthKey = (timestamp: number) => { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + return `${year}-${month}`; +}; + +const formatHourKey = (timestamp: number) => { + const date = new Date(timestamp); + return `${formatCalendarDateKey(timestamp)}T${String( + date.getHours(), + ).padStart(2, '0')}`; +}; + +const parseMonthKey = (monthKey: string) => { + const [year, month] = monthKey.split('-').map(Number); + return { year, monthIndex: month - 1 }; +}; + +const getContributionLevel = ( + count: number, + nonZeroCounts: number[], +): 0 | 1 | 2 | 3 | 4 => { if (count <= 0) return 0; - if (count < 2) return 1; - if (count < 3) return 2; - if (count < 5) return 3; + if (nonZeroCounts.length === 0) return 0; + + const lower = nonZeroCounts[Math.floor((nonZeroCounts.length - 1) * 0.25)]; + const middle = nonZeroCounts[Math.floor((nonZeroCounts.length - 1) * 0.5)]; + const upper = nonZeroCounts[Math.floor((nonZeroCounts.length - 1) * 0.75)]; + + if (count <= lower) return 1; + if (count <= middle) return 2; + if (count <= upper) return 3; return 4; }; -const buildContributionCalendarDateRange = (now = new Date()) => { - const rollingEndMs = getLocalDayStart(now.getTime()); - // Rolling year: if today is May 17, activity range starts May 18 prior year. - const rollingStartMs = addLocalDays( - rollingEndMs, - -(CONTRIBUTION_CALENDAR_DAYS - 1), +const buildContributionCalendarDateRange = ( + selectedMonth: string, + now = new Date(), +) => { + const { year, monthIndex } = parseMonthKey(selectedMonth); + const currentMonth = formatMonthKey(now.getTime()); + const startMs = getLocalHourStart( + new Date(year, monthIndex - 3, 1).getTime(), ); - // 53 Sun–Sat columns; only render days through today (no future week padding). - const gridStartMs = - getLocalSundayWeekStart(rollingEndMs) - - (CONTRIBUTION_CALENDAR_WEEKS - 1) * WEEK_MS; - return { gridStartMs, rollingEndMs, rollingStartMs }; + const endMs = + selectedMonth === currentMonth + ? getLocalHourStart(now.getTime()) + : getLocalHourStart(new Date(year, monthIndex + 1, 0, 23).getTime()); + return { startMs, endMs }; +}; + +interface ContributionCalendarAggregation { + prs: CommitLog[]; + issues: MirrorDashboardIssue[]; + currentMonth: string; + availableMonths: string[]; + hourlyCounts: Map; +} + +let contributionCalendarAggregationCache: ContributionCalendarAggregation | null = + null; + +const getContinuousContributionMonths = (months: Set) => { + const sortedMonths = Array.from(months).sort((a, b) => a.localeCompare(b)); + const firstMonth = sortedMonths[0]; + const lastMonth = sortedMonths[sortedMonths.length - 1]; + if (!firstMonth || !lastMonth) return []; + + const { year: startYear, monthIndex: startMonthIndex } = + parseMonthKey(firstMonth); + const { year: endYear, monthIndex: endMonthIndex } = parseMonthKey(lastMonth); + const continuousMonths: string[] = []; + const cursor = new Date(startYear, startMonthIndex, 1); + const end = new Date(endYear, endMonthIndex, 1); + + while (cursor.getTime() <= end.getTime()) { + continuousMonths.push(formatMonthKey(cursor.getTime())); + cursor.setMonth(cursor.getMonth() + 1, 1); + } + + return continuousMonths.sort((a, b) => b.localeCompare(a)); +}; + +const getContributionCalendarAggregation = ( + prs: CommitLog[], + issues: MirrorDashboardIssue[], + now = new Date(), +): ContributionCalendarAggregation => { + const currentMonth = formatMonthKey(now.getTime()); + const cached = contributionCalendarAggregationCache; + + if ( + cached && + cached.prs === prs && + cached.issues === issues && + cached.currentMonth === currentMonth + ) { + return cached; + } + + const months = new Set([currentMonth]); + const hourlyCounts = new Map(); + + const incrementHour = (timestamp: number | null) => { + if (timestamp === null) return; + const hourMs = getLocalHourStart(timestamp); + const hourKey = formatHourKey(hourMs); + hourlyCounts.set(hourKey, (hourlyCounts.get(hourKey) ?? 0) + 1); + months.add(formatMonthKey(timestamp)); + }; + + prs.forEach((pr) => { + incrementHour(toTimestamp(pr.mergedAt)); + }); + + issues.forEach((issue) => { + if (!isResolvedMinerIssue(issue)) return; + incrementHour(toTimestamp(issue.solving_pr?.merged_at)); + }); + + contributionCalendarAggregationCache = { + prs, + issues, + currentMonth, + availableMonths: getContinuousContributionMonths(months), + hourlyCounts, + }; + + return contributionCalendarAggregationCache; +}; + +const getContributionRangeLabel = (range: TrendTimeRange) => { + if (range === '1d') return 'last 1d'; + if (range === '7d') return 'last 7d'; + if (range === '35d') return 'last 35d'; + return 'all time'; +}; + +const sumHourlyCountsInWindow = ( + hourlyCounts: Map, + window: WindowBounds, +) => { + let total = 0; + hourlyCounts.forEach((count, hourKey) => { + if (isWithinWindow(parseCalendarHourKey(hourKey), window)) { + total += count; + } + }); + return total; +}; + +const getMonthWindowBounds = (monthKey: string): WindowBounds => { + const { year, monthIndex } = parseMonthKey(monthKey); + return { + startMs: new Date(year, monthIndex, 1).getTime(), + endMs: new Date(year, monthIndex + 1, 1).getTime(), + }; +}; + +const addMonthsToMonthKey = (monthKey: string, months: number) => { + const { year, monthIndex } = parseMonthKey(monthKey); + return formatMonthKey(new Date(year, monthIndex + months, 1).getTime()); +}; + +const getCompactMonthLabel = (monthKey: string) => { + const { year, monthIndex } = parseMonthKey(monthKey); + return new Date(year, monthIndex, 1).toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + }); }; export const buildDashboardContributionCalendar = ( prs: CommitLog[], issues: MirrorDashboardIssue[], now = new Date(), + selectedMonth = formatMonthKey(now.getTime()), + range: TrendTimeRange = '7d', ): DashboardContributionCalendar => { - const { gridStartMs, rollingEndMs, rollingStartMs } = - buildContributionCalendarDateRange(now); + const { availableMonths, hourlyCounts } = getContributionCalendarAggregation( + prs, + issues, + now, + ); + const resolvedMonth = availableMonths.includes(selectedMonth) + ? selectedMonth + : (availableMonths[0] ?? formatMonthKey(now.getTime())); + const { startMs, endMs } = buildContributionCalendarDateRange( + resolvedMonth, + now, + ); const dataMap = new Map(); for ( - let dayMs = gridStartMs; - dayMs <= rollingEndMs; - dayMs = addLocalDays(dayMs, 1) + let hourMs = startMs; + hourMs <= endMs; + hourMs = addLocalHours(hourMs, 1) ) { - dataMap.set(formatCalendarDateKey(dayMs), 0); + const hourKey = formatHourKey(hourMs); + dataMap.set(hourKey, hourlyCounts.get(hourKey) ?? 0); } - const incrementDay = (timestamp: number | null) => { - if (timestamp === null) return; - const dayMs = getLocalDayStart(timestamp); - if (dayMs < rollingStartMs || dayMs > rollingEndMs) return; - const dateKey = formatCalendarDateKey(dayMs); - if (!dataMap.has(dateKey)) return; - dataMap.set(dateKey, (dataMap.get(dateKey) ?? 0) + 1); - }; + const nonZeroCounts = Array.from(dataMap.values()) + .filter((count) => count > 0) + .sort((a, b) => a - b); - prs.forEach((pr) => incrementDay(toTimestamp(pr.mergedAt))); + const hours = Array.from(dataMap.entries()) + .map(([timestamp, count]) => { + const [date, hourString] = timestamp.split('T'); + return { + timestamp, + date, + hour: Number(hourString), + count, + level: getContributionLevel(count, nonZeroCounts), + }; + }) + .sort((a, b) => a.timestamp.localeCompare(b.timestamp)); - issues.forEach((issue) => { - if (!isResolvedMinerIssue(issue)) return; - incrementDay(toTimestamp(issue.solving_pr?.merged_at)); + const dailyCounts = new Map(); + hours.forEach(({ date, count }) => { + dailyCounts.set(date, (dailyCounts.get(date) ?? 0) + count); }); - const days = Array.from(dataMap.entries()) + const days = Array.from(dailyCounts.entries()) .map(([date, count]) => ({ date, count, - level: getContributionLevel(count), + level: getContributionLevel(count, nonZeroCounts), })) .sort((a, b) => a.date.localeCompare(b.date)); - const thisWeekStart = getLocalSundayWeekStart(now.getTime()); - const lastWeekStart = thisWeekStart - WEEK_MS; + const currentRangeWindow = getWindowBounds(range, now); + const previousRangeWindow = getPreviousWindowBounds(range, now); + const rangeCount = sumHourlyCountsInWindow(hourlyCounts, currentRangeWindow); + const previousRangeCount = previousRangeWindow + ? sumHourlyCountsInWindow(hourlyCounts, previousRangeWindow) + : 0; + let rangeOverRangePercent: number | null = null; + + if (range !== 'all') { + if (rangeCount === 0 && previousRangeCount === 0) { + rangeOverRangePercent = 0; + } else if (previousRangeCount > 0) { + rangeOverRangePercent = + ((rangeCount - previousRangeCount) / previousRangeCount) * 100; + } + } + + const previousMonth = addMonthsToMonthKey(resolvedMonth, -1); + const selectedMonthCount = sumHourlyCountsInWindow( + hourlyCounts, + getMonthWindowBounds(resolvedMonth), + ); + const previousMonthCount = sumHourlyCountsInWindow( + hourlyCounts, + getMonthWindowBounds(previousMonth), + ); + let monthOverMonthPercent: number | null = null; + + if (selectedMonthCount === 0 && previousMonthCount === 0) { + monthOverMonthPercent = 0; + } else if (previousMonthCount > 0) { + monthOverMonthPercent = + ((selectedMonthCount - previousMonthCount) / previousMonthCount) * 100; + } + + const currentDayMs = getLocalDayStart(now.getTime()); + const rolling7Start = addLocalDays(currentDayMs, -6); + const prior7Start = addLocalDays(rolling7Start, -7); let thisWeekCount = 0; let lastWeekCount = 0; days.forEach(({ date, count }) => { const dayMs = parseCalendarDateKey(date); - if (dayMs >= thisWeekStart) { + if (dayMs >= rolling7Start && dayMs <= currentDayMs) { thisWeekCount += count; return; } - if (dayMs >= lastWeekStart) { + if (dayMs >= prior7Start && dayMs < rolling7Start) { lastWeekCount += count; } }); let weekOverWeekPercent: number | null = null; - let weekOverWeekLabel = '0% vs last week'; + let weekOverWeekLabel = '0% vs prior 7d'; if (thisWeekCount === 0 && lastWeekCount === 0) { weekOverWeekPercent = 0; @@ -483,15 +712,24 @@ export const buildDashboardContributionCalendar = ( ((thisWeekCount - lastWeekCount) / lastWeekCount) * 100; const rounded = Math.round(weekOverWeekPercent); const sign = rounded > 0 ? '+' : ''; - weekOverWeekLabel = `${sign}${rounded}% vs last week`; + weekOverWeekLabel = `${sign}${rounded}% vs prior 7d`; } else if (thisWeekCount > 0) { - weekOverWeekLabel = 'New activity this week'; + weekOverWeekLabel = 'New activity in last 7d'; } return { - days, - totalDaysShown: dataMap.size, - weekCount: Math.ceil(days.length / 7), + hours, + totalHoursShown: dataMap.size, + selectedMonth: resolvedMonth, + availableMonths, + rangeCount, + rangeLabel: getContributionRangeLabel(range), + rangeOverRangePercent, + selectedMonthCount, + selectedMonthLabel: getCompactMonthLabel(resolvedMonth), + previousMonthCount, + previousMonthLabel: getCompactMonthLabel(previousMonth), + monthOverMonthPercent, thisWeekCount, weekOverWeekPercent, weekOverWeekLabel, diff --git a/src/pages/dashboard/useDashboardData.ts b/src/pages/dashboard/useDashboardData.ts index 8933cd09..18bc0c74 100644 --- a/src/pages/dashboard/useDashboardData.ts +++ b/src/pages/dashboard/useDashboardData.ts @@ -49,7 +49,7 @@ type DashboardDatasets = { // the React Query cache key stable across renders and route remounts. const DASHBOARD_ISSUES_SINCE_ISO = new Date(GITTENSOR_START_MS).toISOString(); -const useDashboardData = (range: TrendTimeRange) => { +const useDashboardData = (range: TrendTimeRange, calendarMonth?: string) => { const prsQuery = useAllPrs(); const minersQuery = useAllMiners(); const issuesQuery = useIssues(); @@ -140,8 +140,11 @@ const useDashboardData = (range: TrendTimeRange) => { buildDashboardContributionCalendar( datasets.prs.data, datasets.minerIssues.data, + new Date(), + calendarMonth, + range, ), - [datasets.minerIssues.data, datasets.prs.data], + [calendarMonth, datasets.minerIssues.data, datasets.prs.data, range], ); const featuredContributors = useMemo( diff --git a/src/pages/dashboard/views/ActiveNetwork.tsx b/src/pages/dashboard/views/ActiveNetwork.tsx index 3a6dbcbc..fb96416e 100644 --- a/src/pages/dashboard/views/ActiveNetwork.tsx +++ b/src/pages/dashboard/views/ActiveNetwork.tsx @@ -20,6 +20,7 @@ interface ActiveNetworkProps { kpis: DashboardKpi[]; isLoading?: boolean; onRangeChange: (range: TrendTimeRange) => void; + onCalendarMonthChange: (month: string) => void; } const ActiveNetwork: React.FC = ({ @@ -31,6 +32,7 @@ const ActiveNetwork: React.FC = ({ kpis, isLoading = false, onRangeChange, + onCalendarMonthChange, }) => { return ( = ({ {isLoading ? ( diff --git a/src/pages/dashboard/views/ContributionCalendar.tsx b/src/pages/dashboard/views/ContributionCalendar.tsx index 2bead2f4..2962e249 100644 --- a/src/pages/dashboard/views/ContributionCalendar.tsx +++ b/src/pages/dashboard/views/ContributionCalendar.tsx @@ -1,6 +1,10 @@ -import React, { useEffect, useMemo, useRef } from 'react'; -import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; -import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Box, Card, @@ -11,19 +15,63 @@ import { useMediaQuery, } from '@mui/material'; import { alpha, useTheme } from '@mui/material/styles'; -import ContributionHeatmap from '../../../components/ContributionHeatmap'; -import { CONTRIBUTION_HEATMAP_SCALE, TEXT_OPACITY } from '../../../theme'; +import { + CONTRIBUTION_HEATMAP_SCALE, + TEXT_OPACITY, + scrollbarSx, +} from '../../../theme'; +import { pluralize } from '../../../utils/format'; import { type DashboardContributionCalendar } from '../dashboardData'; interface ContributionCalendarProps { calendar: DashboardContributionCalendar; isLoading?: boolean; + onMonthChange?: (month: string) => void; } -const CALENDAR_BLOCK = { - mobile: { size: 10, margin: 3, fontSize: 10 }, - desktop: { size: 11, margin: 3, fontSize: 11 }, -} as const; +const HEATMAP_EMPTY_COLOR = '#161b22'; +const HEATMAP_LOW_COLOR = '#0e4429'; +const HEATMAP_HIGH_COLOR = '#39d353'; +const HOUR_LABELS = new Set([0, 6, 12, 18, 23]); + +const hexToRgb = (hex: string): [number, number, number] => { + const normalized = hex.replace('#', ''); + return [ + parseInt(normalized.slice(0, 2), 16), + parseInt(normalized.slice(2, 4), 16), + parseInt(normalized.slice(4, 6), 16), + ]; +}; + +const toHex = (value: number): string => + Math.round(value).toString(16).padStart(2, '0'); + +const mixHex = (from: string, to: string, amount: number): string => { + const [fr, fg, fb] = hexToRgb(from); + const [tr, tg, tb] = hexToRgb(to); + const clamped = Math.max(0, Math.min(1, amount)); + return `#${toHex(fr + (tr - fr) * clamped)}${toHex( + fg + (tg - fg) * clamped, + )}${toHex(fb + (tb - fb) * clamped)}`; +}; + +const parseMonthKey = (monthKey: string) => { + const [year, month] = monthKey.split('-').map(Number); + return { year, monthIndex: month - 1 }; +}; + +const formatMonthLabel = (monthKey: string, compact = false) => { + const { year, monthIndex } = parseMonthKey(monthKey); + return new Date(year, monthIndex, 1).toLocaleDateString('en-US', { + month: compact ? 'short' : 'long', + year: 'numeric', + }); +}; + +const formatHourTooltip = (timestamp: string, count: number) => { + const [dateKey, hourKey] = timestamp.split('T'); + return `${pluralize(count, 'contribution')} during ${dateKey} ${hourKey}:00`; +}; const ContributionCalendarLegend: React.FC = () => { const theme = useTheme(); @@ -78,135 +126,113 @@ const ContributionCalendarLegend: React.FC = () => { const ContributionCalendar: React.FC = ({ calendar, isLoading = false, + onMonthChange, }) => { const theme = useTheme(); const monoFontFamily = theme.typography.fontFamily; const isMobile = useMediaQuery(theme.breakpoints.down('md')); const scrollRef = useRef(null); + const [heatmapWidth, setHeatmapWidth] = useState(0); - const isEmpty = calendar.days.every((day) => day.count === 0); - const totalContributions = useMemo( - () => calendar.days.reduce((sum, day) => sum + day.count, 0), - [calendar.days], + const isEmpty = calendar.hours.every((hour) => hour.count === 0); + const peakContributionCount = useMemo( + () => Math.max(0, ...calendar.hours.map((hour) => hour.count)), + [calendar.hours], ); - const blockConfig = isMobile ? CALENDAR_BLOCK.mobile : CALENDAR_BLOCK.desktop; + const monthDays = useMemo( + () => Array.from(new Set(calendar.hours.map((hour) => hour.date))).sort(), + [calendar.hours], + ); + const timelineMonths = useMemo( + () => [...calendar.availableMonths].reverse(), + [calendar.availableMonths], + ); + const hourMap = useMemo( + () => new Map(calendar.hours.map((hour) => [hour.timestamp, hour])), + [calendar.hours], + ); + const blockSize = isMobile ? 7 : 10; + const blockGap = isMobile ? 3 : 3; + const labelColumnWidth = isMobile ? 28 : 34; + const visibleDayCapacity = useMemo(() => { + if (heatmapWidth <= labelColumnWidth) return monthDays.length; + return Math.max( + 1, + Math.floor((heatmapWidth - labelColumnWidth) / (blockSize + blockGap)), + ); + }, [blockGap, blockSize, heatmapWidth, labelColumnWidth, monthDays.length]); + const visibleMonthDays = useMemo( + () => monthDays.slice(Math.max(0, monthDays.length - visibleDayCapacity)), + [monthDays, visibleDayCapacity], + ); + + const getHeatmapBlockColor = useCallback( + (count: number) => { + if (count <= 0) return HEATMAP_EMPTY_COLOR; + if (peakContributionCount <= 1) return HEATMAP_HIGH_COLOR; - const heatmapScrollSx = useMemo( - () => ({ - WebkitOverflowScrolling: 'touch', - touchAction: 'pan-x', - pb: 0.5, - '& .react-activity-calendar': { - display: 'inline-block', - width: 'max-content', - minWidth: 'max-content', - }, - '& .react-activity-calendar svg': { - display: 'block', - }, - '& .react-activity-calendar text': { - fill: alpha(theme.palette.text.primary, TEXT_OPACITY.tertiary), - fontFamily: monoFontFamily, - }, - }), - [monoFontFamily, theme.palette.text.primary], + const intensity = Math.log1p(count) / Math.log1p(peakContributionCount); + return mixHex(HEATMAP_LOW_COLOR, HEATMAP_HIGH_COLOR, intensity); + }, + [peakContributionCount], ); useEffect(() => { const el = scrollRef.current; if (!el || isEmpty || isLoading) return; el.scrollLeft = el.scrollWidth; - }, [calendar.days, isEmpty, isLoading]); + }, [calendar.hours, isEmpty, isLoading]); - const weekTrendPositive = - calendar.weekOverWeekPercent !== null && calendar.weekOverWeekPercent >= 0; - const weekTrendColor = - calendar.weekOverWeekPercent === null + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + + const updateWidth = () => setHeatmapWidth(el.clientWidth); + updateWidth(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateWidth); + return () => window.removeEventListener('resize', updateWidth); + } + + const observer = new ResizeObserver(updateWidth); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const rangeTrendPositive = + calendar.rangeOverRangePercent !== null && + calendar.rangeOverRangePercent >= 0; + const rangeTrendColor = + calendar.rangeOverRangePercent === null ? alpha(theme.palette.text.primary, TEXT_OPACITY.muted) - : weekTrendPositive + : rangeTrendPositive ? theme.palette.status.success : theme.palette.status.closed; - - const weekSummaryCard = ( - - - This week - - - {calendar.thisWeekCount.toLocaleString()} - - - Contributions - - - {calendar.weekOverWeekPercent !== null && - (weekTrendPositive ? ( - - ) : ( - - ))} - - {calendar.weekOverWeekLabel} - - - - ); + const rangeDeltaLabel = + calendar.rangeOverRangePercent === null + ? 'n/a' + : `${calendar.rangeOverRangePercent >= 0 ? '+' : ''}${Math.round( + calendar.rangeOverRangePercent, + )}%`; + const showRangeComparison = calendar.rangeOverRangePercent !== null; + const monthTrendPositive = + calendar.monthOverMonthPercent !== null && + calendar.monthOverMonthPercent >= 0; + const monthTrendColor = + calendar.monthOverMonthPercent === null + ? alpha(theme.palette.text.primary, TEXT_OPACITY.muted) + : monthTrendPositive + ? theme.palette.status.success + : theme.palette.status.closed; + const monthDeltaLabel = + calendar.monthOverMonthPercent === null + ? 'n/a' + : `${calendar.monthOverMonthPercent >= 0 ? '+' : ''}${Math.round( + calendar.monthOverMonthPercent, + )}%`; + const showMonthComparison = calendar.monthOverMonthPercent !== null; return ( = ({ ) : ( - - + {timelineMonths.length > 0 && ( + + + {timelineMonths.map((month) => { + const isSelected = month === calendar.selectedMonth; + return ( + onMonthChange?.(month)} + disabled={isSelected} + aria-pressed={isSelected} + aria-label={`Show ${formatMonthLabel(month)}`} + sx={{ + appearance: 'none', + position: 'relative', + zIndex: 1, + border: `1px solid ${ + isSelected + ? alpha(theme.palette.status.success, 0.55) + : alpha(theme.palette.border.light, 0.9) + }`, + borderRadius: 1, + px: 1.05, + py: 0.55, + minWidth: { xs: 76, sm: 86 }, + color: isSelected + ? theme.palette.text.primary + : alpha( + theme.palette.text.primary, + TEXT_OPACITY.secondary, + ), + backgroundColor: isSelected + ? alpha(theme.palette.status.success, 0.14) + : theme.palette.background.paper, + cursor: isSelected ? 'default' : 'pointer', + fontFamily: monoFontFamily, + fontSize: { xs: '0.64rem', sm: '0.68rem' }, + fontWeight: isSelected ? 700 : 600, + lineHeight: 1.1, + textAlign: 'center', + whiteSpace: 'nowrap', + transition: + 'background-color 120ms ease, color 120ms ease, border-color 120ms ease', + '&:hover': { + color: theme.palette.text.primary, + backgroundColor: alpha( + theme.palette.text.primary, + 0.065, + ), + }, + '&:disabled': { + opacity: 1, + }, + }} + > + {formatMonthLabel(month, true)} + + ); + })} + + + )} + + + + {visibleMonthDays.map((dateKey, index) => { + const [year, month, day] = dateKey.split('-').map(Number); + const isMonthStart = day === 1 || index === 0; + const showLabel = isMonthStart || day === 15; + return ( + + {isMonthStart + ? new Date(year, month - 1, day).toLocaleDateString( + 'en-US', + { + month: 'short', + }, + ) + : day} + + ); + })} + {Array.from({ length: 24 }, (_, hour) => ( + + + {String(hour).padStart(2, '0')} + + {visibleMonthDays.map((dateKey) => { + const timestamp = `${dateKey}T${String(hour).padStart( + 2, + '0', + )}`; + const bucket = hourMap.get(timestamp); + const count = bucket?.count ?? 0; + const label = formatHourTooltip(timestamp, count); + return ( + 0 + ? `0 0 0 1px ${alpha( + theme.palette.common.white, + 0.02, + )} inset` + : `0 0 0 1px ${alpha( + theme.palette.common.white, + 0.025, + )} inset`, + }} + /> + ); + })} + + ))} + + = ({ lineHeight: 1.35, }} > - {totalContributions.toLocaleString()} contribution - {totalContributions === 1 ? '' : 's'} in the last year + {calendar.rangeCount.toLocaleString()} contribution + {calendar.rangeCount === 1 ? '' : 's'} in{' '} + {calendar.rangeLabel} + {showRangeComparison && ( + <> + + / + + + {rangeTrendPositive ? '↑ ' : '↓ '} + {rangeDeltaLabel} + + + )} + + / + + + {calendar.selectedMonthCount.toLocaleString()} in{' '} + {calendar.selectedMonthLabel} vs{' '} + {calendar.previousMonthCount.toLocaleString()} in{' '} + {calendar.previousMonthLabel} + + {showMonthComparison && ( + + {monthTrendPositive ? '↑ ' : '↓ '} + {monthDeltaLabel} + + )} {!isEmpty && } - {weekSummaryCard} - + )}