From 6e1c51b5c9f58c1585109ab6347983988b7840cf Mon Sep 17 00:00:00 2001 From: nikhilsinghal005 Date: Tue, 12 May 2026 17:27:32 +0530 Subject: [PATCH] Add watchlist bounty pool chart --- src/pages/WatchlistPage.tsx | 179 ++++++++++++++++++++++- src/tests/bountyPoolByRepository.test.ts | 67 +++++++++ src/utils/bountyPoolByRepository.ts | 37 +++++ 3 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/tests/bountyPoolByRepository.test.ts create mode 100644 src/utils/bountyPoolByRepository.ts diff --git a/src/pages/WatchlistPage.tsx b/src/pages/WatchlistPage.tsx index 61a88467..c8181a4f 100644 --- a/src/pages/WatchlistPage.tsx +++ b/src/pages/WatchlistPage.tsx @@ -97,6 +97,7 @@ import { getIssueStatusMeta } from '../utils/issueStatus'; import { formatDate, formatTokenAmount } from '../utils/format'; import { compareByWatchlist } from '../utils/watchlistSort'; import { getRepositoryOwnerAvatarSrc } from '../utils/avatar'; +import { getBountyPoolByRepository } from '../utils/bountyPoolByRepository'; import theme, { CHART_COLORS, LABEL_COLORS, @@ -2430,6 +2431,7 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { const [sortField, setSortField] = useState('date'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + const [showChart, setShowChart] = useState(false); useEffect(() => { setPage(0); @@ -2481,6 +2483,102 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { }); }, [filtered, sortField, sortOrder]); + const chartData = useMemo( + () => getBountyPoolByRepository(filtered), + [filtered], + ); + + const chartOption = useMemo(() => { + const textColor = alpha(theme.palette.common.white, 0.85); + const gridColor = theme.palette.border.subtle; + + return { + backgroundColor: 'transparent', + title: { + text: 'Bounty Pool by Repository', + subtext: `${filtered.length} watched issues`, + left: 'center', + top: 20, + textStyle: { + color: theme.palette.text.primary, + fontFamily: 'JetBrains Mono', + fontSize: 16, + fontWeight: 600, + }, + subtextStyle: { + color: alpha(theme.palette.common.white, TEXT_OPACITY.tertiary), + fontFamily: 'JetBrains Mono', + fontSize: 12, + }, + }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + backgroundColor: alpha(theme.palette.background.default, 0.95), + borderColor: alpha(theme.palette.common.white, 0.15), + borderWidth: 1, + textStyle: { + color: theme.palette.text.primary, + fontFamily: 'JetBrains Mono', + }, + formatter: (params: { name: string; value: number }[]) => { + const p = params[0]; + return `${p.name}: ${p.value.toFixed(4)} ل`; + }, + }, + grid: { + left: '3%', + right: '3%', + bottom: '15%', + top: '20%', + containLabel: true, + }, + xAxis: { + type: 'category', + data: chartData.map((item) => item.repositoryName), + axisLabel: { + color: textColor, + rotate: 45, + interval: 0, + fontSize: 11, + fontFamily: 'JetBrains Mono', + }, + axisLine: { lineStyle: { color: gridColor } }, + }, + yAxis: { + type: 'value', + name: 'TAO', + nameTextStyle: { color: textColor, fontFamily: 'JetBrains Mono' }, + axisLabel: { + color: textColor, + fontFamily: 'JetBrains Mono', + formatter: (value: number) => `${value.toFixed(2)} ل`, + }, + splitLine: { lineStyle: { color: gridColor } }, + }, + series: [ + { + name: 'Bounty Pool', + type: 'bar', + data: chartData.map((item) => ({ + value: item.amount, + itemStyle: { color: CHART_COLORS.open }, + })), + barMaxWidth: 40, + label: { + show: true, + position: 'top', + color: textColor, + fontFamily: 'JetBrains Mono', + fontSize: 10, + formatter: (params: { value: number }) => + params.value > 0 ? params.value.toFixed(2) : '', + }, + }, + ], + }; + }, [chartData, filtered.length]); + const totalBountyPages = Math.max( 1, Math.ceil(filtered.length / ROWS_PER_PAGE), @@ -2498,6 +2596,82 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { return sorted.slice(start, start + ROWS_PER_PAGE); }, [sorted, page, sidebarFixedRight]); + const chartToggle = ( + + Chart + + + setShowChart((value) => !value)} + sx={{ + color: showChart ? 'text.primary' : 'text.tertiary', + border: '1px solid', + borderColor: 'border.light', + borderRadius: 2, + padding: '6px', + '&:hover': { + backgroundColor: 'surface.subtle', + borderColor: 'border.medium', + }, + }} + > + {showChart ? ( + + ) : ( + + )} + + + + {showChart ? 'Hide chart' : 'Show chart'} + + + + ); + + const chartCollapse = ( + + + {showChart ? ( + chartData.length > 0 ? ( + + ) : ( + + + No watched bounty pool data to chart. + + + ) + ) : null} + + + ); + useEffect(() => { if (!sidebarFixedRight) return; const target = observerTarget.current; @@ -2569,9 +2743,12 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => { }} /> } - hasActiveFilter={statusFilter !== 'all'} + extraContent={chartToggle} + hasActiveFilter={statusFilter !== 'all' || showChart} /> + {chartCollapse} + {viewMode === 'list' ? ( columns={bountyColumns} diff --git a/src/tests/bountyPoolByRepository.test.ts b/src/tests/bountyPoolByRepository.test.ts new file mode 100644 index 00000000..01b5b949 --- /dev/null +++ b/src/tests/bountyPoolByRepository.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { getBountyPoolByRepository } from '../utils/bountyPoolByRepository'; +import type { IssueBounty } from '../api/models/Issues'; + +const bounty = ( + id: number, + repositoryFullName: string, + targetBounty: string, + bountyAmount = '0', +): IssueBounty => ({ + id, + githubUrl: `https://github.com/${repositoryFullName}/issues/${id}`, + repositoryFullName, + issueNumber: id, + bountyAmount, + targetBounty, + status: 'active', + solverHotkey: null, + winningPrNumber: null, + registeredAtBlock: 0, + createdAt: '2026-05-12T00:00:00Z', + updatedAt: '2026-05-12T00:00:00Z', + closedAt: null, + completedAt: null, + title: `Issue ${id}`, +}); + +describe('getBountyPoolByRepository', () => { + it('aggregates watched bounty amounts by repository and sorts by pool size', () => { + const result = getBountyPoolByRepository([ + bounty(1, 'owner/a', '1.5'), + bounty(2, 'owner/b', '4'), + bounty(3, 'owner/a', '2.25'), + ]); + + expect(result).toEqual([ + { repositoryFullName: 'owner/b', repositoryName: 'b', amount: 4 }, + { repositoryFullName: 'owner/a', repositoryName: 'a', amount: 3.75 }, + ]); + }); + + it('falls back to bountyAmount when targetBounty is missing', () => { + const result = getBountyPoolByRepository([bounty(1, 'owner/a', '', '2.5')]); + + expect(result).toEqual([ + { repositoryFullName: 'owner/a', repositoryName: 'a', amount: 2.5 }, + ]); + }); + + it('limits chart data to the top repositories', () => { + const issues = Array.from({ length: 25 }, (_, index) => + bounty(index + 1, `owner/repo-${index + 1}`, String(index + 1)), + ); + + const result = getBountyPoolByRepository(issues, 20); + + expect(result).toHaveLength(20); + expect(result[0]).toMatchObject({ + repositoryFullName: 'owner/repo-25', + amount: 25, + }); + expect(result[result.length - 1]).toMatchObject({ + repositoryFullName: 'owner/repo-6', + amount: 6, + }); + }); +}); diff --git a/src/utils/bountyPoolByRepository.ts b/src/utils/bountyPoolByRepository.ts new file mode 100644 index 00000000..87d14391 --- /dev/null +++ b/src/utils/bountyPoolByRepository.ts @@ -0,0 +1,37 @@ +import type { IssueBounty } from '../api/models/Issues'; + +export interface BountyRepositoryPool { + repositoryFullName: string; + repositoryName: string; + amount: number; +} + +const parseBountyAmount = (issue: IssueBounty): number => { + const rawAmount = issue.targetBounty || issue.bountyAmount || '0'; + const amount = Number.parseFloat(rawAmount); + return Number.isFinite(amount) ? amount : 0; +}; + +export const getBountyPoolByRepository = ( + issues: IssueBounty[], + limit = 20, +): BountyRepositoryPool[] => { + const repoTotals = new Map(); + + issues.forEach((issue) => { + repoTotals.set( + issue.repositoryFullName, + (repoTotals.get(issue.repositoryFullName) || 0) + + parseBountyAmount(issue), + ); + }); + + return [...repoTotals.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([repositoryFullName, amount]) => ({ + repositoryFullName, + repositoryName: repositoryFullName.split('/')[1] || repositoryFullName, + amount, + })); +};