diff --git a/src/pages/dashboard/dashboardData.ts b/src/pages/dashboard/dashboardData.ts index a81ca516..b7605d3a 100644 --- a/src/pages/dashboard/dashboardData.ts +++ b/src/pages/dashboard/dashboardData.ts @@ -478,6 +478,12 @@ interface ContributionCalendarAggregation { let contributionCalendarAggregationCache: ContributionCalendarAggregation | null = null; +let contributionCalendarResultCache: { + prs: CommitLog[]; + issues: MirrorDashboardIssue[]; + currentHour: string; + values: Map; +} | null = null; const getContinuousContributionMonths = (months: Set) => { const sortedMonths = Array.from(months).sort((a, b) => a.localeCompare(b)); @@ -596,6 +602,7 @@ export const buildDashboardContributionCalendar = ( selectedMonth = formatMonthKey(now.getTime()), range: TrendTimeRange = '7d', ): DashboardContributionCalendar => { + const currentHour = formatHourKey(getLocalHourStart(now.getTime())); const { availableMonths, hourlyCounts } = getContributionCalendarAggregation( prs, issues, @@ -604,6 +611,19 @@ export const buildDashboardContributionCalendar = ( const resolvedMonth = availableMonths.includes(selectedMonth) ? selectedMonth : (availableMonths[0] ?? formatMonthKey(now.getTime())); + const cacheKey = `${resolvedMonth}|${range}`; + const cached = contributionCalendarResultCache; + + if ( + cached && + cached.prs === prs && + cached.issues === issues && + cached.currentHour === currentHour + ) { + const cachedValue = cached.values.get(cacheKey); + if (cachedValue) return cachedValue; + } + const { startMs, endMs } = buildContributionCalendarDateRange( resolvedMonth, now, @@ -717,7 +737,7 @@ export const buildDashboardContributionCalendar = ( weekOverWeekLabel = 'New activity in last 7d'; } - return { + const result = { hours, totalHoursShown: dataMap.size, selectedMonth: resolvedMonth, @@ -734,6 +754,24 @@ export const buildDashboardContributionCalendar = ( weekOverWeekPercent, weekOverWeekLabel, }; + + if ( + !contributionCalendarResultCache || + contributionCalendarResultCache.prs !== prs || + contributionCalendarResultCache.issues !== issues || + contributionCalendarResultCache.currentHour !== currentHour + ) { + contributionCalendarResultCache = { + prs, + issues, + currentHour, + values: new Map(), + }; + } + + contributionCalendarResultCache.values.set(cacheKey, result); + + return result; }; // Each PR contributes to exactly one bucket, keyed by its terminal state and diff --git a/src/pages/dashboard/views/ContributionCalendar.tsx b/src/pages/dashboard/views/ContributionCalendar.tsx index 2962e249..a313f2eb 100644 --- a/src/pages/dashboard/views/ContributionCalendar.tsx +++ b/src/pages/dashboard/views/ContributionCalendar.tsx @@ -11,6 +11,7 @@ import { CardContent, CircularProgress, Stack, + Tooltip, Typography, useMediaQuery, } from '@mui/material'; @@ -73,6 +74,84 @@ const formatHourTooltip = (timestamp: string, count: number) => { return `${pluralize(count, 'contribution')} during ${dateKey} ${hourKey}:00`; }; +const formatTooltipDate = (dateKey: string) => { + const [year, month, day] = dateKey.split('-').map(Number); + return new Date(year, month - 1, day).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); +}; + +const formatHourWindow = (hour: number) => + `${String(hour).padStart(2, '0')}:00 - ${String((hour + 1) % 24).padStart( + 2, + '0', + )}:00`; + +const getHourEndMs = (dateKey: string, hour: number) => { + const [year, month, day] = dateKey.split('-').map(Number); + return new Date(year, month - 1, day, hour + 1).getTime(); +}; + +const HeatmapTooltipTitle: React.FC<{ + date: string; + hour: number; + count: number; +}> = ({ date, hour, count }) => { + const theme = useTheme(); + const hasActivity = count > 0; + + return ( + + + {formatTooltipDate(date)} + + + {formatHourWindow(hour)} + + + + {pluralize(count, 'contribution')} + + + + ); +}; + const ContributionCalendarLegend: React.FC = () => { const theme = useTheme(); const monoFontFamily = theme.typography.fontFamily; @@ -133,6 +212,7 @@ const ContributionCalendar: React.FC = ({ const isMobile = useMediaQuery(theme.breakpoints.down('md')); const scrollRef = useRef(null); const [heatmapWidth, setHeatmapWidth] = useState(0); + const currentMs = Date.now(); const isEmpty = calendar.hours.every((hour) => hour.count === 0); const peakContributionCount = useMemo( @@ -144,6 +224,10 @@ const ContributionCalendar: React.FC = ({ () => Array.from(new Set(calendar.hours.map((hour) => hour.date))).sort(), [calendar.hours], ); + const concludedMonthDays = useMemo( + () => monthDays.filter((dateKey) => getHourEndMs(dateKey, 0) <= currentMs), + [currentMs, monthDays], + ); const timelineMonths = useMemo( () => [...calendar.availableMonths].reverse(), [calendar.availableMonths], @@ -155,16 +239,41 @@ const ContributionCalendar: React.FC = ({ const blockSize = isMobile ? 7 : 10; const blockGap = isMobile ? 3 : 3; const labelColumnWidth = isMobile ? 28 : 34; + const baseHeatmapCellStyle = useMemo( + () => ({ + display: 'block', + width: blockSize, + height: blockSize, + borderRadius: 2, + }), + [blockSize], + ); + const hiddenHeatmapCellStyle = useMemo( + () => ({ + ...baseHeatmapCellStyle, + visibility: 'hidden', + }), + [baseHeatmapCellStyle], + ); const visibleDayCapacity = useMemo(() => { - if (heatmapWidth <= labelColumnWidth) return monthDays.length; + if (heatmapWidth <= labelColumnWidth) return concludedMonthDays.length; return Math.max( 1, Math.floor((heatmapWidth - labelColumnWidth) / (blockSize + blockGap)), ); - }, [blockGap, blockSize, heatmapWidth, labelColumnWidth, monthDays.length]); + }, [ + blockGap, + blockSize, + concludedMonthDays.length, + heatmapWidth, + labelColumnWidth, + ]); const visibleMonthDays = useMemo( - () => monthDays.slice(Math.max(0, monthDays.length - visibleDayCapacity)), - [monthDays, visibleDayCapacity], + () => + concludedMonthDays.slice( + Math.max(0, concludedMonthDays.length - visibleDayCapacity), + ), + [concludedMonthDays, visibleDayCapacity], ); const getHeatmapBlockColor = useCallback( @@ -317,19 +426,6 @@ const ContributionCalendar: React.FC = ({ alignItems: 'center', px: 0.15, py: 0.25, - '&::before': { - content: '""', - position: 'absolute', - left: 8, - right: 8, - top: '50%', - height: 1, - backgroundColor: alpha( - theme.palette.text.primary, - 0.12, - ), - transform: 'translateY(-50%)', - }, }} > {timelineMonths.map((month) => { @@ -349,8 +445,8 @@ const ContributionCalendar: React.FC = ({ zIndex: 1, border: `1px solid ${ isSelected - ? alpha(theme.palette.status.success, 0.55) - : alpha(theme.palette.border.light, 0.9) + ? alpha(theme.palette.status.success, 0.85) + : alpha(theme.palette.border.light, 0.72) }`, borderRadius: 1, px: 1.05, @@ -363,8 +459,17 @@ const ContributionCalendar: React.FC = ({ TEXT_OPACITY.secondary, ), backgroundColor: isSelected - ? alpha(theme.palette.status.success, 0.14) - : theme.palette.background.paper, + ? alpha(theme.palette.status.success, 0.22) + : theme.palette.background.default, + boxShadow: isSelected + ? `0 0 0 1px ${alpha( + theme.palette.status.success, + 0.32, + )}` + : `0 0 0 1px ${alpha( + theme.palette.common.black, + 0.45, + )}`, cursor: isSelected ? 'default' : 'pointer', fontFamily: monoFontFamily, fontSize: { xs: '0.64rem', sm: '0.68rem' }, @@ -373,12 +478,16 @@ const ContributionCalendar: React.FC = ({ textAlign: 'center', whiteSpace: 'nowrap', transition: - 'background-color 120ms ease, color 120ms ease, border-color 120ms ease', + 'background-color 120ms ease, color 120ms ease, border-color 120ms ease, box-shadow 120ms ease', '&:hover': { color: theme.palette.text.primary, + borderColor: alpha( + theme.palette.status.success, + 0.5, + ), backgroundColor: alpha( - theme.palette.text.primary, - 0.065, + theme.palette.status.success, + isSelected ? 0.22 : 0.09, ), }, '&:disabled': { @@ -479,33 +588,64 @@ const ContributionCalendar: React.FC = ({ 2, '0', )}`; + const isConcluded = + getHourEndMs(dateKey, hour) <= currentMs; + if (!isConcluded) { + return ( +