diff --git a/src/components/leaderboard/MinerActivityTrendChart.tsx b/src/components/leaderboard/MinerActivityTrendChart.tsx new file mode 100644 index 00000000..0accbd28 --- /dev/null +++ b/src/components/leaderboard/MinerActivityTrendChart.tsx @@ -0,0 +1,512 @@ +import React, { useMemo, useState } from 'react'; +import { + Box, + CircularProgress, + FormControl, + MenuItem, + Select, + Stack, + Typography, +} from '@mui/material'; +import { alpha, useTheme, type Theme } from '@mui/material/styles'; +import ReactECharts from 'echarts-for-react'; +import { + eachDayOfInterval, + format, + isValid, + parseISO, + startOfDay, + subDays, +} from 'date-fns'; +import type { CommitLog } from '../../api/models/Dashboard'; +import { GITTENSOR_START_MS } from '../../pages/dashboard/dashboardData'; +import { CHART_COLORS } from '../../theme'; +import { + echartsAxisTooltipChrome, + echartsFontFamily, + echartsMutedCartesianAxisColors, + echartsTransparentBackground, +} from '../../utils/echarts/gittensorChartTheme'; +import { isMergedPr } from '../../utils/prStatus'; +import { ChartEmptyPanel } from '../common/ChartEmptyPanel'; + +export type MinerActivityRange = '7d' | '35d' | 'all'; + +const RANGE_OPTIONS: { value: MinerActivityRange; label: string }[] = [ + { value: '7d', label: '7D' }, + { value: '35d', label: '35D' }, + { value: 'all', label: 'All' }, +]; + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +const LINE_SMOOTH = 0.35; +const SERIES_LINE_OPACITY = 0.82; +const SERIES_AXIS_LABEL_OPACITY = 0.58; +const SERIES_LEGEND_OPACITY = 0.72; + +const getMinerActivitySeriesColors = (theme: Theme) => { + const rewardsBase = theme.palette.diff.additions; + const minersBase = CHART_COLORS.series[3]; + return { + rewardsLine: alpha(rewardsBase, SERIES_LINE_OPACITY), + rewardsAxis: alpha(rewardsBase, SERIES_AXIS_LABEL_OPACITY), + rewardsLegend: alpha(rewardsBase, SERIES_LEGEND_OPACITY), + minersLine: alpha(minersBase, SERIES_LINE_OPACITY), + minersAxis: alpha(minersBase, SERIES_AXIS_LABEL_OPACITY), + minersLegend: alpha(minersBase, SERIES_LEGEND_OPACITY), + }; +}; +const CHART_HEIGHT_DEFAULT = 280; +const CHART_HEIGHT_SIDEBAR = 220; + +const formatRewardsAxis = (value: number): string => { + if (value >= 1000) { + const compact = value / 1000; + const rounded = + compact >= 10 ? Math.round(compact) : Math.round(compact * 10) / 10; + return `${rounded}k`; + } + return String(Math.round(value)); +}; + +type ActivityBucket = { + key: string; + label: string; + startMs: number; + endMs: number; +}; + +function bucketDayKey(d: Date): string { + return format(startOfDay(d), 'yyyy-MM-dd'); +} + +function getUtcWeekStart(timestamp: number): number { + const date = new Date(timestamp); + const dayOfWeek = date.getUTCDay(); + const diffToMonday = (dayOfWeek + 6) % 7; + return Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate() - diffToMonday, + ); +} + +function buildActivityBuckets(range: MinerActivityRange): ActivityBucket[] { + const now = Date.now(); + const endDay = startOfDay(new Date()); + + if (range === '7d' || range === '35d') { + const days = range === '7d' ? 7 : 35; + const start = startOfDay(subDays(endDay, days - 1)); + return eachDayOfInterval({ start, end: endDay }).map((d) => { + const startMs = startOfDay(d).getTime(); + return { + key: bucketDayKey(d), + label: format(d, 'MMM d'), + startMs, + endMs: startMs + 24 * 60 * 60 * 1000, + }; + }); + } + + const firstWeekStart = getUtcWeekStart(GITTENSOR_START_MS); + const currentWeekStart = getUtcWeekStart(now); + const endExclusive = currentWeekStart + WEEK_MS; + const buckets: ActivityBucket[] = []; + + for ( + let bucketStart = firstWeekStart; + bucketStart < endExclusive; + bucketStart += WEEK_MS + ) { + buckets.push({ + key: String(bucketStart), + label: new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + }).format(new Date(bucketStart)), + startMs: bucketStart, + endMs: bucketStart + WEEK_MS, + }); + } + + return buckets; +} + +function findBucketIndex(timestamp: number, buckets: ActivityBucket[]): number { + for (let index = 0; index < buckets.length; index += 1) { + const bucket = buckets[index]; + if (timestamp >= bucket.startMs && timestamp < bucket.endMs) { + return index; + } + } + return -1; +} + +function aggregateMinerActivity( + prs: CommitLog[], + buckets: ActivityBucket[], + minerGithubIds?: Set, + repositoryFullName?: string, +): { rewards: number[]; activeMiners: number[] } { + const repoLower = repositoryFullName?.toLowerCase(); + const rewards = Array.from({ length: buckets.length }, () => 0); + const minersByBucket = buckets.map(() => new Set()); + + for (const pr of prs) { + if (!isMergedPr(pr)) continue; + if (repoLower && pr.repository?.toLowerCase() !== repoLower) continue; + if (minerGithubIds?.size) { + const id = pr.githubId?.trim(); + if (!id || !minerGithubIds.has(id)) continue; + } + const raw = pr.mergedAt; + if (!raw) continue; + const merged = parseISO(raw); + if (!isValid(merged)) continue; + const index = findBucketIndex(merged.getTime(), buckets); + if (index < 0) continue; + + rewards[index] += Number.parseFloat(pr.score || '0') || 0; + const minerKey = pr.githubId?.trim() || pr.author?.trim().toLowerCase(); + if (minerKey) minersByBucket[index].add(minerKey); + } + + return { + rewards, + activeMiners: minersByBucket.map((set) => set.size), + }; +} + +function buildMinerActivityChartOption( + theme: Theme, + labels: string[], + rewards: number[], + activeMiners: number[], + range: MinerActivityRange, +) { + const chartFont = echartsFontFamily(theme); + const { labelColor, axisLineColor, splitLineColor } = + echartsMutedCartesianAxisColors(theme); + const labelInterval = range === '7d' ? 0 : range === '35d' ? 6 : 'auto'; + const seriesColors = getMinerActivitySeriesColors(theme); + + return { + ...echartsTransparentBackground(), + animationDuration: 420, + color: [seriesColors.rewardsLine, seriesColors.minersLine], + legend: { + data: [ + { + name: 'Rewards (α)', + itemStyle: { color: seriesColors.rewardsLegend }, + }, + { + name: 'Active Miners', + itemStyle: { color: seriesColors.minersLegend }, + }, + ], + top: 0, + left: 'center', + itemGap: 16, + textStyle: { + color: labelColor, + fontSize: 9, + fontFamily: chartFont, + }, + icon: 'circle', + itemWidth: 7, + itemHeight: 7, + }, + grid: { + left: '2%', + right: '2%', + top: 36, + bottom: 6, + containLabel: true, + }, + tooltip: { + trigger: 'axis', + confine: true, + appendTo: () => document.body, + axisPointer: { + type: 'line', + lineStyle: { + color: alpha(theme.palette.text.primary, 0.18), + width: 1, + }, + }, + ...echartsAxisTooltipChrome(theme), + padding: [10, 12], + textStyle: { + color: theme.palette.text.primary, + fontFamily: chartFont, + fontSize: 11, + }, + formatter: ( + params: Array<{ + axisValueLabel: string; + seriesName: string; + value: number; + color?: string; + }>, + ) => { + const rows = params + .map((entry) => { + const dot = ``; + const formatted = + entry.seriesName === 'Rewards (α)' + ? Math.round(entry.value ?? 0).toLocaleString() + : String(entry.value ?? 0); + return `
+ ${dot}${entry.seriesName} + ${formatted} +
`; + }) + .join(''); + return `
+
${params[0]?.axisValueLabel ?? ''}
+ ${rows} +
`; + }, + }, + xAxis: { + type: 'category', + boundaryGap: false, + data: labels, + axisLabel: { + color: labelColor, + fontFamily: chartFont, + fontSize: 9, + interval: labelInterval, + hideOverlap: true, + }, + axisLine: { lineStyle: { color: axisLineColor } }, + axisTick: { show: false }, + }, + yAxis: [ + { + type: 'value', + min: 0, + splitNumber: 4, + position: 'left', + axisLabel: { + color: seriesColors.rewardsAxis, + fontFamily: chartFont, + fontSize: 9, + formatter: (value: number) => formatRewardsAxis(value), + }, + splitLine: { + lineStyle: { color: splitLineColor, type: 'dashed' as const }, + }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + { + type: 'value', + min: 0, + splitNumber: 4, + position: 'right', + axisLabel: { + color: seriesColors.minersAxis, + fontFamily: chartFont, + fontSize: 9, + }, + splitLine: { show: false }, + axisLine: { show: false }, + axisTick: { show: false }, + }, + ], + series: [ + { + name: 'Rewards (α)', + type: 'line', + yAxisIndex: 0, + smooth: LINE_SMOOTH, + showSymbol: true, + symbol: 'circle', + symbolSize: 5, + data: rewards, + lineStyle: { + width: 2, + color: seriesColors.rewardsLine, + }, + itemStyle: { color: seriesColors.rewardsLine }, + emphasis: { + focus: 'series', + lineStyle: { width: 2.5 }, + }, + }, + { + name: 'Active Miners', + type: 'line', + yAxisIndex: 1, + smooth: LINE_SMOOTH, + showSymbol: true, + symbol: 'circle', + symbolSize: 5, + data: activeMiners, + lineStyle: { + width: 2, + color: seriesColors.minersLine, + }, + itemStyle: { color: seriesColors.minersLine }, + emphasis: { + focus: 'series', + lineStyle: { width: 2.5 }, + }, + }, + ], + }; +} + +export interface MinerActivityTrendChartProps { + prs: CommitLog[] | undefined; + isLoading?: boolean; + /** When set, only merged PRs from these GitHub IDs are counted. */ + minerGithubIds?: Set; + /** When set, only PRs in this repository are counted. */ + repositoryFullName?: string; + title?: string; + /** Sidebar layout — shorter chart for the right column. */ + variant?: 'default' | 'sidebar'; +} + +const MinerActivityTrendChart: React.FC = ({ + prs, + isLoading = false, + minerGithubIds, + repositoryFullName, + title = 'Miner Activity', + variant = 'default', +}) => { + const theme = useTheme(); + const [range, setRange] = useState('35d'); + const isSidebar = variant === 'sidebar'; + const chartHeight = isSidebar ? CHART_HEIGHT_SIDEBAR : CHART_HEIGHT_DEFAULT; + + const buckets = useMemo(() => buildActivityBuckets(range), [range]); + + const { rewards, activeMiners, hasAny } = useMemo(() => { + const rows = prs ?? []; + const { rewards: rewardValues, activeMiners: minerCounts } = + aggregateMinerActivity(rows, buckets, minerGithubIds, repositoryFullName); + const any = + rewardValues.some((n) => n > 0) || minerCounts.some((n) => n > 0); + return { + rewards: rewardValues, + activeMiners: minerCounts, + hasAny: any, + }; + }, [prs, buckets, minerGithubIds, repositoryFullName]); + + const chartOption = useMemo( + () => + buildMinerActivityChartOption( + theme, + buckets.map((b) => b.label), + rewards, + activeMiners, + range, + ), + [theme, buckets, rewards, activeMiners, range], + ); + + return ( + + + + {title} + + + + + + + div': { width: '100%', height: '100%' }, + }} + > + {isLoading ? ( + + + + ) : ( + + + + )} + + + ); +}; + +export default MinerActivityTrendChart; diff --git a/src/components/leaderboard/index.ts b/src/components/leaderboard/index.ts index d45d0e7b..58fa4bc9 100644 --- a/src/components/leaderboard/index.ts +++ b/src/components/leaderboard/index.ts @@ -2,6 +2,7 @@ export { default as TopMinersTable } from './TopMinersTable'; export { default as TopRepositoriesTable } from './TopRepositoriesTable'; export { ActivitySidebarCards } from './ActivitySidebarCards'; +export { default as MinerActivityTrendChart } from './MinerActivityTrendChart'; // Types and utilities export type { MinerStats } from './types'; diff --git a/src/pages/RepositoryDetailsPage.tsx b/src/pages/RepositoryDetailsPage.tsx index 5dd932cf..2a2cc5ff 100644 --- a/src/pages/RepositoryDetailsPage.tsx +++ b/src/pages/RepositoryDetailsPage.tsx @@ -36,7 +36,7 @@ import TuneIcon from '@mui/icons-material/Tune'; import GroupsIcon from '@mui/icons-material/Groups'; import { RANK_COLORS, STATUS_COLORS } from '../theme'; import { Page } from '../components/layout'; -import { useReposAndWeights, useRepoBountySummary } from '../api'; +import { useAllPrs, useReposAndWeights, useRepoBountySummary } from '../api'; import { RepositoryPRsTable, RepositoryIssuesTable, @@ -46,6 +46,7 @@ import { ReadmeViewer, RepositoryStats, RepositoryPrActivityChart, + MinerActivityTrendChart, ContributingViewer, RepositoryMaintainers, RepositoryCheckTab, @@ -213,6 +214,7 @@ const RepositoryDetailsPage: React.FC = () => { const repo = searchParams.get('name'); const tabValue = tabIndexFromSearchParam(searchParams.get('tab')); const { data: repos, isLoading: isLoadingRepos } = useReposAndWeights(); + const { data: allPrs, isLoading: isLoadingPrs } = useAllPrs(); const { data: bountySummary } = useRepoBountySummary(repo || ''); const trackedRepo = repos?.find( (r) => r.fullName.toLowerCase() === (repo ?? '').toLowerCase(), @@ -649,6 +651,15 @@ const RepositoryDetailsPage: React.FC = () => { {/* Repository Stats */} + {tabValue === 2 && repo ? ( + + ) : null} + {tabValue === 4 || tabValue === 5 ? ( { }; const WatchlistPage: React.FC = () => { + const [searchParams] = useSearchParams(); + const activeTab = tabFromParam(searchParams.get('tab')); const { ids: minerIds } = useWatchlist('miners'); const { data: allMinersData } = useAllMiners(); + const { data: allPrs, isLoading: isLoadingPrs } = useAllPrs(); const isLargeScreen = useMediaQuery(theme.breakpoints.up('xl')); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -471,15 +475,16 @@ const WatchlistPage: React.FC = () => { const stickySidebarRef = useTwitterStickySidebar(); + const watchedGithubIds = useMemo(() => new Set(minerIds), [minerIds]); + const minerStats = useMemo(() => { - const watchedSet = new Set(minerIds); return mapAllMinersToStats(allMinersData ?? []) - .filter((m) => watchedSet.has(m.githubId)) + .filter((m) => watchedGithubIds.has(m.githubId)) .map((m) => ({ ...m, isEligible: Boolean(m.ossIsEligible || m.discoveriesIsEligible), })); - }, [allMinersData, minerIds]); + }, [allMinersData, watchedGithubIds]); return ( @@ -540,21 +545,33 @@ const WatchlistPage: React.FC = () => { miners={minerStats} defaultFilter="all" insertAfterFirstCard={ - + <> + {activeTab === 'miners' ? ( + 0 ? watchedGithubIds : undefined + } + /> + ) : null} + + } />