diff --git a/src/components/ContributionHeatmap.tsx b/src/components/ContributionHeatmap.tsx index 6c759ea6..e5ebc58f 100644 --- a/src/components/ContributionHeatmap.tsx +++ b/src/components/ContributionHeatmap.tsx @@ -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; @@ -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; + scrollContainerSx?: SxProps; } const ContributionHeatmap: React.FC = ({ @@ -35,8 +67,18 @@ const ContributionHeatmap: React.FC = ({ 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]; @@ -44,139 +86,160 @@ const ContributionHeatmap: React.FC = ({ const isEmpty = data.length === 0; const interactive = !!onDayClick; - const content = ( - <> - - + {isEmpty ? ( + - {contributionsLast30Days.toLocaleString()} - - - {subtitle} - - - - - {isEmpty ? ( - + {emptyTitle} + + {emptySubtitle && ( - {emptyTitle} + {emptySubtitle} - {emptySubtitle && ( - + ) : ( + { + 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 ? ( + onDayClick?.(activity.date)} + style={{ cursor: 'pointer' }} + role="button" + aria-label={`View ${activity.count} contribution${activity.count !== 1 ? 's' : ''} on ${activity.date}`} + > + {highlighted} + + ) : ( + highlighted + ); + return ( + - {emptySubtitle} - - )} - - ) : ( - + ); + }} + /> + )} + + ); + + const content = ( + <> + {showHeader && ( + + { - 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 ? ( - onDayClick?.(activity.date)} - style={{ cursor: 'pointer' }} - role="button" - aria-label={`View ${activity.count} contribution${activity.count !== 1 ? 's' : ''} on ${activity.date}`} - > - {highlighted} - - ) : ( - highlighted - ); - return ( - - {wrapped} - - ); + > + {contributionsLast30Days.toLocaleString()} + + - )} - + > + {subtitle} + + + )} + + {heatmapScroll} {footerText && ( { overview, trendLabels, trendSeries, + contributionCalendar, featuredWork, isFeaturedWorkLoading, featuredContributors, @@ -75,6 +76,7 @@ const DashboardFeaturePage: React.FC = () => { range={range} trendLabels={trendLabels} trendSeries={trendSeries} + contributionCalendar={contributionCalendar} sections={overview} kpis={kpis} isLoading={isLoading} diff --git a/src/pages/dashboard/dashboardData.ts b/src/pages/dashboard/dashboardData.ts index 264f41e6..4db172b4 100644 --- a/src/pages/dashboard/dashboardData.ts +++ b/src/pages/dashboard/dashboardData.ts @@ -55,6 +55,21 @@ export interface DashboardKpi { subtitle: string; } +export interface DashboardContributionDay { + date: string; + count: number; + level: 0 | 1 | 2 | 3 | 4; +} + +export interface DashboardContributionCalendar { + days: DashboardContributionDay[]; + totalDaysShown: number; + weekCount: number; + thisWeekCount: number; + weekOverWeekPercent: number | null; + weekOverWeekLabel: string; +} + export interface DashboardFeaturedContributor { featuredLabel: string; githubId: string; @@ -123,6 +138,10 @@ const TREND_SERIES_KEYS: TrendSeriesKey[] = [ 'issuesOpened', ]; const CURRENT_LOOKBACK_WINDOW: PresetTimeRange = '35d'; +/** Activity counted over this rolling window (inclusive, ending today). */ +export const CONTRIBUTION_CALENDAR_DAYS = 365; +/** GitHub-style column count (Sun–Sat weeks). */ +export const CONTRIBUTION_CALENDAR_WEEKS = 53; type WindowBounds = { startMs: number; @@ -339,6 +358,146 @@ export const buildDashboardTrendData = ( }; }; +/** Local calendar date key (yyyy-MM-dd) — matches the user's system day boundaries. */ +const formatCalendarDateKey = (timestamp: number) => { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const getLocalDayStart = (timestamp: number) => { + const date = new Date(timestamp); + date.setHours(0, 0, 0, 0); + return date.getTime(); +}; + +const addLocalDays = (dayStartMs: number, days: number) => { + const date = new Date(dayStartMs); + date.setDate(date.getDate() + days); + date.setHours(0, 0, 0, 0); + 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()); + return date.getTime(); +}; + +const parseCalendarDateKey = (dateKey: string) => { + const [year, month, day] = dateKey.split('-').map(Number); + return getLocalDayStart(new Date(year, month - 1, day).getTime()); +}; + +const getContributionLevel = (count: 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; + 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), + ); + // 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 }; +}; + +export const buildDashboardContributionCalendar = ( + prs: CommitLog[], + issues: MirrorDashboardIssue[], + now = new Date(), +): DashboardContributionCalendar => { + const { gridStartMs, rollingEndMs, rollingStartMs } = + buildContributionCalendarDateRange(now); + const dataMap = new Map(); + + for ( + let dayMs = gridStartMs; + dayMs <= rollingEndMs; + dayMs = addLocalDays(dayMs, 1) + ) { + dataMap.set(formatCalendarDateKey(dayMs), 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); + }; + + prs.forEach((pr) => incrementDay(toTimestamp(pr.mergedAt))); + + issues.forEach((issue) => { + if (!isResolvedMinerIssue(issue)) return; + incrementDay(toTimestamp(issue.solving_pr?.merged_at)); + }); + + const days = Array.from(dataMap.entries()) + .map(([date, count]) => ({ + date, + count, + level: getContributionLevel(count), + })) + .sort((a, b) => a.date.localeCompare(b.date)); + + const thisWeekStart = getLocalSundayWeekStart(now.getTime()); + const lastWeekStart = thisWeekStart - WEEK_MS; + + let thisWeekCount = 0; + let lastWeekCount = 0; + + days.forEach(({ date, count }) => { + const dayMs = parseCalendarDateKey(date); + if (dayMs >= thisWeekStart) { + thisWeekCount += count; + return; + } + if (dayMs >= lastWeekStart) { + lastWeekCount += count; + } + }); + + let weekOverWeekPercent: number | null = null; + let weekOverWeekLabel = '0% vs last week'; + + if (thisWeekCount === 0 && lastWeekCount === 0) { + weekOverWeekPercent = 0; + } else if (lastWeekCount > 0) { + weekOverWeekPercent = + ((thisWeekCount - lastWeekCount) / lastWeekCount) * 100; + const rounded = Math.round(weekOverWeekPercent); + const sign = rounded > 0 ? '+' : ''; + weekOverWeekLabel = `${sign}${rounded}% vs last week`; + } else if (thisWeekCount > 0) { + weekOverWeekLabel = 'New activity this week'; + } + + return { + days, + totalDaysShown: dataMap.size, + weekCount: Math.ceil(days.length / 7), + thisWeekCount, + weekOverWeekPercent, + weekOverWeekLabel, + }; +}; + // Each PR contributes to exactly one bucket, keyed by its terminal state and // the timestamp that produced that state. API does not currently return // closedAt for PRs — fall back to prCreatedAt so closed PRs are still windowed. diff --git a/src/pages/dashboard/useDashboardData.ts b/src/pages/dashboard/useDashboardData.ts index 53cfdca1..8933cd09 100644 --- a/src/pages/dashboard/useDashboardData.ts +++ b/src/pages/dashboard/useDashboardData.ts @@ -25,6 +25,7 @@ import { type Repository, } from '../../api/models'; import { + buildDashboardContributionCalendar, buildDashboardKpis, buildDashboardOverview, buildDashboardTrendData, @@ -134,6 +135,15 @@ const useDashboardData = (range: TrendTimeRange) => { [datasets.minerIssues.data, datasets.prs.data, range], ); + const contributionCalendar = useMemo( + () => + buildDashboardContributionCalendar( + datasets.prs.data, + datasets.minerIssues.data, + ), + [datasets.minerIssues.data, datasets.prs.data], + ); + const featuredContributors = useMemo( () => buildFeaturedContributors(datasets.prs.data, datasets.miners.data), [datasets.miners.data, datasets.prs.data], @@ -175,6 +185,7 @@ const useDashboardData = (range: TrendTimeRange) => { overview, trendLabels: trendData.labels, trendSeries: trendData.series, + contributionCalendar, featuredWork, isFeaturedWorkLoading, featuredContributors, diff --git a/src/pages/dashboard/views/ActiveNetwork.tsx b/src/pages/dashboard/views/ActiveNetwork.tsx index a74a135a..3a6dbcbc 100644 --- a/src/pages/dashboard/views/ActiveNetwork.tsx +++ b/src/pages/dashboard/views/ActiveNetwork.tsx @@ -1,11 +1,13 @@ import React from 'react'; import { Box, CircularProgress } from '@mui/material'; import { + type DashboardContributionCalendar, type DashboardKpi, type DashboardOverviewSection, type DashboardTrendSeries, type TrendTimeRange, } from '../dashboardData'; +import ContributionCalendar from './ContributionCalendar'; import ContributionTrends from './ContributionTrends'; import DashboardOverview from './DashboardOverview'; @@ -13,6 +15,7 @@ interface ActiveNetworkProps { range: TrendTimeRange; trendLabels: string[]; trendSeries: DashboardTrendSeries[]; + contributionCalendar: DashboardContributionCalendar; sections: DashboardOverviewSection[]; kpis: DashboardKpi[]; isLoading?: boolean; @@ -23,6 +26,7 @@ const ActiveNetwork: React.FC = ({ range, trendLabels, trendSeries, + contributionCalendar, sections, kpis, isLoading = false, @@ -46,6 +50,11 @@ const ActiveNetwork: React.FC = ({ onRangeChange={onRangeChange} /> + + {isLoading ? ( { + const theme = useTheme(); + const monoFontFamily = theme.typography.fontFamily; + const legendColor = alpha(theme.palette.text.primary, TEXT_OPACITY.tertiary); + + return ( + + + Less + + {CONTRIBUTION_HEATMAP_SCALE.map((color) => ( + + ))} + + More + + + ); +}; + +const ContributionCalendar: React.FC = ({ + calendar, + isLoading = false, +}) => { + const theme = useTheme(); + const monoFontFamily = theme.typography.fontFamily; + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const scrollRef = useRef(null); + + const isEmpty = calendar.days.every((day) => day.count === 0); + const totalContributions = useMemo( + () => calendar.days.reduce((sum, day) => sum + day.count, 0), + [calendar.days], + ); + + const blockConfig = isMobile ? CALENDAR_BLOCK.mobile : CALENDAR_BLOCK.desktop; + + 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], + ); + + useEffect(() => { + const el = scrollRef.current; + if (!el || isEmpty || isLoading) return; + el.scrollLeft = el.scrollWidth; + }, [calendar.days, isEmpty, isLoading]); + + const weekTrendPositive = + calendar.weekOverWeekPercent !== null && calendar.weekOverWeekPercent >= 0; + const weekTrendColor = + calendar.weekOverWeekPercent === null + ? alpha(theme.palette.text.primary, TEXT_OPACITY.muted) + : weekTrendPositive + ? theme.palette.status.success + : theme.palette.status.closed; + + const weekSummaryCard = ( + + + This week + + + {calendar.thisWeekCount.toLocaleString()} + + + Contributions + + + {calendar.weekOverWeekPercent !== null && + (weekTrendPositive ? ( + + ) : ( + + ))} + + {calendar.weekOverWeekLabel} + + + + ); + + return ( + + + Contribution Calendar + + + + + {isLoading ? ( + + + + ) : ( + + + + + + {totalContributions.toLocaleString()} contribution + {totalContributions === 1 ? '' : 's'} in the last year + + {!isEmpty && } + + + {weekSummaryCard} + + )} + + + + ); +}; + +export default ContributionCalendar;