Skip to content
Closed
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
179 changes: 178 additions & 1 deletion src/pages/WatchlistPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,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,
Expand Down Expand Up @@ -2435,6 +2436,7 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => {

const [sortField, setSortField] = useState<BountySortKey>('date');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [showChart, setShowChart] = useState(false);

useEffect(() => {
setPage(0);
Expand Down Expand Up @@ -2486,6 +2488,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),
Expand All @@ -2503,6 +2601,82 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => {
return sorted.slice(start, start + ROWS_PER_PAGE);
}, [sorted, page, sidebarFixedRight]);

const chartToggle = (
<Box>
<OptionsLabel>Chart</OptionsLabel>
<Stack direction="row" spacing={1} alignItems="center">
<Tooltip title={showChart ? 'Hide Chart' : 'Show Chart'}>
<IconButton
size="small"
onClick={() => 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 ? (
<TableChartIcon fontSize="small" />
) : (
<BarChartIcon fontSize="small" />
)}
</IconButton>
</Tooltip>
<Typography
sx={{
fontFamily: '"JetBrains Mono", monospace',
fontSize: '0.78rem',
color: 'text.secondary',
}}
>
{showChart ? 'Hide chart' : 'Show chart'}
</Typography>
</Stack>
</Box>
);

const chartCollapse = (
<Collapse in={showChart}>
<Box
sx={{
height: 500,
p: 2,
borderBottom: '1px solid',
borderColor: 'border.light',
backgroundColor: alpha(theme.palette.common.black, 0.2),
}}
>
{showChart ? (
chartData.length > 0 ? (
<ReactECharts
option={chartOption}
style={{ height: '100%', width: '100%' }}
/>
) : (
<Box
sx={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography sx={{ color: 'text.secondary', fontSize: '0.85rem' }}>
No watched bounty pool data to chart.
</Typography>
</Box>
)
) : null}
</Box>
</Collapse>
);

useEffect(() => {
if (!sidebarFixedRight) return;
const target = observerTarget.current;
Expand Down Expand Up @@ -2576,11 +2750,14 @@ const BountiesList: React.FC<{ itemKeys: string[] }> = ({ itemKeys }) => {
}}
/>
}
hasActiveFilter={statusFilter !== 'all'}
extraContent={chartToggle}
hasActiveFilter={statusFilter !== 'all' || showChart}
/>
)}
</DebouncedSearchInput>

{chartCollapse}

{viewMode === 'list' ? (
<DataTable<IssueBounty, BountySortKey>
columns={bountyColumns}
Expand Down
67 changes: 67 additions & 0 deletions src/tests/bountyPoolByRepository.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
37 changes: 37 additions & 0 deletions src/utils/bountyPoolByRepository.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>();

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,
}));
};