Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
303 changes: 183 additions & 120 deletions src/components/ContributionHeatmap.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,41 @@
import React from 'react';
import { Box, Card, Typography, Tooltip, alpha, useTheme } from '@mui/material';
import {
Box,
Card,
Typography,
Tooltip,
alpha,
useTheme,
type SxProps,
type Theme,
} from '@mui/material';
import { ActivityCalendar } from 'react-activity-calendar';

import {
CONTRIBUTION_HEATMAP_SCALE,
TEXT_OPACITY,
scrollbarSx,
} from '../theme';

interface ContributionData {
export interface ContributionData {
date: string;
count: number;
level: 0 | 1 | 2 | 3 | 4;
}

interface ContributionHeatmapProps {
type DayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
type WeekdayLabel = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat';

const formatActivityDateLabel = (dateKey: string) => {
const [year, month, day] = dateKey.split('-').map(Number);
return new Date(year, month - 1, day).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};

export interface ContributionHeatmapProps {
data: ContributionData[];
contributionsLast30Days: number;
totalDaysShown: number;
Expand All @@ -22,8 +44,18 @@ interface ContributionHeatmapProps {
emptyTitle?: string;
emptySubtitle?: string;
bare?: boolean;
showHeader?: boolean;
selectedDate?: string;
onDayClick?: (date: string) => void;
blockSize?: number;
blockMargin?: number;
fontSize?: number;
weekStart?: DayIndex;
showWeekdayLabels?: boolean | WeekdayLabel[];
showTotalCount?: boolean;
showColorLegend?: boolean;
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
scrollContainerSx?: SxProps<Theme>;
}

const ContributionHeatmap: React.FC<ContributionHeatmapProps> = ({
Expand All @@ -35,148 +67,179 @@ const ContributionHeatmap: React.FC<ContributionHeatmapProps> = ({
emptyTitle = 'No contributions yet',
emptySubtitle = 'Activity will appear here once PRs are merged',
bare = false,
showHeader = true,
selectedDate,
onDayClick,
blockSize = 11,
blockMargin = 3,
fontSize = 11,
weekStart,
showWeekdayLabels = false,
showTotalCount,
showColorLegend,
scrollContainerRef,
scrollContainerSx,
}) => {
const theme = useTheme();
const heatmapLevels = [...CONTRIBUTION_HEATMAP_SCALE];
const heatmapTheme = { light: heatmapLevels, dark: heatmapLevels };
const isEmpty = data.length === 0;
const interactive = !!onDayClick;

const content = (
<>
<Box sx={{ mb: 2.5 }}>
<Typography
const heatmapScroll = (
<Box
ref={scrollContainerRef}
sx={{
width: '100%',
maxWidth: '100%',
overflowX: 'auto',
overflowY: 'hidden',
mb: showHeader || footerText ? 1 : 0,
...scrollbarSx,
...scrollContainerSx,
}}
>
{isEmpty ? (
<Box
sx={{
color: 'text.primary',
fontWeight: 700,
fontSize: '2.5rem',
lineHeight: 1,
py: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 100,
width: '100%',
}}
>
{contributionsLast30Days.toLocaleString()}
</Typography>
<Typography
variant="body2"
sx={{
color: alpha(theme.palette.common.white, TEXT_OPACITY.faint),
fontSize: '0.85rem',
mt: 0.5,
}}
>
{subtitle}
</Typography>
</Box>

<Box sx={{ width: '100%', overflowX: 'auto', mb: 1, ...scrollbarSx }}>
{isEmpty ? (
<Box
<Typography
sx={{
py: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 100,
color: alpha(theme.palette.common.white, TEXT_OPACITY.muted),
fontSize: '0.85rem',
textAlign: 'center',
}}
>
{emptyTitle}
</Typography>
{emptySubtitle && (
<Typography
sx={{
color: alpha(theme.palette.common.white, TEXT_OPACITY.muted),
fontSize: '0.85rem',
color: alpha(theme.palette.common.white, TEXT_OPACITY.ghost),
fontSize: '0.75rem',
textAlign: 'center',
mt: 0.5,
}}
>
{emptyTitle}
{emptySubtitle}
</Typography>
{emptySubtitle && (
<Typography
sx={{
color: alpha(theme.palette.common.white, TEXT_OPACITY.ghost),
fontSize: '0.75rem',
textAlign: 'center',
mt: 0.5,
)}
</Box>
) : (
<ActivityCalendar
data={data}
theme={heatmapTheme}
labels={{
legend: { less: 'Less', more: 'More' },
months: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
],
totalCount: `{{count}} contribution(s) in the last ${totalDaysShown} day(s)`,
weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
}}
blockSize={blockSize}
blockMargin={blockMargin}
fontSize={fontSize}
style={{ color: theme.palette.text.primary }}
weekStart={weekStart}
showWeekdayLabels={showWeekdayLabels}
showTotalCount={showTotalCount}
showColorLegend={showColorLegend}
renderBlock={(block, activity) => {
const clickable = interactive;
const isSelected = selectedDate === activity.date;
const highlighted =
clickable && isSelected
? React.cloneElement(block as React.ReactElement, {
stroke: theme.palette.text.primary,
strokeWidth: 1.5,
})
: block;
const wrapped = clickable ? (
<g
onClick={() => onDayClick?.(activity.date)}
style={{ cursor: 'pointer' }}
role="button"
aria-label={`View ${activity.count} contribution${activity.count !== 1 ? 's' : ''} on ${activity.date}`}
>
{highlighted}
</g>
) : (
highlighted
);
return (
<Tooltip
title={`${activity.count} contribution${activity.count !== 1 ? 's' : ''} on ${formatActivityDateLabel(activity.date)}${clickable ? ' — click to view PRs' : ''}`}
arrow
placement="top"
enterDelay={0}
enterNextDelay={0}
leaveDelay={0}
disableInteractive
slotProps={{
popper: {
sx: {
zIndex: theme.zIndex.tooltip,
},
},
}}
>
{emptySubtitle}
</Typography>
)}
</Box>
) : (
<ActivityCalendar
data={data}
theme={heatmapTheme}
labels={{
legend: { less: 'Less', more: 'More' },
months: [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
],
totalCount: `{{count}} contribution(s) in the last ${totalDaysShown} day(s)`,
weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
{wrapped}
</Tooltip>
);
}}
/>
)}
</Box>
);

const content = (
<>
{showHeader && (
<Box sx={{ mb: 2.5 }}>
<Typography
sx={{
color: 'text.primary',
fontWeight: 700,
fontSize: '2.5rem',
lineHeight: 1,
}}
blockSize={11}
blockMargin={3}
fontSize={11}
style={{ color: theme.palette.text.primary }}
showWeekdayLabels={false}
renderBlock={(block, activity) => {
const clickable = interactive;
const isSelected = selectedDate === activity.date;
const highlighted =
clickable && isSelected
? React.cloneElement(block as React.ReactElement, {
stroke: theme.palette.text.primary,
strokeWidth: 1.5,
})
: block;
const wrapped = clickable ? (
<g
onClick={() => onDayClick?.(activity.date)}
style={{ cursor: 'pointer' }}
role="button"
aria-label={`View ${activity.count} contribution${activity.count !== 1 ? 's' : ''} on ${activity.date}`}
>
{highlighted}
</g>
) : (
highlighted
);
return (
<Tooltip
title={`${activity.count} contribution${activity.count !== 1 ? 's' : ''} on ${new Date(activity.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}${clickable ? ' — click to view PRs' : ''}`}
arrow
placement="top"
enterDelay={0}
enterNextDelay={0}
leaveDelay={0}
disableInteractive
slotProps={{
popper: {
sx: {
zIndex: theme.zIndex.tooltip,
},
},
}}
>
{wrapped}
</Tooltip>
);
>
{contributionsLast30Days.toLocaleString()}
</Typography>
<Typography
variant="body2"
sx={{
color: alpha(theme.palette.common.white, TEXT_OPACITY.faint),
fontSize: '0.85rem',
mt: 0.5,
}}
/>
)}
</Box>
>
{subtitle}
</Typography>
</Box>
)}

{heatmapScroll}

{footerText && (
<Typography
Expand Down
2 changes: 2 additions & 0 deletions src/pages/dashboard/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const DashboardFeaturePage: React.FC = () => {
overview,
trendLabels,
trendSeries,
contributionCalendar,
featuredWork,
isFeaturedWorkLoading,
featuredContributors,
Expand Down Expand Up @@ -75,6 +76,7 @@ const DashboardFeaturePage: React.FC = () => {
range={range}
trendLabels={trendLabels}
trendSeries={trendSeries}
contributionCalendar={contributionCalendar}
sections={overview}
kpis={kpis}
isLoading={isLoading}
Expand Down
Loading
Loading