Skip to content
Merged
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
40 changes: 39 additions & 1 deletion src/pages/dashboard/dashboardData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,12 @@ interface ContributionCalendarAggregation {

let contributionCalendarAggregationCache: ContributionCalendarAggregation | null =
null;
let contributionCalendarResultCache: {
prs: CommitLog[];
issues: MirrorDashboardIssue[];
currentHour: string;
values: Map<string, DashboardContributionCalendar>;
} | null = null;

const getContinuousContributionMonths = (months: Set<string>) => {
const sortedMonths = Array.from(months).sort((a, b) => a.localeCompare(b));
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -717,7 +737,7 @@ export const buildDashboardContributionCalendar = (
weekOverWeekLabel = 'New activity in last 7d';
}

return {
const result = {
hours,
totalHoursShown: dataMap.size,
selectedMonth: resolvedMonth,
Expand All @@ -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
Expand Down
232 changes: 186 additions & 46 deletions src/pages/dashboard/views/ContributionCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
CardContent,
CircularProgress,
Stack,
Tooltip,
Typography,
useMediaQuery,
} from '@mui/material';
Expand Down Expand Up @@ -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 (
<Box sx={{ minWidth: 168 }}>
<Typography
sx={{
color: theme.palette.text.primary,
fontFamily: theme.typography.fontFamily,
fontSize: '0.72rem',
fontWeight: 700,
lineHeight: 1.25,
}}
>
{formatTooltipDate(date)}
</Typography>
<Typography
sx={{
mt: 0.35,
color: alpha(theme.palette.text.primary, TEXT_OPACITY.secondary),
fontFamily: theme.typography.fontFamily,
fontSize: '0.64rem',
lineHeight: 1.25,
}}
>
{formatHourWindow(hour)}
</Typography>
<Box
sx={{
mt: 0.8,
pt: 0.7,
borderTop: `1px solid ${alpha(theme.palette.text.primary, 0.12)}`,
}}
>
<Typography
sx={{
color: hasActivity
? theme.palette.status.success
: alpha(theme.palette.text.primary, TEXT_OPACITY.tertiary),
fontFamily: theme.typography.fontFamily,
fontSize: '0.72rem',
fontWeight: 700,
lineHeight: 1.25,
}}
>
{pluralize(count, 'contribution')}
</Typography>
</Box>
</Box>
);
};

const ContributionCalendarLegend: React.FC = () => {
const theme = useTheme();
const monoFontFamily = theme.typography.fontFamily;
Expand Down Expand Up @@ -133,6 +212,7 @@ const ContributionCalendar: React.FC<ContributionCalendarProps> = ({
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const scrollRef = useRef<HTMLDivElement>(null);
const [heatmapWidth, setHeatmapWidth] = useState(0);
const currentMs = Date.now();

const isEmpty = calendar.hours.every((hour) => hour.count === 0);
const peakContributionCount = useMemo(
Expand All @@ -144,6 +224,10 @@ const ContributionCalendar: React.FC<ContributionCalendarProps> = ({
() => 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],
Expand All @@ -155,16 +239,41 @@ const ContributionCalendar: React.FC<ContributionCalendarProps> = ({
const blockSize = isMobile ? 7 : 10;
const blockGap = isMobile ? 3 : 3;
const labelColumnWidth = isMobile ? 28 : 34;
const baseHeatmapCellStyle = useMemo<React.CSSProperties>(
() => ({
display: 'block',
width: blockSize,
height: blockSize,
borderRadius: 2,
}),
[blockSize],
);
const hiddenHeatmapCellStyle = useMemo<React.CSSProperties>(
() => ({
...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(
Expand Down Expand Up @@ -317,19 +426,6 @@ const ContributionCalendar: React.FC<ContributionCalendarProps> = ({
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) => {
Expand All @@ -349,8 +445,8 @@ const ContributionCalendar: React.FC<ContributionCalendarProps> = ({
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,
Expand All @@ -363,8 +459,17 @@ const ContributionCalendar: React.FC<ContributionCalendarProps> = ({
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' },
Expand All @@ -373,12 +478,16 @@ const ContributionCalendar: React.FC<ContributionCalendarProps> = ({
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': {
Expand Down Expand Up @@ -479,33 +588,64 @@ const ContributionCalendar: React.FC<ContributionCalendarProps> = ({
2,
'0',
)}`;
const isConcluded =
getHourEndMs(dateKey, hour) <= currentMs;
if (!isConcluded) {
return (
<span
key={timestamp}
aria-hidden="true"
style={hiddenHeatmapCellStyle}
/>
);
}
const bucket = hourMap.get(timestamp);
const count = bucket?.count ?? 0;
const label = formatHourTooltip(timestamp, count);
const blockStyle: React.CSSProperties = {
...baseHeatmapCellStyle,
backgroundColor: getHeatmapBlockColor(count),
boxShadow:
count > 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`,
cursor: count > 0 ? 'default' : 'initial',
};

if (count <= 0) {
return (
<span
key={timestamp}
aria-label={label}
style={blockStyle}
/>
);
}

return (
<Box
<Tooltip
key={timestamp}
component="span"
title={label}
aria-label={label}
sx={{
display: 'block',
width: blockSize,
height: blockSize,
borderRadius: '2px',
backgroundColor: getHeatmapBlockColor(count),
boxShadow:
count > 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`,
}}
/>
title={
<HeatmapTooltipTitle
date={dateKey}
hour={hour}
count={count}
/>
}
arrow
disableInteractive
enterDelay={120}
enterNextDelay={40}
placement="top"
>
<span aria-label={label} style={blockStyle} />
</Tooltip>
);
})}
</React.Fragment>
Expand Down
Loading