diff --git a/src/api/models/Dashboard.ts b/src/api/models/Dashboard.ts index dd259ea1..4e85bc17 100644 --- a/src/api/models/Dashboard.ts +++ b/src/api/models/Dashboard.ts @@ -362,6 +362,14 @@ export type MinerEvaluation = { lifetimeAlpha?: number; lifetimeTao?: number; lifetimeUsd?: number; + // Metagraph standing — present on the leaderboard list (`GET /miners`); used + // to surface a miner's authoritative subnet rank. Absent on the single-miner + // endpoint, so always optional. + metagraphRank?: number; + metagraphTrust?: number; + metagraphConsensus?: number; + metagraphIncentive?: number; + metagraphEmission?: number; }; export type GithubMinerData = { diff --git a/src/components/common/SegmentedToggle.tsx b/src/components/common/SegmentedToggle.tsx new file mode 100644 index 00000000..5ca93c73 --- /dev/null +++ b/src/components/common/SegmentedToggle.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Box, ToggleButton, ToggleButtonGroup } from '@mui/material'; + +export interface SegmentedToggleOption { + value: T; + label: string; + /** Optional muted count shown after the label. */ + count?: number; +} + +interface SegmentedToggleProps { + value: T; + onChange: (value: T) => void; + options: ReadonlyArray>; + ariaLabel?: string; +} + +/** + * Segmented pill toggle — a bordered container with one elevated active pill, + * each segment optionally showing a muted count. Shared by the dashboard filter + * switches (eligibility, PR status, issue status) so they read identically. + */ +function SegmentedToggle({ + value, + onChange, + options, + ariaLabel, +}: SegmentedToggleProps) { + return ( + v && onChange(v)} + size="small" + aria-label={ariaLabel} + sx={{ + border: '1px solid', + borderColor: 'border.light', + borderRadius: 2, + p: 0.5, + gap: 0.5, + flexWrap: 'wrap', + // Each segment reads as its own pill, not a merged button group. + '& .MuiToggleButtonGroup-grouped': { + m: 0, + border: '1px solid transparent', + borderRadius: '6px !important', + }, + '& .MuiToggleButton-root': { + textTransform: 'none', + fontSize: '0.78rem', + fontWeight: 500, + color: 'text.secondary', + px: 1.25, + py: 0.4, + lineHeight: 1.2, + transition: 'all 0.2s', + '&:hover': { + color: 'text.primary', + backgroundColor: 'surface.light', + }, + '&.Mui-selected': { + color: 'text.primary', + fontWeight: 700, + backgroundColor: 'surface.elevated', + borderColor: 'border.medium', + boxShadow: '0 1px 2px rgba(0, 0, 0, 0.45)', + '&:hover': { backgroundColor: 'surface.elevated' }, + }, + }, + }} + > + {options.map((o) => ( + + {o.label} + {o.count !== undefined && ( + + {o.count} + + )} + + ))} + + ); +} + +export default SegmentedToggle; diff --git a/src/components/common/TablePagination.tsx b/src/components/common/TablePagination.tsx index 7e2c6f52..6e53572c 100644 --- a/src/components/common/TablePagination.tsx +++ b/src/components/common/TablePagination.tsx @@ -64,7 +64,7 @@ export function parseMinerExplorerPageParam(raw: string | null): number { export function getMinerExplorerPaging( items: readonly T[], page: number, - rows: MinerExplorerRowsOption, + rows: number | 'all', ): { totalPages: number; safePage: number; diff --git a/src/components/leaderboard/MinersLeaderTable.tsx b/src/components/leaderboard/MinersLeaderTable.tsx index a152cc97..5dda3668 100644 --- a/src/components/leaderboard/MinersLeaderTable.tsx +++ b/src/components/leaderboard/MinersLeaderTable.tsx @@ -45,6 +45,7 @@ import theme, { } from '../../theme'; import { getRepositoryOwnerAvatarSrc } from '../../utils/avatar'; import { parseNumber } from '../../utils/ExplorerUtils'; +import { formatRelativeTimeAgo as formatLastActive } from '../../utils/format'; import { paginateItems } from '../../utils'; import { useDataTableParams } from '../../hooks/useDataTableParams'; import { useWatchlist } from '../../hooks/useWatchlist'; @@ -160,21 +161,6 @@ const fmtUsd = (n: number): string => { return `$${Math.round(n).toLocaleString()}`; }; -const formatLastActive = (iso: string | null | undefined): string => { - if (!iso) return '—'; - const t = Date.parse(iso); - if (!Number.isFinite(t)) return '—'; - const mins = Math.floor((Date.now() - t) / 60_000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - const months = Math.floor(days / 30); - return `${months}mo ago`; -}; - const SparklineButton: React.FC<{ username: string; githubId?: string; @@ -1717,7 +1703,7 @@ interface ToolbarProps { const SORT_FIELD_LABELS: Record = { score: 'Score', usd: '$/Day', - credibility: 'Success rate', + credibility: 'Credibility', volume: 'Volume (PRs + issues)', active: 'Last active', movement: 'Rank movement', diff --git a/src/components/miners/CredibilityChart.tsx b/src/components/miners/CredibilityChart.tsx deleted file mode 100644 index bdf2b9f3..00000000 --- a/src/components/miners/CredibilityChart.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, { useMemo } from 'react'; -import { Box, Typography, alpha, useTheme } from '@mui/material'; -import ReactECharts from 'echarts-for-react'; -import { CHART_COLORS, TEXT_OPACITY } from '../../theme'; -import { - echartsItemTooltipChrome, - echartsTransparentBackground, -} from '../../utils/echarts/gittensorChartTheme'; -import { - ChartEmptyPanel, - chartNumericSeriesHasPositive, -} from '../common/ChartEmptyPanel'; - -interface CredibilityChartProps { - merged: number; - open: number; - closed: number; - credibility: number; -} - -const CredibilityChart: React.FC = ({ - merged, - open, - closed, - credibility, -}) => { - const theme = useTheme(); - const hasPrActivity = chartNumericSeriesHasPositive([merged, open, closed]); - - const chartOption = useMemo( - () => ({ - ...echartsTransparentBackground(), - title: { - text: `${(credibility * 100).toFixed(0)}%`, - subtext: 'Credibility', - left: 'center', - top: '38%', - textStyle: { - color: theme.palette.text.primary, - fontSize: 28, - fontWeight: 'bold', - }, - subtextStyle: { - color: alpha(theme.palette.common.white, TEXT_OPACITY.muted), - fontSize: 11, - fontWeight: 500, - }, - }, - tooltip: { - trigger: 'item', - formatter: '{b}: {c} ({d}%)', - ...echartsItemTooltipChrome(theme), - }, - series: [ - { - name: 'PR Status', - type: 'pie', - radius: ['58%', '72%'], - avoidLabelOverlap: false, - itemStyle: { - borderRadius: 6, - borderColor: theme.palette.background.paper, - borderWidth: 3, - }, - label: { show: false, position: 'center' }, - emphasis: { label: { show: false }, scale: true, scaleSize: 5 }, - labelLine: { show: false }, - data: [ - { - value: merged, - name: 'Merged', - itemStyle: { color: CHART_COLORS.merged }, - }, - { - value: open, - name: 'Open', - itemStyle: { color: CHART_COLORS.open }, - }, - { - value: closed, - name: 'Closed', - itemStyle: { color: CHART_COLORS.closed }, - }, - ], - }, - ], - }), - [merged, open, closed, credibility, theme], - ); - - return ( - - - Credibility - - - - - - - - - - - - - - - ); -}; - -const LegendItem: React.FC<{ label: string; value: number; color: string }> = ({ - label, - value, - color, -}) => { - const theme = useTheme(); - - return ( - - - - {label} - - - {value} - - - ); -}; - -export default CredibilityChart; diff --git a/src/components/miners/MergeOutcomeBar.tsx b/src/components/miners/MergeOutcomeBar.tsx new file mode 100644 index 00000000..6174b470 --- /dev/null +++ b/src/components/miners/MergeOutcomeBar.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; +import { CHART_COLORS } from '../../theme'; +import { credibilityColor } from '../../utils/format'; + +interface MergeOutcomeBarProps { + /** Merged PRs / solved issues. */ + merged: number; + /** Open PRs / open issues. */ + open: number; + /** Closed-unmerged PRs / closed issues. */ + closed: number; + /** 0–1 merge/solve rate, shown as the headline figure. */ + credibility: number; + /** Heading above the bar. */ + title: string; + /** Label for the merged segment ("Merged" | "Solved"). */ + mergedLabel?: string; + /** Label for the credibility figure ("Merge rate" | "Solve rate"). */ + rateLabel?: string; +} + +const Legend: React.FC<{ label: string; value: number; color: string }> = ({ + label, + value, + color, +}) => ( + + + + {label} + + + {value.toLocaleString()} + + +); + +/** + * A flat horizontal merged / open / closed bar — a clearer, lower-chrome + * replacement for the credibility donut. Reads at a glance at any width and + * stays on the app's border-driven aesthetic (no echarts). The merge/solve + * rate is the headline figure; the bar shows the underlying split. + */ +const MergeOutcomeBar: React.FC = ({ + merged, + open, + closed, + credibility, + title, + mergedLabel = 'Merged', + rateLabel = 'Credibility', +}) => { + const total = merged + open + closed; + const rateColor = credibilityColor(credibility); + const seg = (n: number) => (total > 0 ? `${(n / total) * 100}%` : '0%'); + + return ( + + + + {title} + + {total > 0 && ( + + + {Math.round(credibility * 100)}% + + + {rateLabel} + + + )} + + + {total > 0 ? ( + <> + + {merged > 0 && ( + + )} + {open > 0 && ( + + )} + {closed > 0 && ( + + )} + + + + + + + + ) : ( + + No activity yet. + + )} + + ); +}; + +export default MergeOutcomeBar; diff --git a/src/components/miners/MinerActivity.tsx b/src/components/miners/MinerActivity.tsx index f30224b0..d3b0c9de 100644 --- a/src/components/miners/MinerActivity.tsx +++ b/src/components/miners/MinerActivity.tsx @@ -19,9 +19,8 @@ import { } from '../../api'; import ContributionHeatmap from '../ContributionHeatmap'; import DayPRsPanel from '../DayPRsPanel'; -import { CHART_COLORS, STATUS_COLORS, TEXT_OPACITY } from '../../theme'; +import { STATUS_COLORS, TEXT_OPACITY } from '../../theme'; import { - echartsItemTooltipChrome, echartsRadarChrome, echartsTransparentBackground, } from '../../utils/echarts/gittensorChartTheme'; @@ -32,194 +31,23 @@ import { type IssueRepoStats, } from '../../utils/ExplorerUtils'; import TrustBadge from './TrustBadge'; -import CredibilityChart from './CredibilityChart'; +import MergeOutcomeBar from './MergeOutcomeBar'; import PerformanceRadar from './PerformanceRadar'; -import { - ChartEmptyPanel, - chartNumericSeriesHasPositive, -} from '../common/ChartEmptyPanel'; +import { ChartEmptyPanel } from '../common/ChartEmptyPanel'; type ViewMode = 'prs' | 'issues'; interface MinerActivityProps { githubId: string; viewMode?: ViewMode; + /** Heatmap window from the dashboard's 1D/7D/30D range switch. */ + rangeDays?: number; } // --------------------------------------------------------------------------- // Issue-mode chart sub-components // --------------------------------------------------------------------------- -const LegendItem: React.FC<{ label: string; value: number; color: string }> = ({ - label, - value, - color, -}) => { - const theme = useTheme(); - - return ( - - - - {label} - - - {value} - - - ); -}; - -const IssueCredibilityChart: React.FC<{ - solved: number; - open: number; - closed: number; - credibility: number; -}> = ({ solved, open, closed, credibility }) => { - const theme = useTheme(); - const hasIssueActivity = chartNumericSeriesHasPositive([ - solved, - open, - closed, - ]); - - const chartOption = useMemo( - () => ({ - ...echartsTransparentBackground(), - title: { - text: `${(credibility * 100).toFixed(0)}%`, - subtext: 'Credibility', - left: 'center', - top: '38%', - textStyle: { - color: theme.palette.text.primary, - fontSize: 28, - fontWeight: 'bold', - }, - subtextStyle: { - color: alpha(theme.palette.common.white, TEXT_OPACITY.muted), - fontSize: 11, - fontWeight: 500, - }, - }, - tooltip: { - trigger: 'item', - formatter: '{b}: {c} ({d}%)', - ...echartsItemTooltipChrome(theme), - }, - series: [ - { - name: 'Issue Status', - type: 'pie', - radius: ['58%', '72%'], - avoidLabelOverlap: false, - itemStyle: { - borderRadius: 6, - borderColor: 'transparent', - borderWidth: 3, - }, - label: { show: false, position: 'center' }, - emphasis: { label: { show: false }, scale: true, scaleSize: 5 }, - labelLine: { show: false }, - data: [ - { - value: solved, - name: 'Solved', - itemStyle: { color: CHART_COLORS.merged }, - }, - { - value: open, - name: 'Open', - itemStyle: { color: CHART_COLORS.open }, - }, - { - value: closed, - name: 'Closed', - itemStyle: { color: CHART_COLORS.closed }, - }, - ], - }, - ], - }), - [solved, open, closed, credibility, theme], - ); - - return ( - - - Issue Solve Ratio - - - - - - - - - - - - - - - ); -}; - const IssuePerformanceRadar: React.FC<{ credibility: number; solvedRatio: number; @@ -246,12 +74,12 @@ const IssuePerformanceRadar: React.FC<{ ...echartsRadarChrome(theme), indicator: [ { name: 'Credibility', max: 100 }, - { name: 'Solve\nRate', max: 100 }, - { name: 'Valid\nRate', max: 100 }, - { name: 'Volume', max: 100 }, - { name: 'Token\nScore', max: 100 }, + { name: 'Solve\nrate', max: 100 }, + { name: 'Valid\nrate', max: 100 }, + { name: 'Solve\nvolume', max: 100 }, + { name: 'Token\nscore', max: 100 }, // Keep max 100 like other spokes — ECharts radar mixes poorly with max: 1. - { name: 'Avg Repo\nWeight', max: 100 }, + { name: 'Repo\npayout', max: 100 }, ], center: ['50%', '50%'], radius: '50%', @@ -310,12 +138,22 @@ const IssuePerformanceRadar: React.FC<{ variant="monoSmall" sx={{ color: alpha(theme.palette.common.white, TEXT_OPACITY.muted), - mb: 2, + mb: 0.5, textAlign: 'center', }} > Discovery Profile + + Each axis scaled 0–100 vs the network's best + = ({ githubId, viewMode = 'prs', + rangeDays, }) => { const isIssueMode = viewMode === 'issues'; const { data: minerStats } = useMinerStats(githubId); @@ -378,15 +217,21 @@ const MinerActivity: React.FC = ({ const diffTime = Math.abs(today.getTime() - earliestDate.getTime()); const daysDiff = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - const daysToShow = Math.max(daysDiff, 1); + // Clamp the visible window to the selected range (1D/7D/30D); without a + // range, show the miner's full history as before. + const daysToShow = Math.min( + Math.max(daysDiff, 1), + rangeDays ?? Number.MAX_SAFE_INTEGER, + ); const dataMap = new Map(); for (let i = daysToShow; i >= 0; i--) { dataMap.set(format(subDays(today, i), 'yyyy-MM-dd'), 0); } + const windowDays = rangeDays ?? 30; let last30Count = 0; - const thirtyDaysAgo = subDays(today, 30); + const windowStart = subDays(today, windowDays); prs.forEach((pr) => { if (!pr.mergedAt) return; @@ -397,7 +242,7 @@ const MinerActivity: React.FC = ({ if (dataMap.has(dateStr)) { dataMap.set(dateStr, (dataMap.get(dateStr) || 0) + 1); } - if (date >= thirtyDaysAgo) last30Count++; + if (date >= windowStart) last30Count++; }); const data = Array.from(dataMap.entries()) @@ -416,7 +261,7 @@ const MinerActivity: React.FC = ({ contributionsLast30Days: last30Count, totalDaysShown: daysToShow, }; - }, [prs]); + }, [prs, rangeDays]); // PR-mode radar chart values (normalized to 100) const prRadarValues = useMemo(() => { @@ -613,11 +458,14 @@ const MinerActivity: React.FC = ({ justifyContent: 'center', }} > - @@ -662,7 +510,7 @@ const MinerActivity: React.FC = ({ data={contributionData} contributionsLast30Days={contributionsLast30Days} totalDaysShown={totalDaysShown} - subtitle="contribution(s) in the last 30 days" + subtitle={`contribution(s) in the last ${rangeDays ?? 30} days`} footerText="* Activity based on merged PRs in Gittensor-tracked repositories" bare selectedDate={selectedDate} @@ -685,11 +533,14 @@ const MinerActivity: React.FC = ({ justifyContent: 'center', }} > - diff --git a/src/components/miners/MinerIdentityRail.tsx b/src/components/miners/MinerIdentityRail.tsx deleted file mode 100644 index 6c91fcb1..00000000 --- a/src/components/miners/MinerIdentityRail.tsx +++ /dev/null @@ -1,463 +0,0 @@ -import React, { useMemo } from 'react'; -import { - alpha, - Avatar, - Box, - ButtonBase, - Card, - Chip, - Stack, - Typography, -} from '@mui/material'; -import { - Business as CompanyIcon, - GitHub as GitHubIcon, - Language as WebsiteIcon, - LocationOn as LocationIcon, - People as FollowersIcon, - Update as UpdateIcon, -} from '@mui/icons-material'; -import { - useMinerGithubData, - useMinerPRs, - useMinerStats, - type MinerRepositoryEvaluation, -} from '../../api'; -import { useClipboardCopy } from '../../hooks/useClipboardCopy'; -import { STATUS_COLORS } from '../../theme'; -import { getRepositoryOwnerAvatarSrc } from '../../utils/avatar'; -import { LoadingCard } from '../common'; -import MinerInsightsCard from './MinerInsightsCard'; - -type ViewMode = 'prs' | 'issues'; - -interface MinerIdentityRailProps { - githubId: string; - viewMode?: ViewMode; -} - -const COPY_FEEDBACK_MS = 1500; -const HOTKEY_VISIBLE_EDGE_CHARS = 5; - -const formatTimeAgo = (date: Date): string => { - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) { - const mins = diffMins % 60; - return mins > 0 ? `${diffHours}h ${mins}m ago` : `${diffHours}h ago`; - } - if (diffDays === 1) return '1 day ago'; - return `${diffDays} days ago`; -}; - -const formatHotkeyPreview = (hotkey: string): string => { - if (!hotkey) return ''; - if (hotkey.length <= HOTKEY_VISIBLE_EDGE_CHARS * 2 + 3) return hotkey; - return `${hotkey.slice(0, HOTKEY_VISIBLE_EDGE_CHARS)}...${hotkey.slice( - -HOTKEY_VISIBLE_EDGE_CHARS, - )}`; -}; - -/** Copy-to-clipboard hotkey button. Lifted verbatim from MinerScoreCard. */ -const CopyableHotkey: React.FC<{ hotkey: string }> = ({ hotkey }) => { - const { copied, copy, liveRegion } = useClipboardCopy({ - resetMs: COPY_FEEDBACK_MS, - copiedMessage: 'Hotkey copied to clipboard', - }); - - if (!hotkey) return null; - - const hotkeyTextSx = { - color: 'inherit', - fontSize: '0.62rem', - fontFamily: '"JetBrains Mono", monospace', - overflowWrap: 'anywhere', - lineHeight: 1, - } as const; - - return ( - <> - void copy(hotkey)} - aria-label="Copy hotkey" - disableRipple - sx={{ - display: 'inline-flex', - alignItems: 'center', - textAlign: 'left', - borderRadius: '4px', - lineHeight: 1, - p: 0, - m: 0, - maxWidth: '100%', - overflow: 'hidden', - color: (t) => - copied - ? t.palette.status.success - : alpha(t.palette.text.primary, 0.45), - transition: 'color 0.15s ease', - '&:hover': { - color: (t) => - copied - ? t.palette.status.success - : alpha(t.palette.text.primary, 0.8), - }, - '&:focus-visible': { - outline: (t) => `2px solid ${t.palette.primary.main}`, - outlineOffset: '2px', - }, - }} - > - - {copied ? '✓ Copied to clipboard' : formatHotkeyPreview(hotkey)} - - - {liveRegion} - - ); -}; - -/** Honest, counts-only standing summary — no averages or MAX rollups. */ -interface SummaryCounts { - repoCount: number; - ossEligible: number; - issueEligible: number; -} - -const computeSummaryCounts = ( - repositories: MinerRepositoryEvaluation[], -): SummaryCounts => ({ - repoCount: repositories.length, - ossEligible: repositories.filter((r) => r.isEligible).length, - issueEligible: repositories.filter((r) => r.isIssueEligible).length, -}); - -const SummaryRow: React.FC<{ - label: string; - value: string; - accent?: boolean; -}> = ({ label, value, accent }) => ( - - alpha(t.palette.text.primary, 0.6), - }} - > - {label} - - - {value} - - -); - -/** Compact rail card shell — transparent, hairline border, matches the page. */ -const RailCard: React.FC<{ - title: string; - children: React.ReactNode; - hint?: string; -}> = ({ title, children, hint }) => ( - - - {title} - - {hint && ( - alpha(t.palette.text.primary, 0.45), - mb: 1.5, - }} - > - {hint} - - )} - {children} - -); - -/** - * Left-rail content for the miner details page — the only genuinely - * miner-wide surfaces: identity, an honest counts-only standing summary, - * network-wide earnings, and per-repo-aware insights. - * - * Per-repo scoring means averaged/MAX rollups (a single credibility %, a - * SUM score) are misleading; this rail deliberately shows counts only. - */ -const MinerIdentityRail: React.FC = ({ - githubId, - viewMode = 'prs', -}) => { - const { data: minerStats, isLoading, error } = useMinerStats(githubId); - const { data: githubData } = useMinerGithubData(githubId); - const { data: prs } = useMinerPRs(githubId); - - const isIssueMode = viewMode === 'issues'; - const username = githubData?.login || prs?.[0]?.author || githubId; - - // Pre-migration placeholder rows carry an empty repo name — drop them so - // the counts only reflect real repositories. - const summary = useMemo(() => { - if (!minerStats) return null; - const repositories = (minerStats.repositories ?? []).filter( - (r) => r.repositoryFullName.trim().length > 0, - ); - return computeSummaryCounts(repositories); - }, [minerStats]); - - if (isLoading) { - return ; - } - - if (error || !minerStats || !summary) { - return ( - - - No data found for GitHub user: {githubId} - - - ); - } - - const repoNoun = summary.repoCount === 1 ? 'repository' : 'repositories'; - const eligibleCount = isIssueMode - ? summary.issueEligible - : summary.ossEligible; - const eligibleLabel = isIssueMode ? 'Issue-eligible' : 'PR-eligible'; - const usdPerDay = minerStats.usdPerDay ?? 0; - const lifetimeUsd = minerStats.lifetimeUsd ?? 0; - - return ( - - {/* ── Identity ─────────────────────────────────────────── */} - - - - - - {githubData?.name || username} - - - - @{username} - - - - - - - {githubData?.bio && ( - alpha(t.palette.text.primary, 0.7), - fontSize: '0.8rem', - mt: 1.25, - lineHeight: 1.5, - }} - > - {githubData.bio} - - )} - - {githubData && ( - - {githubData.company && ( - } - label={githubData.company} - size="small" - /> - )} - {githubData.location && ( - } - label={githubData.location} - size="small" - /> - )} - {githubData.blog && ( - } - label="Website" - clickable - size="small" - /> - )} - } - label={`${githubData.followers ?? 0} followers`} - size="small" - /> - - )} - - {minerStats.updatedAt && ( - alpha(t.palette.text.primary, 0.4), - }} - > - - - Updated {formatTimeAgo(new Date(minerStats.updatedAt))} - - - )} - - - {/* ── Standing summary — counts only ───────────────────── */} - - - - 0} - /> - - - - {/* ── Earnings — explicitly network-wide ───────────────── */} - - - 0 ? STATUS_COLORS.success : 'text.primary', - }} - > - ~${Math.round(usdPerDay).toLocaleString()} - - alpha(t.palette.text.primary, 0.5), - }} - > - /day - - - alpha(t.palette.text.primary, 0.45), - mt: 0.6, - }} - > - ~${Math.round(usdPerDay * 30).toLocaleString()}/mo · ~$ - {Math.round(lifetimeUsd).toLocaleString()} lifetime - - - - {/* ── Insights — per-repo-aware ────────────────────────── */} - - - ); -}; - -export default MinerIdentityRail; diff --git a/src/components/miners/MinerInsightsCard.tsx b/src/components/miners/MinerInsightsCard.tsx deleted file mode 100644 index 82bb84b5..00000000 --- a/src/components/miners/MinerInsightsCard.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import React, { useMemo } from 'react'; -import { Box, Card, Typography, alpha } from '@mui/material'; -import { - CheckCircle as AchievementIcon, - ErrorOutline as WarningIcon, - Lightbulb as TipIcon, -} from '@mui/icons-material'; -import { useMinerStats } from '../../api'; -import type { MinerRepositoryEvaluation } from '../../api/models/Dashboard'; -import { STATUS_COLORS } from '../../theme'; - -interface MinerInsightsCardProps { - githubId: string; - viewMode?: 'prs' | 'issues'; -} - -type InsightType = 'warning' | 'tip' | 'achievement'; - -interface InsightItem { - id: string; - type: InsightType; - title: string; - description: string; - priority: number; -} - -/** Per-repo eligibility / score / credibility accessors, mode-aware. */ -const eligibleFor = ( - repo: MinerRepositoryEvaluation, - isIssueMode: boolean, -): boolean => (isIssueMode ? repo.isIssueEligible : repo.isEligible); - -const scoreFor = ( - repo: MinerRepositoryEvaluation, - isIssueMode: boolean, -): number => (isIssueMode ? repo.issueDiscoveryScore : repo.totalScore); - -const credibilityFor = ( - repo: MinerRepositoryEvaluation, - isIssueMode: boolean, -): number => (isIssueMode ? repo.issueCredibility : repo.credibility); - -/** - * Build per-repository insights. Each insight names the repository it - * concerns. Eligibility verdicts come straight from the server-computed - * `isEligible` / `isIssueEligible` flags — each repo applies its own - * configurable gate, so no fixed threshold is ever printed here. - */ -const buildInsights = ( - repositories: MinerRepositoryEvaluation[], - isIssueMode: boolean, -): InsightItem[] => { - const assembled: InsightItem[] = []; - - if (repositories.length === 0) { - assembled.push({ - id: 'no-repos', - type: 'tip', - title: 'No repository evaluations yet', - description: isIssueMode - ? 'Discover and solve issues in tracked repositories to start earning a per-repository standing.' - : 'Open and merge pull requests in tracked repositories to start earning a per-repository standing.', - priority: 40, - }); - return assembled; - } - - const eligibleRepos = repositories.filter((r) => eligibleFor(r, isIssueMode)); - const ineligibleRepos = repositories.filter( - (r) => !eligibleFor(r, isIssueMode), - ); - - // Ineligible repos with a server-supplied reason — name the repo + reason. - ineligibleRepos - .filter((r) => r.failedReason) - .slice(0, 2) - .forEach((repo, index) => { - assembled.push({ - id: `ineligible-${repo.repositoryFullName}`, - type: 'warning', - title: `Ineligible in ${repo.repositoryFullName}`, - description: `${repo.failedReason} Credibility in this repository is ${( - credibilityFor(repo, isIssueMode) * 100 - ).toFixed(1)}%.`, - priority: 95 - index, - }); - }); - - // Ineligible with no reason string — generic prompt, still repo-named. - const unexplainedIneligible = ineligibleRepos.find((r) => !r.failedReason); - if (unexplainedIneligible && assembled.length < 3) { - assembled.push({ - id: `ineligible-generic-${unexplainedIneligible.repositoryFullName}`, - type: 'warning', - title: `Not yet eligible in ${unexplainedIneligible.repositoryFullName}`, - description: isIssueMode - ? "Raise issue credibility and solved-issue volume to clear this repository's eligibility gate." - : "Raise merge credibility and contribution volume to clear this repository's eligibility gate.", - priority: 80, - }); - } - - // Strongest eligible repo — an achievement that names where the miner leads. - if (eligibleRepos.length > 0) { - const topRepo = eligibleRepos.reduce((best, current) => - scoreFor(current, isIssueMode) > scoreFor(best, isIssueMode) - ? current - : best, - ); - assembled.push({ - id: `top-repo-${topRepo.repositoryFullName}`, - type: 'achievement', - title: `Strongest in ${topRepo.repositoryFullName}`, - description: `${ - isIssueMode ? 'Issue-discovery' : 'OSS' - } score here is ${scoreFor(topRepo, isIssueMode).toFixed(2)} with ${( - credibilityFor(topRepo, isIssueMode) * 100 - ).toFixed(1)}% credibility. Keep this consistency to maximize earnings.`, - priority: 55, - }); - } - - // Eligible-everywhere achievement, or a coverage tip. - if (eligibleRepos.length === repositories.length) { - assembled.push({ - id: 'eligible-all', - type: 'achievement', - title: 'Eligible across every repository', - description: `You clear the gate in all ${repositories.length} evaluated ${ - repositories.length === 1 ? 'repository' : 'repositories' - }.`, - priority: 45, - }); - } else if (eligibleRepos.length > 0) { - assembled.push({ - id: 'coverage-tip', - type: 'tip', - title: 'Expand eligible coverage', - description: `You are eligible in ${eligibleRepos.length} of ${ - repositories.length - } repositories. Lifting credibility in the rest unlocks more of the network reward pool.`, - priority: 35, - }); - } - - return assembled; -}; - -const getInsightStyle = (type: InsightType) => { - switch (type) { - case 'warning': - return { - color: STATUS_COLORS.warningOrange, - border: alpha(STATUS_COLORS.warningOrange, 0.3), - background: alpha(STATUS_COLORS.warningOrange, 0.09), - icon: , - }; - case 'achievement': - return { - color: STATUS_COLORS.success, - border: alpha(STATUS_COLORS.success, 0.35), - background: alpha(STATUS_COLORS.success, 0.1), - icon: , - }; - default: - return { - color: STATUS_COLORS.info, - border: alpha(STATUS_COLORS.info, 0.35), - background: alpha(STATUS_COLORS.info, 0.1), - icon: , - }; - } -}; - -/** - * Per-repository-aware insights, rendered as a compact rail strip. Each - * actionable item names the repository it concerns — per-repo gates make a - * single miner-wide verdict meaningless. - */ -const MinerInsightsCard: React.FC = ({ - githubId, - viewMode = 'prs', -}) => { - const { data: minerStats } = useMinerStats(githubId); - const isIssueMode = viewMode === 'issues'; - - const insights = useMemo(() => { - if (!minerStats) return []; - // Pre-migration placeholder rows carry an empty repo name — drop them. - const repositories = (minerStats.repositories ?? []).filter( - (r) => r.repositoryFullName.trim().length > 0, - ); - return buildInsights(repositories, isIssueMode) - .sort((a, b) => b.priority - a.priority) - .slice(0, 3); - }, [minerStats, isIssueMode]); - - if (!minerStats || insights.length === 0) return null; - - return ( - - - {isIssueMode ? 'Issue discovery insights' : 'Insights & next actions'} - - - - {insights.map((insight) => { - const style = getInsightStyle(insight.type); - return ( - - - {style.icon} - - - - {insight.title} - - alpha(t.palette.text.primary, 0.68), - fontSize: '0.78rem', - mt: 0.35, - lineHeight: 1.45, - wordBreak: 'break-word', - }} - > - {insight.description} - - - - ); - })} - - - ); -}; - -export default MinerInsightsCard; diff --git a/src/components/miners/MinerNextSteps.tsx b/src/components/miners/MinerNextSteps.tsx new file mode 100644 index 00000000..e17bd51b --- /dev/null +++ b/src/components/miners/MinerNextSteps.tsx @@ -0,0 +1,215 @@ +import React, { useMemo, useState } from 'react'; +import { alpha, Box, ButtonBase, Card, Typography } from '@mui/material'; +import { + CheckCircleOutlined as GoodIcon, + ErrorOutline as CriticalIcon, + LightbulbOutlined as TipIcon, + PlaylistAddCheckOutlined as NextStepsIcon, + WarningAmberOutlined as WarningIcon, +} from '@mui/icons-material'; +import { + useMinerPRs, + useMinerStats, + useReposAndWeights, + type RepositoryConfig, +} from '../../api'; +import { STATUS_COLORS } from '../../theme'; +import { buildRepoWeightsMap } from '../../utils/ExplorerUtils'; +import { + buildEligibilitySteps, + buildLeverSteps, + mergeNextSteps, + type NextStep, + type NextStepTone, +} from '../../utils/minerNextSteps'; + +interface MinerNextStepsProps { + githubId: string; +} + +/** How many steps to show before the "show all" toggle (2-up grid → 2 rows). */ +const COLLAPSED_COUNT = 4; + +const TONE_COLOR: Record = { + critical: STATUS_COLORS.error, + warning: STATUS_COLORS.warningOrange, + tip: STATUS_COLORS.info, + good: STATUS_COLORS.success, +}; + +const TONE_ICON: Record = { + critical: , + warning: , + tip: , + good: , +}; + +const StepRow: React.FC<{ step: NextStep }> = ({ step }) => { + const color = TONE_COLOR[step.tone]; + return ( + + {TONE_ICON[step.tone]} + + + {step.title} + + alpha(t.palette.text.primary, 0.68), + fontSize: '0.78rem', + mt: 0.35, + lineHeight: 1.45, + wordBreak: 'break-word', + }} + > + {step.action} + + + + ); +}; + +/** + * "What to do next" — the dashboard's single prioritised action list. Merges + * the score-lever, eligibility and open-PR-risk guidance that used to be spread + * across three sections into one severity-ranked set of steps, each linking to + * the section a miner should act in. + */ +const MinerNextSteps: React.FC = ({ githubId }) => { + const { data: minerStats } = useMinerStats(githubId); + const { data: prs } = useMinerPRs(githubId); + const { data: repos } = useReposAndWeights(); + const [showAll, setShowAll] = useState(false); + + const configByRepo = useMemo(() => { + const map = new Map(); + (repos ?? []).forEach((r) => map.set(r.fullName.toLowerCase(), r.config)); + return map; + }, [repos]); + + // Mode-independent on purpose: the next-steps list summarises both tracks at + // once (the OSS/Discovery toggle below only governs the repositories table and + // contribution tabs). Discovery steps are added only when the miner has issue + // activity, and are namespaced/tagged so they don't collide with OSS steps. + const steps = useMemo(() => { + if (!minerStats) return []; + const repositories = (minerStats.repositories ?? []).filter( + (r) => r.repositoryFullName.trim().length > 0, + ); + const weights = repos + ? buildRepoWeightsMap(repos) + : new Map(); + const hasDiscovery = repositories.some( + (r) => + (r.totalSolvedIssues ?? 0) + + (r.totalOpenIssues ?? 0) + + (r.totalClosedIssues ?? 0) > + 0, + ); + return mergeNextSteps( + buildLeverSteps(minerStats, prs, configByRepo), + buildEligibilitySteps(repositories, false, weights), + hasDiscovery + ? buildEligibilitySteps(repositories, true, weights, { + idPrefix: 'disc-', + trackTag: 'Disc', + }) + : [], + ); + }, [minerStats, prs, repos, configByRepo]); + + if (!minerStats || steps.length === 0) return null; + + const visible = showAll ? steps : steps.slice(0, COLLAPSED_COUNT); + const hiddenCount = steps.length - visible.length; + + return ( + + + + What to do next + + highest impact first + + + + + {visible.map((step) => ( + + ))} + + + {(hiddenCount > 0 || showAll) && steps.length > COLLAPSED_COUNT && ( + setShowAll((p) => !p)} + disableRipple + sx={{ + mt: 1.25, + fontSize: '0.74rem', + fontWeight: 600, + color: 'primary.main', + alignSelf: 'flex-start', + '&:focus-visible': { + outline: (t) => `2px solid ${t.palette.primary.main}`, + outlineOffset: '2px', + }, + }} + > + {showAll ? 'Show fewer' : `Show ${hiddenCount} more`} + + )} + + ); +}; + +export default MinerNextSteps; diff --git a/src/components/miners/MinerOpenDiscoveryIssuesByRepo.tsx b/src/components/miners/MinerOpenDiscoveryIssuesByRepo.tsx index ba9821e1..57912ca8 100644 --- a/src/components/miners/MinerOpenDiscoveryIssuesByRepo.tsx +++ b/src/components/miners/MinerOpenDiscoveryIssuesByRepo.tsx @@ -20,25 +20,27 @@ import { isIssueStatusFilter, isOutsideScoringWindow, minerPrPath, - paginateItems, type IssueStatusFilter, } from '../../utils'; import { DataTable, type DataTableColumn, } from '../../components/common/DataTable'; -import FilterButton from '../FilterButton'; +import SegmentedToggle from '../common/SegmentedToggle'; import { ClearSearchAdornment } from '../common/ClearSearchAdornment'; import { LoadingCard } from '../common'; -import TablePagination from '../common/TablePagination'; +import TablePagination, { + DEFAULT_MINER_EXPLORER_ROWS, + getMinerExplorerPaging, + type MinerExplorerRowsOption, +} from '../common/TablePagination'; +import MinerTableRowsSelect from './MinerTableRowsSelect'; import { tooltipSlotProps } from '../../theme'; import { useDataTableParams } from '../../hooks/useDataTableParams'; type IssueSortField = 'number' | 'repository' | 'date'; type SortDir = 'asc' | 'desc'; -const PAGE_SIZE = 20; - const DEFAULT_SORT_DIR: Record = { number: 'desc', repository: 'asc', @@ -85,20 +87,38 @@ const getIssueDate = (issue: MinerIssue): string => interface MinerOpenDiscoveryIssuesByRepoProps { githubId: string; + /** When set, only issues active within the last N days are shown (driven by + * the dashboard's 1D/7D/30D range switch). */ + rangeDays?: number; } const MinerOpenDiscoveryIssuesByRepo: React.FC< MinerOpenDiscoveryIssuesByRepoProps -> = ({ githubId }) => { +> = ({ githubId, rangeDays }) => { const theme = useTheme(); const navigate = useNavigate(); // Pin the `since` cutoff per mount so React Query's cache key stays stable // while the component is open. The 35-day scoring window slides on remount. const since = useMemo(() => getScoringWindowStartIso(), []); const { data: issues, isLoading } = useMinerIssues(githubId, true, since); + + // Range filter — applied before status/search filters and the status counts. + const rangedIssues = useMemo(() => { + if (!issues) return issues; + if (!rangeDays) return issues; + const cutoff = Date.now() - rangeDays * 86_400_000; + return issues.filter((issue) => { + const iso = getIssueDate(issue); + const t = iso ? Date.parse(iso) : NaN; + return Number.isFinite(t) ? t >= cutoff : true; + }); + }, [issues, rangeDays]); const [searchQuery, setSearchQuery] = useState(''); const [sortField, setSortField] = useState('date'); const [sortDir, setSortDir] = useState('desc'); + const [rowsPerPage, setRowsPerPage] = useState( + DEFAULT_MINER_EXPLORER_ROWS, + ); const filtersConfig = useMemo( () => ({ @@ -151,8 +171,8 @@ const MinerOpenDiscoveryIssuesByRepo: React.FC< }, [githubId]); const filteredIssues = useMemo( - () => filterIssues(issues ?? [], { statusFilter, searchQuery }), - [issues, statusFilter, searchQuery], + () => filterIssues(rangedIssues ?? [], { statusFilter, searchQuery }), + [rangedIssues, statusFilter, searchQuery], ); const sortedIssues = useMemo(() => { @@ -176,23 +196,26 @@ const MinerOpenDiscoveryIssuesByRepo: React.FC< return sorted; }, [filteredIssues, sortField, sortDir]); - const pagedIssues = useMemo( - () => paginateItems(sortedIssues, page, PAGE_SIZE), - [sortedIssues, page], + const { + slice: pagedIssues, + totalPages, + safePage, + showPageNav, + } = useMemo( + () => getMinerExplorerPaging(sortedIssues, page, rowsPerPage), + [sortedIssues, page, rowsPerPage], ); - const totalPages = Math.ceil(sortedIssues.length / PAGE_SIZE); - // Count over the search scope (excluding the active status filter) so each // button reflects what the user would see if they clicked it. const statusCounts = useMemo(() => { - if (!issues) return { all: 0, open: 0, solved: 0, closed: 0 }; - const scope = filterIssues(issues, { + if (!rangedIssues) return { all: 0, open: 0, solved: 0, closed: 0 }; + const scope = filterIssues(rangedIssues, { statusFilter: 'all', searchQuery, }); return getIssueStatusCounts(scope); - }, [issues, searchQuery]); + }, [rangedIssues, searchQuery]); const hasFilters = statusFilter !== 'all' || searchQuery.trim() !== ''; @@ -399,95 +422,84 @@ const MinerOpenDiscoveryIssuesByRepo: React.FC< width: { xs: '100%', sm: 'auto' }, }} > - .MuiButton-root': { - flex: { xs: 1, sm: 'none' }, - minWidth: 0, - }, - }} - > - setStatusFilter('all')} - /> - setStatusFilter('open')} - /> - setStatusFilter('solved')} - /> - setStatusFilter('closed')} - /> - + - { - setSearchQuery(e.target.value); - setPage(0); - }} - InputProps={{ - startAdornment: ( - - alpha(t.palette.text.primary, 0.3), - fontSize: '1rem', - }} - /> - - ), - endAdornment: ( - { - setSearchQuery(''); - setPage(0); - }} - /> - ), - }} + + > + { + setRowsPerPage(next); + setPage(0); + }} + id="miner-issues-rows" + /> + { + setSearchQuery(e.target.value); + setPage(0); + }} + InputProps={{ + startAdornment: ( + + alpha(t.palette.text.primary, 0.3), + fontSize: '1rem', + }} + /> + + ), + endAdornment: ( + { + setSearchQuery(''); + setPage(0); + }} + /> + ), + }} + sx={{ + flex: '0 1 320px', + minWidth: 0, + '& .MuiOutlinedInput-root': { + fontSize: '0.8rem', + color: 'text.primary', + backgroundColor: 'surface.subtle', + borderRadius: 2, + '& fieldset': { borderColor: 'border.light' }, + '&:hover fieldset': { borderColor: 'border.medium' }, + '&.Mui-focused fieldset': { borderColor: 'primary.main' }, + }, + }} + /> + ); @@ -540,11 +552,13 @@ const MinerOpenDiscoveryIssuesByRepo: React.FC< onChange: handleSort, }} pagination={ - + showPageNav ? ( + + ) : null } /> diff --git a/src/components/miners/MinerPRsTable.tsx b/src/components/miners/MinerPRsTable.tsx index 60b66784..17b2f100 100644 --- a/src/components/miners/MinerPRsTable.tsx +++ b/src/components/miners/MinerPRsTable.tsx @@ -23,7 +23,12 @@ import { ExpandMore as ExpandMoreIcon, Search as SearchIcon, } from '@mui/icons-material'; -import { useMinerPRs, type CommitLog } from '../../api'; +import { + useMinerPRs, + useReposAndWeights, + type CommitLog, + type RepositoryConfig, +} from '../../api'; import { filterPrs, getRepositoryOwnerAvatarSrc, @@ -36,7 +41,7 @@ import { DataTable, type DataTableColumn, } from '../../components/common/DataTable'; -import FilterButton from '../FilterButton'; +import SegmentedToggle from '../common/SegmentedToggle'; import { ClearSearchAdornment } from '../common/ClearSearchAdornment'; import { DebouncedSearchInput } from '../common/DebouncedSearchInput'; import { LoadingCard, WatchlistButton } from '../../components/common'; @@ -52,9 +57,13 @@ import TablePagination, { MINER_EXPLORER_PAGE_PARAM, useMinerExplorerPagination, } from '../common/TablePagination'; -import { formatDate } from '../../utils/format'; import { tooltipSlotProps } from '../../theme'; import MinerPrScoreDetail from './MinerPrScoreDetail'; +import PRTimeDecayChart from '../prs/PRTimeDecayChart'; +import { + buildDecayProjection, + resolveDecayParams, +} from '../prs/prTimeDecayModel'; type PrSortField = | 'number' @@ -105,12 +114,59 @@ const prRowKey = (pr: CommitLog): string => interface MinerPRsTableProps { githubId: string; + /** When set, only PRs whose merge/close/create date falls within the last + * N days are shown (driven by the dashboard's 1D/7D/30D range switch). */ + rangeDays?: number; } -const MinerPRsTable: React.FC = ({ githubId }) => { +const MinerPRsTable: React.FC = ({ + githubId, + rangeDays, +}) => { const theme = useTheme(); const { data: prs, isLoading } = useMinerPRs(githubId); + + // Range filter — applied before every other filter and the status counts so + // the whole table reflects the selected window. + const rangedPrs = useMemo(() => { + if (!prs) return prs; + if (!rangeDays) return prs; + const cutoff = Date.now() - rangeDays * 86_400_000; + return prs.filter((pr) => { + const iso = pr.mergedAt ?? pr.closedAt ?? pr.prCreatedAt; + const t = iso ? Date.parse(iso) : NaN; + return Number.isFinite(t) ? t >= cutoff : true; + }); + }, [prs, rangeDays]); + const { data: repos } = useReposAndWeights(); const { isWatched } = useWatchlist('prs'); + + // Per-repo config drives each PR's time-decay curve (resolved client-side, no + // extra fetch) so the table can show how much a merged PR has decayed. + const configByRepo = useMemo(() => { + const map = new Map(); + (repos ?? []).forEach((r) => map.set(r.fullName.toLowerCase(), r.config)); + return map; + }, [repos]); + const decayMultiplierFor = useCallback( + (pr: CommitLog): number | null => { + if (pr.prState !== 'MERGED' || !pr.mergedAt) return null; + const params = resolveDecayParams( + null, + configByRepo.get((pr.repository || '').toLowerCase()), + ); + return buildDecayProjection( + { + mergedAt: pr.mergedAt, + prState: pr.prState, + timeDecayMultiplier: pr.timeDecayMultiplier, + earnedScore: pr.score, + }, + params, + ).chartNowMultiplier; + }, + [configByRepo], + ); const [selectedAuthor, setSelectedAuthor] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [sortField, setSortField] = useState('date'); @@ -162,13 +218,13 @@ const MinerPRsTable: React.FC = ({ githubId }) => { const filteredPRs = useMemo( () => - filterPrs(prs ?? [], { + filterPrs(rangedPrs ?? [], { author: selectedAuthor, includeNumber: true, searchQuery, statusFilter, }), - [prs, selectedAuthor, statusFilter, searchQuery], + [rangedPrs, selectedAuthor, statusFilter, searchQuery], ); const sortedPRs = useMemo(() => { @@ -247,15 +303,15 @@ const MinerPRsTable: React.FC = ({ githubId }) => { // Count over the search + author scope (excluding the active status filter) // so each button reflects what the user would see if they clicked it. const statusCounts = useMemo(() => { - if (!prs) return { all: 0, open: 0, merged: 0, closed: 0 }; - const scope = filterPrs(prs, { + if (!rangedPrs) return { all: 0, open: 0, merged: 0, closed: 0 }; + const scope = filterPrs(rangedPrs, { author: selectedAuthor, includeNumber: true, searchQuery, statusFilter: 'all', }); return getPrStatusCounts(scope); - }, [prs, selectedAuthor, searchQuery]); + }, [rangedPrs, selectedAuthor, searchQuery]); const hasFilters = Boolean(selectedAuthor) || @@ -457,6 +513,31 @@ const MinerPRsTable: React.FC = ({ githubId }) => { Collateral )} + {pr.mergedAt && + (() => { + const mult = decayMultiplierFor(pr); + if (mult == null) return null; + const color = + mult >= 0.7 + ? theme.palette.status.success + : mult >= 0.4 + ? theme.palette.status.warning + : theme.palette.status.warningOrange; + return ( + + + decay {mult.toFixed(2)}× + + + ); + })()} ); }, @@ -473,7 +554,7 @@ const MinerPRsTable: React.FC = ({ githubId }) => { }, renderCell: (pr) => pr.mergedAt - ? formatDate(pr.mergedAt) + ? new Date(pr.mergedAt).toLocaleDateString() : pr.prState === 'CLOSED' ? 'Closed' : 'Open', @@ -557,47 +638,17 @@ const MinerPRsTable: React.FC = ({ githubId }) => { /> )} - .MuiButton-root': { - flex: { xs: 1, sm: 'none' }, - minWidth: 0, - }, - }} - > - setStatusFilter('all')} - /> - setStatusFilter('open')} - /> - setStatusFilter('merged')} - /> - setStatusFilter('closed')} - /> - + @@ -607,6 +658,7 @@ const MinerPRsTable: React.FC = ({ githubId }) => { display: 'flex', flexDirection: 'row', alignItems: 'center', + justifyContent: 'space-between', flexWrap: 'nowrap', gap: 2, width: '100%', @@ -647,10 +699,8 @@ const MinerPRsTable: React.FC = ({ githubId }) => { ), }} sx={{ - flex: 1, + flex: '0 1 320px', minWidth: 0, - width: 'auto', - maxWidth: { xs: '100%', sm: 480 }, '& .MuiOutlinedInput-root': { fontSize: '0.8rem', color: 'text.primary', @@ -708,6 +758,17 @@ const MinerPRsTable: React.FC = ({ githubId }) => { const open = expandedKeys.has(prRowKey(pr)); return ( + {pr.prState === 'MERGED' && pr.mergedAt && ( + + + + )} ); diff --git a/src/components/miners/MinerProfileHero.tsx b/src/components/miners/MinerProfileHero.tsx new file mode 100644 index 00000000..7eebb5b7 --- /dev/null +++ b/src/components/miners/MinerProfileHero.tsx @@ -0,0 +1,544 @@ +import React, { useMemo } from 'react'; +import { + alpha, + Avatar, + Box, + ButtonBase, + Card, + CircularProgress, + Typography, +} from '@mui/material'; +import { + Business as CompanyIcon, + GitHub as GitHubIcon, + Language as WebsiteIcon, + LocationOn as LocationIcon, + People as FollowersIcon, + Update as UpdateIcon, +} from '@mui/icons-material'; +import { + useMinerGithubData, + useMinerPRs, + useMinerStats, + type CommitLog, +} from '../../api'; +import { useClipboardCopy } from '../../hooks/useClipboardCopy'; +import { formatRelativeTimeAgo } from '../../utils/format'; +import { LEADERBOARD_TRACK_COLORS, STATUS_COLORS } from '../../theme'; +import { getRepositoryOwnerAvatarSrc } from '../../utils/avatar'; +import { deriveMinerStatus, StatusBadge } from '../leaderboard/MinerStatus'; +import type { MinerActivity } from '../leaderboard/useMinerActivityIndex'; + +interface MinerProfileHeroProps { + githubId: string; + /** Optional action affordance pinned to the card's top-right (e.g. watch star). */ + action?: React.ReactNode; +} + +const COPY_FEEDBACK_MS = 1500; +const HOTKEY_VISIBLE_EDGE_CHARS = 6; +const MS_PER_DAY = 86_400_000; + +const startOfUtcDay = (ms: number): number => { + const d = new Date(ms); + d.setUTCHours(0, 0, 0, 0); + return d.getTime(); +}; + +const formatHotkeyPreview = (hotkey: string): string => { + if (!hotkey) return ''; + if (hotkey.length <= HOTKEY_VISIBLE_EDGE_CHARS * 2 + 3) return hotkey; + return `${hotkey.slice(0, HOTKEY_VISIBLE_EDGE_CHARS)}…${hotkey.slice( + -HOTKEY_VISIBLE_EDGE_CHARS, + )}`; +}; + +/** + * Build the last-30-day `dailyMerged` series the momentum status derivation + * needs, straight from the miner's own PRs (the network activity index only + * covers the 500 most-recent commits and may miss this miner). + */ +const buildActivityFromPrs = (prs: CommitLog[] | undefined): MinerActivity => { + const lookbackDays = 30; + const today = startOfUtcDay(Date.now()); + const windowStart = today - (lookbackDays - 1) * MS_PER_DAY; + const dailyMerged = new Array(lookbackDays).fill(0); + let lastActiveAt: string | null = null; + (prs ?? []).forEach((pr) => { + if (pr.mergedAt) { + const t = Date.parse(pr.mergedAt); + if (Number.isFinite(t) && t >= windowStart) { + const slot = Math.round((startOfUtcDay(t) - windowStart) / MS_PER_DAY); + if (slot >= 0 && slot < lookbackDays) dailyMerged[slot] += 1; + } + } + const iso = pr.mergedAt ?? pr.prCreatedAt ?? null; + if (iso && (!lastActiveAt || Date.parse(iso) > Date.parse(lastActiveAt))) { + lastActiveAt = iso; + } + }); + return { + dailyMerged, + dailyOss: [], + dailyDiscovery: [], + topRepos: [], + lastActiveAt, + reviewHits: 0, + }; +}; + +/** Copy-to-clipboard hotkey, monospace with truncated preview. */ +const CopyableHotkey: React.FC<{ hotkey: string }> = ({ hotkey }) => { + const { copied, copy, liveRegion } = useClipboardCopy({ + resetMs: COPY_FEEDBACK_MS, + copiedMessage: 'Hotkey copied to clipboard', + }); + if (!hotkey) return null; + return ( + <> + void copy(hotkey)} + aria-label="Copy hotkey" + disableRipple + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: 0.5, + px: 0.75, + py: 0.25, + borderRadius: 1, + border: '1px solid', + borderColor: 'border.light', + backgroundColor: 'transparent', + fontSize: '0.66rem', + lineHeight: 1, + color: (t) => + copied + ? t.palette.status.success + : alpha(t.palette.text.primary, 0.6), + transition: 'color 0.15s ease, border-color 0.15s ease', + '&:hover': { + borderColor: 'border.medium', + color: (t) => + copied + ? t.palette.status.success + : alpha(t.palette.text.primary, 0.9), + }, + '&:focus-visible': { + outline: (t) => `2px solid ${t.palette.primary.main}`, + outlineOffset: '2px', + }, + }} + > + + {copied ? '✓ Copied' : formatHotkeyPreview(hotkey)} + + + {liveRegion} + + ); +}; + +/** Thin middot divider between inline metadata items. */ +const SepDot: React.FC = () => ( + +); + +/** One muted metadata item — icon + text, rendered as a link when `href` set. */ +const MetaItem: React.FC<{ + icon: React.ReactNode; + href?: string; + children: React.ReactNode; +}> = ({ icon, href, children }) => { + const body = ( + + + {icon} + + alpha(t.palette.text.primary, 0.6), + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {children} + + + ); + if (!href) return body; + return ( + + {body} + + ); +}; + +/** Compact eligibility standing pill — "OSS 4 / 15". */ +const EligibilityPill: React.FC<{ + label: string; + eligible: number; + total: number; + color: string; +}> = ({ label, eligible, total, color }) => { + const active = eligible > 0; + return ( + + + alpha(t.palette.text.primary, 0.7), + whiteSpace: 'nowrap', + }} + > + {label} + + + {eligible} + + {' '} + / {total} + + + + ); +}; + +/** + * Identity header for the miner performance dashboard. A single, flat card: + * avatar + name + handle on the left, a muted GitHub-metadata line beneath the + * bio, and the last-updated timestamp tucked top-right. Deliberately about + * identity — the performance numbers (eligibility included) live in the stat + * band below. + */ +const MinerProfileHero: React.FC = ({ + githubId, + action, +}) => { + const { data: minerStats, isLoading, error } = useMinerStats(githubId); + const { data: githubData } = useMinerGithubData(githubId); + const { data: prs } = useMinerPRs(githubId); + + const username = githubData?.login || prs?.[0]?.author || githubId; + + const repoCount = useMemo( + () => + (minerStats?.repositories ?? []).filter( + (r) => r.repositoryFullName.trim().length > 0, + ).length, + [minerStats], + ); + + const status = useMemo(() => { + if (!minerStats) return null; + return deriveMinerStatus(minerStats, buildActivityFromPrs(prs)); + }, [minerStats, prs]); + + if (isLoading) { + return ( + + + + ); + } + + if (error || !minerStats) { + return ( + + + No data found for GitHub user: {githubId} + + + ); + } + + // Muted GitHub-metadata line — only the fields the profile actually has. + const metaItems: { key: string; node: React.ReactNode }[] = []; + if (githubData) { + metaItems.push({ + key: 'followers', + node: ( + }> + {(githubData.followers ?? 0).toLocaleString()} followers + + ), + }); + if (githubData.company) { + metaItems.push({ + key: 'company', + node: }>{githubData.company}, + }); + } + if (githubData.location) { + metaItems.push({ + key: 'location', + node: ( + }>{githubData.location} + ), + }); + } + if (githubData.blog) { + const href = githubData.blog.startsWith('http') + ? githubData.blog + : `https://${githubData.blog}`; + metaItems.push({ + key: 'website', + node: ( + } href={href}> + Website + + ), + }); + } + } + + return ( + + + {/* ── Identity ─────────────────────────────────────────── */} + + + + {/* Name + momentum status + last-updated */} + + + {githubData?.name || username} + + {status && } + + {(minerStats.updatedAt || action) && ( + + {minerStats.updatedAt && ( + alpha(t.palette.text.primary, 0.4), + }} + > + + + Updated {formatRelativeTimeAgo(minerStats.updatedAt)} + + + )} + {action} + + )} + + + {/* Handle · UID · hotkey */} + + + @{username} + + + + UID {minerStats.uid} + + + + + {/* Bio */} + {githubData?.bio && ( + alpha(t.palette.text.primary, 0.7), + fontSize: '0.82rem', + mt: 0.85, + lineHeight: 1.45, + maxWidth: 560, + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + }} + > + {githubData.bio} + + )} + + {/* Muted GitHub metadata */} + {metaItems.length > 0 && ( + + {metaItems.map((item, i) => ( + + {i > 0 && } + {item.node} + + ))} + + )} + + {/* Eligibility standing — repos with earning unlocked, per track */} + + + + + + + + ); +}; + +export default MinerProfileHero; diff --git a/src/components/miners/MinerRepoStandingCard.tsx b/src/components/miners/MinerRepoStandingCard.tsx deleted file mode 100644 index dad7e6ff..00000000 --- a/src/components/miners/MinerRepoStandingCard.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import React from 'react'; -import { alpha, Avatar, Box, Card, Tooltip, Typography } from '@mui/material'; -import type { MinerRepositoryEvaluation } from '../../api/models/Dashboard'; -import { tooltipSlotProps } from '../../theme'; -import { credibilityColor } from '../../utils/format'; -import { getRepositoryOwnerAvatarSrc } from '../../utils/avatar'; -import { minerRepositoryPath } from '../../utils'; -import { linkResetSx, useLinkBehavior } from '../common/linkBehavior'; - -type ViewMode = 'prs' | 'issues'; - -interface MinerRepoStandingCardProps { - repo: MinerRepositoryEvaluation; - viewMode: ViewMode; -} - -const FONT_MONO = '"JetBrains Mono", monospace'; -const TABULAR_NUMS = '"tnum" 1, "ss01" 1'; -const INACTIVE_OPACITY = 0.42; - -/** Build the link to a repository's Miners tab. */ -const repoMinersHref = (repositoryFullName: string): string => - minerRepositoryPath(repositoryFullName, { tab: 'miners' }); - -/* ── Eligibility marker — dot + word, echoes leaderboard MinerCard ─── */ -const EligibilityLabel: React.FC<{ eligible: boolean }> = ({ eligible }) => ( - - ({ - width: 5, - height: 5, - borderRadius: '50%', - backgroundColor: eligible - ? theme.palette.status.merged - : alpha(theme.palette.text.tertiary, 0.6), - flexShrink: 0, - })} - /> - ({ - fontFamily: FONT_MONO, - fontSize: '0.7rem', - fontWeight: 500, - color: eligible - ? theme.palette.text.primary - : theme.palette.text.tertiary, - letterSpacing: '0.01em', - lineHeight: 1, - whiteSpace: 'nowrap', - })} - > - {eligible ? 'Eligible' : 'Ineligible'} - - -); - -/* ── Section overline ─────────────────────────────────────────────── */ -const Overline: React.FC<{ - children: React.ReactNode; - align?: 'left' | 'right'; -}> = ({ children, align = 'left' }) => ( - ({ - fontFamily: FONT_MONO, - fontSize: '0.58rem', - fontWeight: 500, - color: theme.palette.text.secondary, - textTransform: 'uppercase', - letterSpacing: '0.16em', - lineHeight: 1, - textAlign: align, - })} - > - {children} - -); - -/* ── Inline stat: "Merged 142" with subtle separator dot ──────────── */ -const InlineStat: React.FC<{ - label: string; - value: number; - isLast: boolean; - eligible: boolean; -}> = ({ label, value, isLast, eligible }) => ( - - ({ - fontFamily: FONT_MONO, - fontSize: '0.62rem', - fontWeight: 500, - color: theme.palette.text.tertiary, - textTransform: 'uppercase', - letterSpacing: '0.1em', - })} - > - {label} - - ({ - fontFamily: FONT_MONO, - fontSize: '0.88rem', - fontWeight: 700, - color: eligible - ? theme.palette.text.primary - : alpha(theme.palette.text.tertiary, INACTIVE_OPACITY), - fontFeatureSettings: TABULAR_NUMS, - lineHeight: 1, - })} - > - {value.toLocaleString()} - - {!isLast && ( - ({ - fontFamily: FONT_MONO, - fontSize: '0.82rem', - color: alpha(theme.palette.text.tertiary, 0.5), - ml: 0.5, - lineHeight: 1, - })} - > - · - - )} - -); - -/** - * One per-repository standing card. Echoes the leaderboard `MinerCard` - * visual language (header strip, hero metric / credibility split, activity - * row) but is scoped to a single `miner_evaluations` repository row. - * - * Links to that repository's Miners tab. Ineligible repos are muted and - * surface the server-computed `failedReason`; credibility is shown as a - * plain number — never compared against a fixed gate. - */ -const MinerRepoStandingCard: React.FC = ({ - repo, - viewMode, -}) => { - const isIssueMode = viewMode === 'issues'; - const linkProps = useLinkBehavior( - repoMinersHref(repo.repositoryFullName), - ); - - const eligible = isIssueMode ? repo.isIssueEligible : repo.isEligible; - const credibility = isIssueMode ? repo.issueCredibility : repo.credibility; - const score = isIssueMode ? repo.issueDiscoveryScore : repo.totalScore; - const owner = repo.repositoryFullName.split('/')[0]; - const repoName = - repo.repositoryFullName.split('/').slice(1).join('/') || - repo.repositoryFullName; - - const segments = isIssueMode - ? [ - { label: 'Solved', value: repo.totalSolvedIssues }, - { label: 'Valid', value: repo.totalValidSolvedIssues }, - { label: 'Open', value: repo.totalOpenIssues }, - ] - : [ - { label: 'Merged', value: repo.totalMergedPrs }, - { label: 'Open', value: repo.totalOpenPrs }, - { label: 'Closed', value: repo.totalClosedPrs }, - ]; - - return ( - ({ - ...linkResetSx, - position: 'relative', - p: 0, - backgroundColor: 'transparent', - border: `1px solid ${theme.palette.border.medium}`, - borderRadius: 1.5, - cursor: 'pointer', - height: '100%', - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - opacity: eligible ? 1 : 0.72, - transition: - 'background-color 0.22s ease, border-color 0.22s ease, transform 0.22s ease, box-shadow 0.22s ease, opacity 0.22s ease', - '&:hover': { - backgroundColor: alpha(theme.palette.status.merged, 0.04), - borderColor: alpha(theme.palette.status.merged, 0.28), - transform: 'scale(1.015)', - boxShadow: `0 8px 24px -6px ${alpha( - theme.palette.common.black, - 0.18, - )}`, - opacity: 1, - }, - '&:hover .repo-standing-name': { - textDecoration: 'underline', - textDecorationColor: alpha(theme.palette.status.merged, 0.55), - textDecorationThickness: '1px', - textUnderlineOffset: '3px', - }, - '&:focus-visible': { - outline: `2px solid ${theme.palette.primary.main}`, - outlineOffset: '2px', - }, - '@media (prefers-reduced-motion: reduce)': { - transition: 'none', - '&:hover': { transform: 'none' }, - }, - })} - elevation={0} - > - {/* ── Header strip — avatar + repo name + eligibility ──── */} - - ({ - width: 38, - height: 38, - border: `1px solid ${theme.palette.border.light}`, - backgroundColor: theme.palette.surface.subtle, - filter: eligible ? 'none' : 'grayscale(100%)', - })} - /> - - ({ - fontFamily: FONT_MONO, - fontSize: '0.9rem', - fontWeight: 600, - color: theme.palette.text.primary, - opacity: eligible ? 1 : INACTIVE_OPACITY, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - letterSpacing: '-0.005em', - lineHeight: 1.2, - })} - > - {repoName} - - ({ - fontFamily: FONT_MONO, - fontSize: '0.66rem', - color: theme.palette.text.tertiary, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - })} - > - {owner} - - - - - {/* ── Hero: score │ credibility ─────────────────────────── */} - ({ - display: 'grid', - gridTemplateColumns: '1fr auto', - alignItems: 'stretch', - borderTop: `1px solid ${theme.palette.border.light}`, - borderBottom: `1px solid ${theme.palette.border.light}`, - backgroundColor: alpha(theme.palette.text.primary, 0.012), - })} - > - - {isIssueMode ? 'Discovery score' : 'Repo score'} - ({ - fontFamily: FONT_MONO, - fontSize: '1.55rem', - fontWeight: 700, - color: eligible - ? theme.palette.text.primary - : alpha(theme.palette.text.primary, INACTIVE_OPACITY), - lineHeight: 1, - letterSpacing: '-0.028em', - fontFeatureSettings: TABULAR_NUMS, - mt: 0.7, - })} - > - {score.toFixed(2)} - - - - ({ - px: 2, - py: 1.5, - borderLeft: `1px solid ${theme.palette.border.light}`, - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - gap: 0.7, - minWidth: 92, - })} - > - Credibility - - {(credibility * 100).toFixed(1)}% - - - - - {/* ── Activity row + eligibility verdict ────────────────── */} - - - {isIssueMode ? 'Issue activity' : 'PR activity'} - - {segments.map((segment, index) => ( - - ))} - - - - {eligible ? ( - - ) : repo.failedReason ? ( - - - - - - ({ - fontSize: '0.72rem', - color: alpha(theme.palette.text.primary, 0.55), - lineHeight: 1.4, - overflow: 'hidden', - display: '-webkit-box', - WebkitLineClamp: 2, - WebkitBoxOrient: 'vertical', - })} - > - {repo.failedReason} - - - - ) : ( - - )} - - - ); -}; - -export { repoMinersHref }; -export default MinerRepoStandingCard; diff --git a/src/components/miners/MinerRepoStandings.tsx b/src/components/miners/MinerRepoStandings.tsx deleted file mode 100644 index 04a9ab1f..00000000 --- a/src/components/miners/MinerRepoStandings.tsx +++ /dev/null @@ -1,595 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { - alpha, - Avatar, - Box, - Card, - Chip, - CircularProgress, - IconButton, - MenuItem, - Select, - ToggleButton, - ToggleButtonGroup, - Tooltip, - Typography, -} from '@mui/material'; -import { - ArrowDownward as ArrowDownwardIcon, - ArrowUpward as ArrowUpwardIcon, - GridView as GridViewIcon, - TableRows as TableRowsIcon, -} from '@mui/icons-material'; -import { useMinerStats } from '../../api'; -import type { MinerRepositoryEvaluation } from '../../api/models/Dashboard'; -import { STATUS_COLORS, tooltipSlotProps } from '../../theme'; -import { credibilityColor } from '../../utils/format'; -import { type SortOrder } from '../../utils/ExplorerUtils'; -import { getRepositoryOwnerAvatarSrc } from '../../utils/avatar'; -import { DataTable, type DataTableColumn } from '../common'; -import EmptyStateMessage from './EmptyStateMessage'; -import MinerRepoStandingCard, { repoMinersHref } from './MinerRepoStandingCard'; - -type ViewMode = 'prs' | 'issues'; -type StandingsView = 'cards' | 'table'; -type StandingsSortKey = - | 'repository' - | 'eligible' - | 'credibility' - | 'merged' - | 'score' - | 'issueEligible' - | 'issueCredibility' - | 'solved' - | 'valid' - | 'discoveryScore'; - -interface MinerRepoStandingsProps { - githubId: string; - viewMode?: ViewMode; -} - -/* ── Table-view cell helpers (folded in from MinerRepoEligibilityTable) ── */ - -const EligibilityChip: React.FC<{ - eligible: boolean; - reason?: string | null; -}> = ({ eligible, reason }) => { - const color = eligible ? STATUS_COLORS.success : STATUS_COLORS.neutral; - const chip = ( - - ); - if (!eligible && reason) { - return ( - - - {chip} - - - ); - } - return chip; -}; - -const CredibilityValue: React.FC<{ value: number }> = ({ value }) => ( - - {`${(value * 100).toFixed(1)}%`} - -); - -const RepositoryCell: React.FC<{ repository: string }> = ({ repository }) => { - const owner = repository.split('/')[0]; - return ( - - - - {repository} - - - ); -}; - -/** - * Per-repository standings — the miner details page centerpiece. - * - * Sourced from the authoritative per-repo `miner_evaluations` rows - * (`MinerEvaluation.repositories`). Rows are sorted by per-repo score - * descending; ineligible repos are muted. Two views: a card grid (default) - * and a dense table. - */ -const MinerRepoStandings: React.FC = ({ - githubId, - viewMode = 'prs', -}) => { - const { data: minerStats, isLoading } = useMinerStats(githubId); - const [view, setView] = useState('cards'); - const isIssueMode = viewMode === 'issues'; - const [sortField, setSortField] = useState( - isIssueMode ? 'discoveryScore' : 'score', - ); - const [sortOrder, setSortOrder] = useState('desc'); - const [isSortMenuOpen, setIsSortMenuOpen] = useState(false); - - useEffect(() => { - setSortField(isIssueMode ? 'discoveryScore' : 'score'); - setSortOrder('desc'); - }, [isIssueMode]); - - const baseRows = useMemo(() => { - // Pre-migration placeholder rows carry an empty repo name — drop them. - const repos = (minerStats?.repositories ?? []).filter( - (r) => r.repositoryFullName.trim().length > 0, - ); - return repos.slice(); - }, [minerStats]); - - const rows = useMemo(() => { - const sorted = baseRows.slice().sort((a, b) => { - let comparison = 0; - switch (sortField) { - case 'repository': - comparison = a.repositoryFullName.localeCompare(b.repositoryFullName); - break; - case 'eligible': - comparison = Number(a.isEligible) - Number(b.isEligible); - break; - case 'credibility': - comparison = a.credibility - b.credibility; - break; - case 'merged': - comparison = a.totalMergedPrs - b.totalMergedPrs; - break; - case 'score': - comparison = a.totalScore - b.totalScore; - break; - case 'issueEligible': - comparison = Number(a.isIssueEligible) - Number(b.isIssueEligible); - break; - case 'issueCredibility': - comparison = a.issueCredibility - b.issueCredibility; - break; - case 'solved': - comparison = a.totalSolvedIssues - b.totalSolvedIssues; - break; - case 'valid': - comparison = a.totalValidSolvedIssues - b.totalValidSolvedIssues; - break; - case 'discoveryScore': - comparison = a.issueDiscoveryScore - b.issueDiscoveryScore; - break; - } - return sortOrder === 'asc' ? comparison : -comparison; - }); - return sorted; - }, [baseRows, sortField, sortOrder]); - - const handleSortChange = (nextField: StandingsSortKey) => { - if (nextField === sortField) { - setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); - return; - } - setSortField(nextField); - setSortOrder('desc'); - }; - const toggleSortOrder = () => - setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); - - const cardSortOptions = isIssueMode - ? [ - { value: 'discoveryScore' as const, label: 'Discovery score' }, - { value: 'issueCredibility' as const, label: 'Credibility' }, - { value: 'solved' as const, label: 'Solved' }, - { value: 'valid' as const, label: 'Valid' }, - { value: 'issueEligible' as const, label: 'Eligibility' }, - { value: 'repository' as const, label: 'Repository' }, - ] - : [ - { value: 'score' as const, label: 'Repo score' }, - { value: 'credibility' as const, label: 'Credibility' }, - { value: 'merged' as const, label: 'Merged PRs' }, - { value: 'eligible' as const, label: 'Eligibility' }, - { value: 'repository' as const, label: 'Repository' }, - ]; - - const eligibleCount = rows.filter((r) => - isIssueMode ? r.isIssueEligible : r.isEligible, - ).length; - - const prColumns: DataTableColumn< - MinerRepositoryEvaluation, - StandingsSortKey - >[] = [ - { - key: 'repository', - header: 'Repository', - width: '40%', - sortKey: 'repository', - renderCell: (r) => , - }, - { - key: 'eligible', - header: 'PR eligibility', - width: '18%', - sortKey: 'eligible', - renderCell: (r) => ( - - ), - }, - { - key: 'credibility', - header: 'Credibility', - width: '14%', - align: 'right', - sortKey: 'credibility', - renderCell: (r) => , - }, - { - key: 'merged', - header: 'Merged PRs', - width: '14%', - align: 'right', - sortKey: 'merged', - renderCell: (r) => r.totalMergedPrs, - }, - { - key: 'score', - header: 'Repo score', - width: '14%', - align: 'right', - sortKey: 'score', - renderCell: (r) => r.totalScore.toFixed(2), - }, - ]; - - const issueColumns: DataTableColumn< - MinerRepositoryEvaluation, - StandingsSortKey - >[] = [ - { - key: 'repository', - header: 'Repository', - width: '34%', - sortKey: 'repository', - renderCell: (r) => , - }, - { - key: 'issueEligible', - header: 'Issue eligibility', - width: '18%', - sortKey: 'issueEligible', - renderCell: (r) => ( - - ), - }, - { - key: 'issueCredibility', - header: 'Issue credibility', - width: '16%', - align: 'right', - sortKey: 'issueCredibility', - renderCell: (r) => , - }, - { - key: 'solved', - header: 'Solved', - width: '10%', - align: 'right', - sortKey: 'solved', - renderCell: (r) => r.totalSolvedIssues, - }, - { - key: 'valid', - header: 'Valid', - width: '10%', - align: 'right', - sortKey: 'valid', - renderCell: (r) => r.totalValidSolvedIssues, - }, - { - key: 'discoveryScore', - header: 'Discovery score', - width: '12%', - align: 'right', - sortKey: 'discoveryScore', - renderCell: (r) => r.issueDiscoveryScore.toFixed(2), - }, - ]; - - const handleViewChange = ( - _event: React.MouseEvent, - next: StandingsView | null, - ) => { - if (next) setView(next); - }; - - return ( - - - - - - Per-repository standings - - alpha(t.palette.text.primary, 0.5), - fontSize: '0.75rem', - }} - > - ({rows.length}) - - - {rows.length > 0 && ( - - {isIssueMode ? 'Issue-discovery eligible' : 'PR eligible'} in{' '} - 0 - ? STATUS_COLORS.success - : STATUS_COLORS.neutral, - fontWeight: 600, - }} - > - {eligibleCount}/{rows.length} - {' '} - {rows.length === 1 ? 'repository' : 'repositories'} - - )} - - - {rows.length > 0 && ( - - {view === 'cards' && ( - <> - - Sort: - - - - {sortOrder === 'asc' ? ( - - ) : ( - - )} - - - )} - alpha(t.palette.primary.main, 0.12), - '&:hover': { - backgroundColor: (t) => - alpha(t.palette.primary.main, 0.18), - }, - }, - }, - }} - > - - - - - - - - - )} - - - {isLoading ? ( - - - - ) : rows.length === 0 ? ( - - ) : view === 'cards' ? ( - - {rows.map((repo) => ( - - ))} - - ) : ( - - columns={isIssueMode ? issueColumns : prColumns} - rows={rows} - getRowKey={(r) => r.repositoryFullName} - getRowHref={(r) => repoMinersHref(r.repositoryFullName)} - minWidth="640px" - sort={{ - field: sortField, - order: sortOrder, - onChange: handleSortChange, - }} - /> - )} - - ); -}; - -export default MinerRepoStandings; diff --git a/src/components/miners/MinerRepositoryCard.tsx b/src/components/miners/MinerRepositoryCard.tsx new file mode 100644 index 00000000..bbadc233 --- /dev/null +++ b/src/components/miners/MinerRepositoryCard.tsx @@ -0,0 +1,495 @@ +import React from 'react'; +import { alpha, Avatar, Box, Card, Tooltip, Typography } from '@mui/material'; +import { + CheckCircleOutline as UnlockedIcon, + LockOutlined as LockedIcon, +} from '@mui/icons-material'; +import type { MinerRepositoryEvaluation } from '../../api/models/Dashboard'; +import { STATUS_COLORS, tooltipSlotProps } from '../../theme'; +import { credibilityColor } from '../../utils/format'; +import { getRepositoryOwnerAvatarSrc } from '../../utils/avatar'; +import { linkResetSx, useLinkBehavior } from '../common/linkBehavior'; +import type { + GateProgress, + RepoUnlock, + ViewMode, +} from '../../utils/minerProgress'; + +const FONT_MONO = '"JetBrains Mono", monospace'; +const TABULAR = '"tnum" 1, "ss01" 1'; + +export const repoMinersHref = (repositoryFullName: string): string => + `/miners/repository?name=${encodeURIComponent(repositoryFullName)}&tab=miners`; + +/** A single eligibility gate's progress bar with "what's left" affordance. */ +export const GateBar: React.FC<{ + label: string; + progress: GateProgress; + kind: 'count' | 'percent'; + accent: string; +}> = ({ label, progress, kind, accent }) => { + const color = progress.met ? STATUS_COLORS.success : accent; + const have = + kind === 'percent' + ? `${(progress.have * 100).toFixed(0)}%` + : `${progress.have}`; + const need = + kind === 'percent' + ? `${(progress.need * 100).toFixed(0)}%` + : `${progress.need}`; + const remaining = + progress.met || progress.remaining <= 0 + ? null + : kind === 'percent' + ? `+${Math.max(1, Math.round(progress.remaining * 100))}%` + : `${progress.remaining} more`; + + return ( + + + + {label} + + + + {have} + + + {' '} + / {need} + + {remaining && ( + + {remaining} + + )} + + + + + + + ); +}; + +const StatusChip: React.FC<{ unlocked: boolean }> = ({ unlocked }) => { + const color = unlocked ? STATUS_COLORS.success : STATUS_COLORS.neutral; + const Icon = unlocked ? UnlockedIcon : LockedIcon; + return ( + + + + {unlocked ? 'EARNING' : 'LOCKED'} + + + ); +}; + +/** One headline figure in the card's top stat strip. */ +const StatCell: React.FC<{ + label: string; + value: React.ReactNode; + valueColor?: string; + tooltip?: string; +}> = ({ label, value, valueColor, tooltip }) => { + const body = ( + + + {label} + + + {value} + + + ); + return tooltip ? ( + + {body} + + ) : ( + body + ); +}; + +interface MinerRepositoryCardProps { + repo: MinerRepositoryEvaluation; + unlock: RepoUnlock; + viewMode: ViewMode; + /** Repo's emission share (payout weight) of the OSS reward pool, 0–1. */ + pays?: number; +} + +/** + * One repository's standing + unlock progress, consolidated into a single card. + * For locked repos the gate bars show exactly what's left to start earning; for + * unlocked repos they confirm the gates are cleared. Replaces the old + * standings-only card so the per-repo story lives in one place. + */ +const MinerRepositoryCard: React.FC = ({ + repo, + unlock, + viewMode, + pays = 0, +}) => { + const isIssue = viewMode === 'issues'; + const linkProps = useLinkBehavior( + repoMinersHref(repo.repositoryFullName), + ); + + const credibility = isIssue ? repo.issueCredibility : repo.credibility; + const score = isIssue ? repo.issueDiscoveryScore : repo.totalScore; + const owner = repo.repositoryFullName.split('/')[0]; + const repoName = + repo.repositoryFullName.split('/').slice(1).join('/') || + repo.repositoryFullName; + + const counts: { label: string; value: number; tone: string }[] = isIssue + ? [ + { + label: 'Solved', + value: repo.totalSolvedIssues, + tone: 'text.primary', + }, + { + label: 'Valid', + value: repo.totalValidSolvedIssues, + tone: 'text.primary', + }, + { label: 'Open', value: repo.totalOpenIssues, tone: 'text.secondary' }, + { + label: 'Closed', + value: repo.totalClosedIssues, + tone: 'text.tertiary', + }, + ] + : [ + { label: 'Merged', value: repo.totalMergedPrs, tone: 'text.primary' }, + { label: 'Valid', value: unlock.count.have, tone: 'text.primary' }, + { label: 'Open', value: repo.totalOpenPrs, tone: 'text.secondary' }, + { label: 'Closed', value: repo.totalClosedPrs, tone: 'text.tertiary' }, + ]; + + const accent = STATUS_COLORS.info; + + return ( + ({ + ...linkResetSx, + p: 0, + display: 'flex', + flexDirection: 'column', + height: '100%', + overflow: 'hidden', + border: `1px solid ${theme.palette.border.light}`, + borderRadius: 2, + backgroundColor: 'transparent', + opacity: unlock.unlocked ? 1 : 0.92, + transition: + 'border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease', + '&:hover': { + borderColor: alpha( + unlock.unlocked + ? theme.palette.status.success + : theme.palette.status.info, + 0.4, + ), + transform: 'translateY(-2px)', + boxShadow: `0 8px 24px -8px ${alpha(theme.palette.common.black, 0.4)}`, + }, + '&:focus-visible': { + outline: `2px solid ${theme.palette.primary.main}`, + outlineOffset: '2px', + }, + '@media (prefers-reduced-motion: reduce)': { + transition: 'none', + '&:hover': { transform: 'none' }, + }, + })} + > + {/* Header */} + + + + + {repoName} + + + {owner} + + + + + + {/* Headline stats */} + ({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + borderTop: `1px solid ${theme.palette.border.light}`, + borderBottom: `1px solid ${theme.palette.border.light}`, + '& > *:not(:last-of-type)': { + borderRight: `1px solid ${theme.palette.border.light}`, + }, + })} + > + + 0 ? `${(pays * 100).toFixed(pays >= 0.1 ? 1 : 2)}%` : '—' + } + valueColor={pays > 0 ? 'text.primary' : 'text.tertiary'} + tooltip="Payout weight — the share of the OSS reward pool this repository distributes." + /> + + + + {/* Unlock progress — grows so the count footer pins to the card base */} + + + {unlock.unlocked && ( + + )} + + {unlock.unlocked ? 'All gates cleared' : 'Unlock progress'} + + + + + + {!unlock.unlocked && repo.failedReason && ( + + alpha(t.palette.text.primary, 0.5), + lineHeight: 1.4, + display: '-webkit-box', + WebkitLineClamp: 2, + WebkitBoxOrient: 'vertical', + overflow: 'hidden', + mt: 0.25, + }} + > + {repo.failedReason} + + + )} + + + {/* Activity count footer */} + ({ + display: 'grid', + gridTemplateColumns: 'repeat(4, 1fr)', + borderTop: `1px solid ${theme.palette.border.light}`, + '& > *:not(:last-of-type)': { + borderRight: `1px solid ${theme.palette.border.subtle}`, + }, + })} + > + {counts.map((c) => ( + + 0 ? c.tone : 'text.tertiary', + }} + > + {c.value.toLocaleString()} + + + {c.label} + + + ))} + + + ); +}; + +export default MinerRepositoryCard; diff --git a/src/components/miners/MinerRepositoryPanel.tsx b/src/components/miners/MinerRepositoryPanel.tsx new file mode 100644 index 00000000..3d6cba3f --- /dev/null +++ b/src/components/miners/MinerRepositoryPanel.tsx @@ -0,0 +1,915 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + alpha, + Avatar, + Box, + Card, + CircularProgress, + FormControl, + IconButton, + InputAdornment, + MenuItem, + Select, + TextField, + ToggleButton, + ToggleButtonGroup, + Tooltip, + Typography, + useMediaQuery, + useTheme, +} from '@mui/material'; +import { + ArrowDownward as ArrowDownIcon, + ArrowUpward as ArrowUpIcon, + GridView as GridIcon, + Search as SearchIcon, + TableRows as TableIcon, +} from '@mui/icons-material'; +import { + useMinerPRs, + useMinerStats, + useReposAndWeights, + type MinerRepositoryEvaluation, + type RepositoryConfig, +} from '../../api'; +import { STATUS_COLORS, tooltipSlotProps } from '../../theme'; +import { credibilityColor } from '../../utils/format'; +import { getRepositoryOwnerAvatarSrc } from '../../utils/avatar'; +import { buildRepoWeightsMap } from '../../utils/ExplorerUtils'; +import { + computeOpenPrAllowance, + computeRepoUnlock, + countValidMergedPrsByRepo, + getEligibilityThresholds, + type EligibilityThresholds, + type RepoUnlock, + type ViewMode, +} from '../../utils/minerProgress'; +import { DataTable, type DataTableColumn } from '../common'; +import SegmentedToggle from '../common/SegmentedToggle'; +import { ClearSearchAdornment } from '../common/ClearSearchAdornment'; +import { DebouncedSearchInput } from '../common/DebouncedSearchInput'; +import TablePagination, { + getMinerExplorerPaging, + MINER_EXPLORER_ROWS_SELECT_SX, +} from '../common/TablePagination'; +import EmptyStateMessage from './EmptyStateMessage'; +import MinerRepositoryCard, { repoMinersHref } from './MinerRepositoryCard'; + +// Page-size options per view. Card sizes are multiples of the 1/2/3-column grid +// so the final row is never partial — mirrors the leaderboard's card sizing. +const TABLE_ROWS = [5, 10, 20, 50] as const; +const CARD_ROWS = [12, 24, 48] as const; +const DEFAULT_TABLE_ROWS = 20; +const DEFAULT_CARD_ROWS = 12; + +const clampRowsForView = (rows: number, view: StandingsView): number => { + const opts = view === 'cards' ? CARD_ROWS : TABLE_ROWS; + if ((opts as readonly number[]).includes(rows)) return rows; + return view === 'cards' ? DEFAULT_CARD_ROWS : DEFAULT_TABLE_ROWS; +}; + +type StandingsView = 'cards' | 'table'; +type SortKey = + | 'progress' + | 'status' + | 'pays' + | 'score' + | 'credibility' + | 'count' + | 'repository'; +type SortOrder = 'asc' | 'desc'; +type EligibilityFilter = 'all' | 'eligible' | 'ineligible'; + +interface MinerRepositoryPanelProps { + githubId: string; + viewMode?: ViewMode; +} + +interface RepoRow { + repo: MinerRepositoryEvaluation; + unlock: RepoUnlock; + score: number; + credibility: number; + count: number; + /** Repo's emission share (payout weight) of the OSS reward pool, 0–1. */ + pays: number; + /** Open-PR allowance before the excessive-open-PR gate zeroes repo score. */ + openAllowance: number; +} + +type OpenRiskLevel = 'over' | 'at' | 'near' | 'ok'; + +const OPEN_RISK_COLOR: Record = { + over: STATUS_COLORS.error, + at: STATUS_COLORS.warningOrange, + near: STATUS_COLORS.warning, + ok: STATUS_COLORS.success, +}; + +const openRiskLevel = (open: number, allowance: number): OpenRiskLevel => { + if (open > allowance) return 'over'; + if (open === allowance) return 'at'; + if (allowance - open <= 1) return 'near'; + return 'ok'; +}; + +/** + * Open PRs against the repo's open-PR allowance. Open PRs withhold collateral + * and, once they exceed the allowance, zero the repo's contribution score — so + * this doubles as the risk signal that used to live in its own section: green + * healthy → amber approaching → red over limit. Renders "—" with no open PRs. + */ +const OpenRiskCell: React.FC<{ open: number; allowance: number }> = ({ + open, + allowance, +}) => { + if (open <= 0) { + return ( + + — + + ); + } + const level = openRiskLevel(open, allowance); + const color = OPEN_RISK_COLOR[level]; + const title = + level === 'over' + ? `${open} open PRs exceed the allowance of ${allowance} — this repository’s contribution score is currently zeroed. Merge or close PRs to recover.` + : level === 'at' + ? `At the open-PR limit (${open}/${allowance}). One more open PR zeroes this repository’s contribution score.` + : level === 'near' + ? `${open}/${allowance} open-PR slots used — one slot left before the score is zeroed.` + : `${open}/${allowance} open-PR slots used. Healthy headroom (allowance grows with token score).`; + return ( + + + + {open} + + + / {allowance} + + + + ); +}; + +const SORT_OPTIONS: { value: SortKey; label: string }[] = [ + { value: 'progress', label: 'Closest to unlock' }, + { value: 'pays', label: 'Pays most' }, + { value: 'status', label: 'Status' }, + { value: 'score', label: 'Score' }, + { value: 'credibility', label: 'Credibility' }, + { value: 'count', label: 'Activity' }, + { value: 'repository', label: 'Repository' }, +]; + +/** Payout weight as a percentage of the OSS reward pool. */ +const PaysCell: React.FC<{ pays: number }> = ({ pays }) => + pays > 0 ? ( + + {(pays * 100).toFixed(pays >= 0.1 ? 1 : 2)}% + + ) : ( + + — + + ); + +/** Compact activity count with a muted hierarchy (merged → open → closed). */ +const COUNT_TONE: Record<'merged' | 'open' | 'closed', string> = { + merged: 'text.primary', + open: 'text.secondary', + closed: 'text.tertiary', +}; + +const CountCell: React.FC<{ + value: number; + tone: 'merged' | 'open' | 'closed'; +}> = ({ value, tone }) => ( + 0 ? COUNT_TONE[tone] : 'text.tertiary', + }} + > + {value > 0 ? value.toLocaleString() : '—'} + +); + +const RepositoryCell: React.FC<{ repository: string }> = ({ repository }) => { + const owner = repository.split('/')[0]; + return ( + + + + {repository} + + + ); +}; + +const StatusCell: React.FC<{ unlock: RepoUnlock }> = ({ unlock }) => { + const color = unlock.unlocked ? STATUS_COLORS.success : STATUS_COLORS.info; + return ( + + + + + + {unlock.unlocked + ? 'Earning' + : `${Math.round(unlock.overallPct * 100)}%`} + + + ); +}; + +/** + * The consolidated per-repository section — each repo's standing AND unlock + * progress in one place (replacing the separate standings table + progress + * views to remove the redundancy issue #95 flags). Searchable, sortable + * (default: closest-to-unlock first), with a card grid or dense table. + */ +const MinerRepositoryPanel: React.FC = ({ + githubId, + viewMode = 'prs', +}) => { + const isIssue = viewMode === 'issues'; + const { data: minerStats, isLoading } = useMinerStats(githubId); + const { data: prs } = useMinerPRs(githubId); + const { data: repos } = useReposAndWeights(); + + // Default to the dense table on wider screens (compact for scanning many + // repos); on mobile start in the card grid so there's no horizontal scroll. + // The card grid is one toggle away either way. + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const [view, setView] = useState(isMobile ? 'cards' : 'table'); + const [sortField, setSortField] = useState('progress'); + const [sortOrder, setSortOrder] = useState('desc'); + const [eligibility, setEligibility] = useState('eligible'); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState( + isMobile ? DEFAULT_CARD_ROWS : DEFAULT_TABLE_ROWS, + ); + const rowsOptions = view === 'cards' ? CARD_ROWS : TABLE_ROWS; + + // Per-repo eligibility config (token threshold for "valid" PRs + gate values). + const thresholdsByRepo = useMemo(() => { + const map = new Map(); + (repos ?? []).forEach((r) => + map.set( + r.fullName.toLowerCase(), + getEligibilityThresholds(r.config as RepositoryConfig), + ), + ); + return map; + }, [repos]); + + // Per-repo payout weight (emission share) — what fraction of the OSS reward + // pool the repo distributes; lets a miner see which repos are worth the effort. + const weightsByRepo = useMemo( + () => (repos ? buildRepoWeightsMap(repos) : new Map()), + [repos], + ); + + const validMergedByRepo = useMemo( + () => + countValidMergedPrsByRepo( + prs, + (name) => + thresholdsByRepo.get(name.toLowerCase())?.minTokenScoreForBaseScore ?? + 5, + ), + [prs, thresholdsByRepo], + ); + + const rows = useMemo(() => { + const evals = (minerStats?.repositories ?? []).filter( + (r) => r.repositoryFullName.trim().length > 0, + ); + return evals.map((repo) => { + const thresholds = + thresholdsByRepo.get(repo.repositoryFullName.toLowerCase()) ?? + getEligibilityThresholds(undefined); + const validCount = + validMergedByRepo.get(repo.repositoryFullName.toLowerCase()) ?? 0; + const unlock = computeRepoUnlock(repo, validCount, thresholds, viewMode); + return { + repo, + unlock, + score: isIssue ? repo.issueDiscoveryScore : repo.totalScore, + credibility: isIssue ? repo.issueCredibility : repo.credibility, + count: isIssue ? repo.totalSolvedIssues : repo.totalMergedPrs, + pays: weightsByRepo.get(repo.repositoryFullName.toLowerCase()) ?? 0, + openAllowance: computeOpenPrAllowance( + Number(repo.totalTokenScore) || 0, + thresholds, + ), + }; + }); + }, [ + minerStats, + thresholdsByRepo, + validMergedByRepo, + weightsByRepo, + viewMode, + isIssue, + ]); + + const filteredSorted = useMemo(() => { + const q = search.trim().toLowerCase(); + const filtered = rows.filter((r) => { + if (q && !r.repo.repositoryFullName.toLowerCase().includes(q)) { + return false; + } + if (eligibility === 'eligible') return r.unlock.unlocked; + if (eligibility === 'ineligible') return !r.unlock.unlocked; + return true; + }); + + filtered.sort((a, b) => { + if (sortField === 'progress') { + // Actionable-first: locked repos (by progress desc), then earning ones. + if (a.unlock.unlocked !== b.unlock.unlocked) { + return a.unlock.unlocked ? 1 : -1; + } + if (!a.unlock.unlocked) + return b.unlock.overallPct - a.unlock.overallPct; + return b.score - a.score; + } + let c = 0; + switch (sortField) { + case 'status': + c = Number(a.unlock.unlocked) - Number(b.unlock.unlocked); + break; + case 'pays': + c = a.pays - b.pays; + break; + case 'score': + c = a.score - b.score; + break; + case 'credibility': + c = a.credibility - b.credibility; + break; + case 'count': + c = a.count - b.count; + break; + case 'repository': + c = a.repo.repositoryFullName.localeCompare( + b.repo.repositoryFullName, + ); + break; + } + return sortOrder === 'asc' ? c : -c; + }); + return filtered; + }, [rows, search, eligibility, sortField, sortOrder]); + + // Reset to the first page whenever the result set or its ordering changes so + // the viewer never lands on a now-empty page. + useEffect(() => { + setPage(0); + }, [ + githubId, + search, + eligibility, + sortField, + sortOrder, + viewMode, + rowsPerPage, + ]); + + const { + slice: pagedRows, + totalPages, + safePage, + showPageNav, + } = useMemo( + () => getMinerExplorerPaging(filteredSorted, page, rowsPerPage), + [filteredSorted, page, rowsPerPage], + ); + + const eligibleCount = rows.filter((r) => r.unlock.unlocked).length; + const ineligibleCount = rows.length - eligibleCount; + + const handleSortChange = (next: SortKey) => { + if (next === sortField) { + setSortOrder((p) => (p === 'asc' ? 'desc' : 'asc')); + return; + } + setSortField(next); + setSortOrder(next === 'repository' ? 'asc' : 'desc'); + }; + + const columns: DataTableColumn[] = [ + { + key: 'repository', + header: 'Repository', + width: '22%', + sortKey: 'repository', + renderCell: (r) => ( + + ), + }, + { + key: 'status', + header: 'Unlock', + width: '14%', + sortKey: 'status', + renderCell: (r) => , + }, + { + key: 'pays', + header: ( + + + Pays + + + ), + width: '9%', + align: 'right', + sortKey: 'pays', + renderCell: (r) => , + }, + { + key: 'credibility', + header: 'Credibility', + width: '11%', + align: 'right', + sortKey: 'credibility', + renderCell: (r) => ( + + {(r.credibility * 100).toFixed(1)}% + + ), + }, + { + key: 'count', + header: isIssue ? 'Solved' : 'Merged', + width: '9%', + align: 'right', + sortKey: 'count', + renderCell: (r) => , + }, + { + key: 'valid', + header: ( + + + Valid + + + ), + width: '8%', + align: 'right', + renderCell: (r) => ( + + ), + }, + { + key: 'open', + header: isIssue ? ( + 'Open' + ) : ( + + + Open / limit + + + ), + width: isIssue ? '8%' : '10%', + align: 'right', + renderCell: (r) => + isIssue ? ( + + ) : ( + + ), + }, + { + key: 'closed', + header: 'Closed', + width: '8%', + align: 'right', + renderCell: (r) => ( + + ), + }, + { + key: 'score', + header: isIssue ? 'Discovery score' : 'Repo score', + width: '11%', + align: 'right', + sortKey: 'score', + renderCell: (r) => r.score.toFixed(2), + }, + ]; + + return ( + + {/* Header */} + + {/* Row 1 — title + eligibility filter */} + + + Repositories + alpha(t.palette.text.primary, 0.5), + }} + > + ({rows.length}) + + + + {rows.length > 0 && ( + + )} + + + {/* Row 2 — sort · order · view · rows · search */} + {rows.length > 0 && ( + + + + {sortField !== 'progress' && ( + + setSortOrder((p) => (p === 'asc' ? 'desc' : 'asc')) + } + size="small" + aria-label="Toggle sort direction" + sx={{ + border: '1px solid', + borderColor: 'border.light', + borderRadius: 1.5, + width: 34, + height: 34, + }} + > + {sortOrder === 'asc' ? ( + + ) : ( + + )} + + )} + + { + if (!v) return; + setView(v); + setRowsPerPage((r) => clampRowsForView(r, v)); + setPage(0); + }} + size="small" + sx={{ + border: '1px solid', + borderColor: 'border.light', + borderRadius: 2, + p: 0.5, + gap: 0.5, + // Undo MUI's grouped border-merging so each icon reads as its + // own pill, matching the OSS/Discovery + range switches. + '& .MuiToggleButtonGroup-grouped': { + m: 0, + border: '1px solid transparent', + borderRadius: '6px !important', + }, + '& .MuiToggleButton-root': { + color: 'text.secondary', + px: 1, + py: 0.4, + transition: 'all 0.2s', + '&:hover': { + color: 'text.primary', + backgroundColor: 'surface.light', + }, + '&.Mui-selected': { + color: 'text.primary', + backgroundColor: 'surface.elevated', + borderColor: 'border.medium', + boxShadow: '0 1px 2px rgba(0, 0, 0, 0.45)', + '&:hover': { backgroundColor: 'surface.elevated' }, + }, + }, + }} + > + + + + + + + + + + + + Rows: + + + + + + {/* Search sits at the right end of the controls row. */} + + {({ draftValue, setDraftValue }) => ( + setDraftValue(e.target.value)} + InputProps={{ + startAdornment: ( + + alpha(t.palette.text.primary, 0.3), + fontSize: '1rem', + }} + /> + + ), + endAdornment: ( + setDraftValue('')} + /> + ), + }} + sx={{ + width: { xs: '100%', md: 180 }, + ml: { md: 'auto' }, + '& .MuiOutlinedInput-root': { + height: 34, + fontSize: '0.8rem', + '& fieldset': { borderColor: 'border.light' }, + '&:hover fieldset': { borderColor: 'border.medium' }, + }, + }} + /> + )} + + + )} + + + {isLoading ? ( + + + + ) : rows.length === 0 ? ( + + ) : filteredSorted.length === 0 ? ( + + ) : view === 'cards' ? ( + <> + + {pagedRows.map((r) => ( + + ))} + + {showPageNav && ( + + )} + + ) : ( + + columns={columns} + rows={pagedRows} + getRowKey={(r) => r.repo.repositoryFullName} + getRowHref={(r) => repoMinersHref(r.repo.repositoryFullName)} + minWidth="960px" + sort={{ + field: sortField, + order: sortOrder, + onChange: handleSortChange, + }} + pagination={ + showPageNav ? ( + + ) : null + } + /> + )} + + ); +}; + +export default MinerRepositoryPanel; diff --git a/src/components/miners/MinerStatBand.tsx b/src/components/miners/MinerStatBand.tsx new file mode 100644 index 00000000..909c0d5c --- /dev/null +++ b/src/components/miners/MinerStatBand.tsx @@ -0,0 +1,438 @@ +import React, { useMemo } from 'react'; +import { alpha, Box, Card, Skeleton, Tooltip, Typography } from '@mui/material'; +import { + BoltOutlined as EarningsIcon, + EmojiEventsOutlined as ScoreIcon, + InsightsOutlined as ActivityIcon, + LockOutlined as CollateralIcon, +} from '@mui/icons-material'; +import { useAllMiners, useMinerStats } from '../../api'; +import { + LEADERBOARD_TRACK_COLORS, + STATUS_COLORS, + tooltipSlotProps, +} from '../../theme'; +import { + aggregateMinerTotals, + computeNetworkRank, +} from '../../utils/minerProgress'; +import { + computeMinerBenchmark, + type Percentile, +} from '../../utils/minerBenchmark'; +import PercentileBadge from './PercentileBadge'; + +interface MinerStatBandProps { + githubId: string; +} + +const toNum = (v: unknown): number => { + const n = typeof v === 'string' ? parseFloat(v) : Number(v); + return Number.isFinite(n) ? n : 0; +}; + +const compact = (n: number): string => { + if (!Number.isFinite(n) || n <= 0) return '0'; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}K`; + return `${Math.round(n)}`; +}; +const usd = (n: number): string => + n >= 1000 ? `$${compact(n)}` : `$${Math.round(n)}`; + +const OSS = LEADERBOARD_TRACK_COLORS.oss; +const DISC = LEADERBOARD_TRACK_COLORS.discovery; + +/** Tiny label after a colored figure in a split value (e.g. "merged"). */ +const TRACK_LABEL_SX = { + fontSize: '0.6rem', + fontWeight: 600, + letterSpacing: '0.04em', + color: 'text.tertiary', + ml: 0.4, +} as const; + +/** Middot between the two track figures of a split value. */ +const TRACK_SEP_SX = { + color: 'text.tertiary', + mx: 0.75, + fontWeight: 400, +} as const; + +const Dot: React.FC<{ color: string }> = ({ color }) => ( + +); + +/** A two-track split value — OSS figure (green) and Disc figure (blue). */ +const TrackPair: React.FC<{ + ossValue: React.ReactNode; + ossLabel: string; + discValue: React.ReactNode; + discLabel: string; +}> = ({ ossValue, ossLabel, discValue, discLabel }) => ( + <> + + {ossValue} + + + {ossLabel} + + + · + + + {discValue} + + + {discLabel} + + +); + +/** One muted detail line under a metric's value. */ +const Detail: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); + +/* ── One metric inside a combined card ────────────────────────────── */ + +const Metric: React.FC<{ + icon: React.ReactNode; + label: string; + tone?: string; + tooltip?: string; + value: React.ReactNode; + valueColor?: string; + percentile?: Percentile | null; + details?: React.ReactNode; +}> = ({ + icon, + label, + tone, + tooltip, + value, + valueColor, + percentile, + details, +}) => { + const content = ( + + + alpha(t.palette.text.primary, 0.4)), + display: 'inline-flex', + '& svg': { fontSize: '0.85rem' }, + }} + > + {icon} + + + {label} + + + + + + {value} + + {percentile && ( + + + + )} + + + {details && ( + + {details} + + )} + + ); + + return tooltip ? ( + + {content} + + ) : ( + content + ); +}; + +/** Two metrics sharing one card, divided by a hairline. */ +const PairCard: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + *:first-of-type': { + borderRight: '1px solid', + borderColor: 'border.light', + }, + }} + > + {children} + + +); + +/** + * Headline performance — two combined cards: Earnings + Score, and Activity + + * Collateral. Each metric carries supporting detail lines (peer percentile, + * rank, score split, code volume, repos at risk) so the band reads richly + * without the old six-cell sprawl. Eligibility now lives in the profile hero. + * + * The single-miner endpoint serves its SUM'd float columns as broken strings; + * `aggregateMinerTotals` sources score/earnings floats from the clean + * leaderboard row and the contribution counts from the per-repo rows (so they + * match the repositories table). + */ +const MinerStatBand: React.FC = ({ githubId }) => { + const { data: m, isLoading } = useMinerStats(githubId); + const { data: allMiners } = useAllMiners(); + + const rank = useMemo( + () => computeNetworkRank(allMiners, githubId), + [allMiners, githubId], + ); + + const benchmark = useMemo( + () => computeMinerBenchmark(allMiners, githubId), + [allMiners, githubId], + ); + + if (isLoading || !m) { + return ( + + {[0, 1].map((i) => ( + + ))} + + ); + } + + const listed = allMiners?.find((x) => x.githubId === githubId); + const totals = aggregateMinerTotals(m, listed); + const { + ossScore, + discScore, + combinedScore, + baseScore, + collateral, + additions, + deletions, + } = totals; + const usdPerDay = toNum(m.usdPerDay); + const lifetimeUsd = toNum(m.lifetimeUsd); + const totalIssues = + totals.solvedIssues + totals.closedIssues + totals.openIssues; + const hasLines = additions + deletions > 0; + const reposWithOpenPrs = (m.repositories ?? []).filter( + (r) => toNum(r.totalOpenPrs) > 0, + ).length; + + return ( + + {/* ── Earnings + Score ─────────────────────────────────── */} + + } + label="$/Day" + tone={usdPerDay > 0 ? STATUS_COLORS.success : undefined} + tooltip="Network-wide earnings from the metagraph incentive distribution — not split per repo or track." + value={`~${usd(usdPerDay)}`} + valueColor={usdPerDay > 0 ? STATUS_COLORS.success : undefined} + percentile={usdPerDay > 0 ? benchmark.earnings : null} + details={ + usdPerDay > 0 || lifetimeUsd > 0 ? ( + <> + ~{usd(usdPerDay * 30)}/mo + ~{usd(lifetimeUsd)} earned to date + + ) : ( + not earning yet + ) + } + /> + } + label="Score" + tooltip={`Combined OSS + discovery score. Base ${baseScore.toFixed( + 2, + )}.${rank ? ` Ranked #${rank.rank} of ${rank.total} by score.` : ''}`} + value={combinedScore.toFixed(2)} + percentile={combinedScore > 0 ? benchmark.combinedScore : null} + details={ + <> + {rank && ( + + #{rank.rank} of {rank.total.toLocaleString()} miners + + )} + + + {ossScore.toFixed(1)} OSS · + {discScore.toFixed(1)} Disc + + + } + /> + + + {/* ── Activity + Collateral ────────────────────────────── */} + + } + label="Activity" + tooltip={ + hasLines + ? `Merged PRs and solved issues. +${additions.toLocaleString()} / −${deletions.toLocaleString()} lines changed.` + : 'Merged PRs and solved issues.' + } + value={ + + } + percentile={totals.mergedPrs > 0 ? benchmark.mergedPrs : null} + details={ + <> + + {totals.prs} PRs · {totalIssues} issues + + {hasLines && ( + + +{compact(additions)} / −{compact(deletions)} lines + + )} + + } + /> + } + label="Collateral" + tone={collateral > 0 ? STATUS_COLORS.warningOrange : undefined} + tooltip="Score withheld as collateral while your PRs stay open — released as they merge, forfeited if they close unmerged." + value={collateral.toFixed(2)} + valueColor={collateral > 0 ? STATUS_COLORS.warningOrange : undefined} + details={ + collateral > 0 ? ( + <> + + held by {totals.openPrs} open PR + {totals.openPrs === 1 ? '' : 's'} + + {reposWithOpenPrs > 0 && ( + + across {reposWithOpenPrs} repo + {reposWithOpenPrs === 1 ? '' : 's'} + + )} + + ) : ( + none withheld + ) + } + /> + + + ); +}; + +export default MinerStatBand; diff --git a/src/components/miners/PercentileBadge.tsx b/src/components/miners/PercentileBadge.tsx new file mode 100644 index 00000000..95865949 --- /dev/null +++ b/src/components/miners/PercentileBadge.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { alpha, Box } from '@mui/material'; +import type { Percentile } from '../../utils/minerBenchmark'; +import { STATUS_COLORS } from '../../theme'; + +interface PercentileBadgeProps { + percentile: Percentile | null | undefined; + /** Kept for call-site compatibility; layout is identical either way. */ + inline?: boolean; +} + +/** + * Peer-standing pill — "top 8%" / "bottom 12%". Top-half standings read in the + * success tone with a tinted fill; the rest stay muted so they inform without + * alarming. Renders nothing when the percentile can't be computed. + */ +const PercentileBadge: React.FC = ({ percentile }) => { + if (!percentile) return null; + const strong = percentile.topPct <= 0.5; + return ( + + {percentile.label} + + ); +}; + +export default PercentileBadge; diff --git a/src/components/miners/PerformanceRadar.tsx b/src/components/miners/PerformanceRadar.tsx index 8b534990..1c2f1829 100644 --- a/src/components/miners/PerformanceRadar.tsx +++ b/src/components/miners/PerformanceRadar.tsx @@ -36,12 +36,12 @@ const PerformanceRadar: React.FC = ({ radar: { ...echartsRadarChrome(theme), indicator: [ - { name: 'Credibility', max: 100 }, - { name: 'Complexity', max: 100 }, + { name: 'Success\nrate', max: 100 }, + { name: 'Code\ndepth', max: 100 }, { name: 'Merged\nPRs', max: 100 }, - { name: 'Unique\nRepos', max: 100 }, - { name: 'Total\nPRs', max: 100 }, - { name: 'Avg Repo\nWeight', max: 100 }, + { name: 'Repo\nspread', max: 100 }, + { name: 'PR\nvolume', max: 100 }, + { name: 'Repo\npayout', max: 100 }, ], center: ['50%', '50%'], radius: '50%', @@ -100,12 +100,22 @@ const PerformanceRadar: React.FC = ({ variant="monoSmall" sx={{ color: alpha(theme.palette.common.white, TEXT_OPACITY.muted), - mb: 2, + mb: 0.5, textAlign: 'center', }} > Performance Profile + + Each axis scaled 0–100 vs the network's best + = ({ color: alpha(theme.palette.common.white, 0.9), }} > - Based on your PR success rate, scaled to reward + Based on your PR credibility, scaled to reward consistency. diff --git a/src/pages/MinerDetailsPage.tsx b/src/pages/MinerDetailsPage.tsx index bef076c1..a1f8e80e 100644 --- a/src/pages/MinerDetailsPage.tsx +++ b/src/pages/MinerDetailsPage.tsx @@ -6,10 +6,12 @@ import { LinkBox } from '../components/common/linkBehavior'; import { BackButton, MinerActivity, - MinerIdentityRail, + MinerNextSteps, MinerOpenDiscoveryIssuesByRepo, MinerPRsTable, - MinerRepoStandings, + MinerProfileHero, + MinerRepositoryPanel, + MinerStatBand, SEO, } from '../components'; import { WatchlistButton } from '../components/common'; @@ -21,17 +23,110 @@ const PR_TABS = ['pull-requests', 'activity'] as const; const ISSUE_TABS = ['open-issues', 'activity'] as const; type MinerDetailsTab = (typeof PR_TABS)[number] | (typeof ISSUE_TABS)[number]; +// Offset so smooth-scrolled sections clear the sticky global search bar +// (see AppLayout) instead of landing flush under it. +const sectionAnchorSx = { scrollMarginTop: { xs: 72, md: 88 } } as const; + +// Segmented-pill tab styling — matches the OSS/Discovery + range switches: +// no underline indicator, the active tab is a raised elevated pill with bold +// white text, inactive tabs are muted grey. const tabsAlignSx = { + minHeight: 0, + border: '1px solid', + borderColor: 'border.light', + borderRadius: 2, + p: 0.5, + '& .MuiTabs-indicator': { display: 'none' }, + '& .MuiTabs-flexContainer': { gap: 0.5 }, '& .MuiTab-root': { - color: 'text.secondary', + minHeight: 0, + minWidth: 0, textTransform: 'none' as const, - fontSize: '0.83rem', + fontSize: '0.8rem', fontWeight: 500, - '&.Mui-selected': { color: 'primary.main' }, - '&:first-of-type': { pl: 0 }, + color: 'text.secondary', + px: { xs: 1.5, sm: 2 }, + py: 0.6, + borderRadius: 1.5, + border: '1px solid transparent', + transition: 'all 0.2s', + '&:hover': { color: 'text.primary', backgroundColor: 'surface.light' }, + '&.Mui-selected': { + color: 'text.primary', + fontWeight: 700, + backgroundColor: 'surface.elevated', + borderColor: 'border.medium', + boxShadow: '0 1px 2px rgba(0, 0, 0, 0.45)', + }, }, }; +// Time-range options for the contributions toolbar. 30D is the default (and the +// widest, matching the subnet's ~30-day PR scoring window). +const RANGES = [ + { label: '1D', value: '1d', days: 1 }, + { label: '7D', value: '7d', days: 7 }, + { label: '30D', value: '30d', days: 30 }, +] as const; + +/** Small segmented (pill) toggle built from router links — used for both the + * OSS/Discovery track and the 1D/7D/30D range. */ +const Segmented: React.FC<{ + ariaLabel: string; + options: ReadonlyArray<{ label: string; href: string; active: boolean }>; +}> = ({ ariaLabel, options }) => ( + + {options.map((o) => ( + alpha(t.palette.text.primary, 0.45), + transition: 'all 0.2s', + '&:hover': { + color: 'text.primary', + backgroundColor: o.active + ? 'surface.elevated' + : (t) => alpha(t.palette.text.primary, 0.04), + }, + }} + > + + {o.label} + + + ))} + +); + const MinerDetailsPage: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); const location = useLocation(); @@ -52,6 +147,19 @@ const MinerDetailsPage: React.FC = () => { return `${location.pathname}?${p.toString()}`; }; + // Time range for the contributions (PR/issue lists + activity). Defaults to + // the widest window (30D). + const rangeParam = searchParams.get('range'); + const activeRange = + RANGES.find((r) => r.value === rangeParam) ?? RANGES[RANGES.length - 1]; + const rangeDays = activeRange.days; + + const buildRangeHref = (value: string) => { + const p = new URLSearchParams(searchParams); + p.set('range', value); + return `${location.pathname}?${p.toString()}`; + }; + const tabParam = searchParams.get('tab'); const activeTab: MinerDetailsTab = tabParam && (tabs as readonly string[]).includes(tabParam) @@ -82,7 +190,7 @@ const MinerDetailsPage: React.FC = () => { { display: 'flex', width: '100%', justifyContent: 'center', - py: { xs: 2, sm: 3 }, + py: { xs: 1.5, sm: 2.5 }, }} > - {/* ── Header: back · mode toggle · watch ─────────────── */} - - - - {/* Mode toggle — a real page-level axis, not a stat */} - - {( - [ - { label: 'OSS Contributions', value: 'prs' as const }, - { label: 'Issue Discovery', value: 'issues' as const }, - ] as const - ).map((option) => { - const isActive = viewMode === option.value; - return ( - alpha(t.palette.text.primary, 0.5), - transition: 'all 0.2s', - '&:hover': { - backgroundColor: 'surface.elevated', - color: 'text.primary', - }, - }} - > - - {option.label} - - - ); - })} - - {minerExists && ( + {/* ── Back (icon button) ───────────────────────────────── */} + + + {/* ── Identity header (watch action pinned top-right) ──── */} + - )} - + ) : undefined + } + /> + + {/* ── Headline performance band (with peer percentiles) ── */} + + + {/* ── What to do next — the prioritised action list ────── */} + + + {/* ── Track toggle — governs the repositories table and the + contribution tabs below (not the hero / next-steps / risk). */} + + + + + {/* ── Repositories — standing + unlock progress ────────── */} + + - {/* ── Two-column shell: main + sticky identity rail ──── */} + {/* ── Contribution detail: tabs (left) · range (right) ─── + The 1D/7D/30D range filters these contribution lists and the + activity heatmap by date. */} - {/* Identity rail — right sidebar on desktop (sticky); ordered - first on mobile so identity is not buried below the page. */} - - + {viewMode === 'issues' ? ( + + ) : ( + + )} + + + + ({ + label: r.label, + href: buildRangeHref(r.value), + active: r.value === activeRange.value, + }))} + /> + - {/* Main column */} - - {/* Centerpiece — per-repository standings */} - - - {/* Reduced detail tabs */} - - - {viewMode === 'issues' ? ( - - ) : ( - - )} - - - - - - {activeTab === 'pull-requests' && viewMode === 'prs' && ( - - )} - {activeTab === 'open-issues' && viewMode === 'issues' && ( - - )} - {activeTab === 'activity' && ( - - )} - - + + {activeTab === 'pull-requests' && viewMode === 'prs' && ( + + )} + {activeTab === 'open-issues' && viewMode === 'issues' && ( + + )} + {activeTab === 'activity' && ( + + )} diff --git a/src/utils/format.ts b/src/utils/format.ts index 3abb980a..84d9937f 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -56,6 +56,29 @@ export const formatDate = (dateStr: string | null | undefined): string => { return format(new Date(dateStr), 'MMM d, yyyy'); }; +/** + * Compact relative "time ago" label — "just now", "5m ago", "3h ago", + * "2d ago", "4mo ago" (returns "—" when missing/invalid). Shared by the + * leaderboard's last-active column and the miner dashboard so recency reads + * identically across the app. + */ +export const formatRelativeTimeAgo = ( + iso: string | null | undefined, +): string => { + if (!iso) return '—'; + const t = Date.parse(iso); + if (!Number.isFinite(t)) return '—'; + const mins = Math.floor((Date.now() - t) / 60_000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + return `${months}mo ago`; +}; + export const formatUsdEstimate = ( value: number | null | undefined, options?: { includeApproxPrefix?: boolean; showZero?: boolean }, diff --git a/src/utils/minerBenchmark.ts b/src/utils/minerBenchmark.ts new file mode 100644 index 00000000..e04a65d7 --- /dev/null +++ b/src/utils/minerBenchmark.ts @@ -0,0 +1,119 @@ +/** + * Peer benchmarking for the miner dashboard. + * + * Turns the network-wide leaderboard (`useAllMiners` → `MinerEvaluation[]`) + * into a per-metric percentile standing for a single miner, so absolute + * figures (a score of 12.5, 41 merged PRs) gain meaning — "top 8%" tells a + * miner far more than the raw number alone. + * + * ⚠️ Read float metrics from the leaderboard list rows only. The single-miner + * endpoint serves its SUM'd float columns as corrupt strings (see + * `aggregateMinerTotals`); the list rows are clean. Counts and credibility are + * clean on both, but we rank against the list either way. + * + * No React, no MUI — pure and unit-testable. + */ +import type { MinerEvaluation } from '../api/models/Dashboard'; +import { blendedSuccessRate } from './minerProgress'; + +/** Coerce the API's stringy numerics to a finite number (0 on failure). */ +const toNum = (value: unknown): number => { + const n = typeof value === 'string' ? parseFloat(value) : Number(value); + return Number.isFinite(n) ? n : 0; +}; + +/** A miner's standing on one metric, higher value = better rank. */ +export interface Percentile { + /** 1-based ordinal; ties share the best (lowest) rank. */ + rank: number; + /** Size of the ranked field. */ + total: number; + /** Fraction of the field strictly below this miner, 0–1 (1 = best). */ + pct: number; + /** Fraction from the top, 0–1 (smaller = better). `rank / total`. */ + topPct: number; + /** Human label, e.g. "top 8%" or "bottom 12%". */ + label: string; +} + +export interface MinerBenchmark { + combinedScore: Percentile | null; + credibility: Percentile | null; + mergedPrs: Percentile | null; + solvedIssues: Percentile | null; + earnings: Percentile | null; +} + +/** Combined OSS + issue-discovery score — matches `computeNetworkRank`. */ +const combinedScoreOf = (m: MinerEvaluation): number => + toNum(m.totalScore) + toNum(m.issueDiscoveryScore); + +const pctLabel = (fraction: number): string => { + // Round to a whole percent, but never 0% — a leader is "top 1%", not "top 0%". + const whole = Math.max(1, Math.round(fraction * 100)); + return `${whole}%`; +}; + +/** + * Percentile standing for one miner on one metric. Returns `null` when the + * field is empty/undefined or the miner isn't in it. + */ +export const percentileFor = ( + allMiners: MinerEvaluation[] | undefined, + githubId: string, + valueOf: (m: MinerEvaluation) => number, +): Percentile | null => { + if (!allMiners || allMiners.length === 0) return null; + const self = allMiners.find((m) => m.githubId === githubId); + if (!self) return null; + + const total = allMiners.length; + const mine = valueOf(self); + let better = 0; // strictly greater value + let below = 0; // strictly lesser value + for (const m of allMiners) { + const v = valueOf(m); + if (v > mine) better += 1; + else if (v < mine) below += 1; + } + + const rank = better + 1; // ties share the best rank + const topPct = rank / total; + const pct = below / total; + const label = + topPct <= 0.5 + ? `top ${pctLabel(topPct)}` + : `bottom ${pctLabel((total - rank + 1) / total)}`; + + return { rank, total, pct, topPct, label }; +}; + +/** + * Percentile standing across every dashboard metric. Each entry is `null` when + * it can't be computed, so callers render "—". + */ +export const computeMinerBenchmark = ( + allMiners: MinerEvaluation[] | undefined, + githubId: string, +): MinerBenchmark => ({ + combinedScore: percentileFor(allMiners, githubId, combinedScoreOf), + // Rank peers by the same merged/total success rate the stat band displays — + // not the upstream `credibility` field — so the badge matches the figure. + credibility: percentileFor(allMiners, githubId, (m) => + blendedSuccessRate( + toNum(m.totalMergedPrs), + toNum(m.totalPrs), + toNum(m.totalSolvedIssues), + toNum(m.totalSolvedIssues) + + toNum(m.totalClosedIssues) + + toNum(m.totalOpenIssues), + toNum(m.totalScore), + toNum(m.issueDiscoveryScore), + ), + ), + mergedPrs: percentileFor(allMiners, githubId, (m) => toNum(m.totalMergedPrs)), + solvedIssues: percentileFor(allMiners, githubId, (m) => + toNum(m.totalSolvedIssues), + ), + earnings: percentileFor(allMiners, githubId, (m) => toNum(m.usdPerDay)), +}); diff --git a/src/utils/minerNextSteps.ts b/src/utils/minerNextSteps.ts new file mode 100644 index 00000000..fe373c09 --- /dev/null +++ b/src/utils/minerNextSteps.ts @@ -0,0 +1,386 @@ +/** + * "What to do next" — the dashboard's single, prioritised action list. + * + * This consolidates what used to be three separate sections (earning levers, + * insights, and the open-PR-risk prompt) into one severity-ranked set of + * actionable steps. The derivations are lifted verbatim from those components + * so the (policy-accurate) maths is unchanged; only the presentation is unified. + * + * Pure — no React, no MUI. Each step can carry an `anchor` naming the page + * section a miner should jump to act on it. + */ +import type { + CommitLog, + MinerEvaluation, + MinerRepositoryEvaluation, + RepositoryConfig, +} from '../api/models/Dashboard'; +import { + buildDecayProjection, + resolveDecayParams, +} from '../components/prs/prTimeDecayModel'; +import { + computeOpenPrAllowance, + getEligibilityThresholds, +} from './minerProgress'; + +/** Section ids a step can scroll to. Kept in sync with the page anchors. */ +export const SECTION_ANCHORS = { + repositories: 'repositories', + contributions: 'contributions', +} as const; + +export type SectionAnchorId = + (typeof SECTION_ANCHORS)[keyof typeof SECTION_ANCHORS]; + +export type NextStepTone = 'critical' | 'warning' | 'tip' | 'good'; + +export interface NextStep { + id: string; + /** Higher surfaces first. */ + severity: number; + tone: NextStepTone; + /** Short headline, names the repo where relevant. */ + title: string; + /** Imperative, plain-language guidance. */ + action: string; + /** Section the miner should jump to in order to act. */ + anchor?: SectionAnchorId; +} + +const toNum = (v: unknown): number => { + const n = typeof v === 'string' ? parseFloat(v) : Number(v); + return Number.isFinite(n) ? n : 0; +}; + +/** + * Score-lever steps: time decay, credibility, review quality, label multiplier + * and the open-PR spam gate. Lifted from the former `MinerScoreLevers`. + */ +export const buildLeverSteps = ( + minerStats: MinerEvaluation | undefined, + prs: CommitLog[] | undefined, + configByRepo: Map, +): NextStep[] => { + if (!minerStats) return []; + const mergedPrs = (prs ?? []).filter( + (p) => p.prState === 'MERGED' && p.mergedAt, + ); + const out: NextStep[] = []; + + // ── Time decay — score-weighted retention across merged PRs ────────── + if (mergedPrs.length > 0) { + let current = 0; + let potential = 0; + for (const pr of mergedPrs) { + const params = resolveDecayParams( + null, + configByRepo.get((pr.repository || '').toLowerCase()), + ); + const proj = buildDecayProjection( + { + mergedAt: pr.mergedAt, + prState: pr.prState, + timeDecayMultiplier: pr.timeDecayMultiplier, + earnedScore: pr.score, + }, + params, + ); + const mult = proj.chartNowMultiplier; + if (!proj.inWindow) continue; + const score = toNum(pr.score); + if (mult && mult > 0 && score > 0) { + current += score; + potential += score / mult; + } + } + if (potential > 0) { + const retain = current / potential; + const dragPct = Math.round((1 - retain) * 100); + const draggy = dragPct >= 8; + out.push({ + id: 'decay', + severity: 100 + dragPct, + tone: draggy ? 'warning' : 'good', + title: `Time decay · ${Math.round(retain * 100)}% retained`, + action: draggy + ? `Aging has shaved ~${dragPct}% off your in-window value — ship a fresh PR to refresh it.` + : 'Your merged work is still fresh — keep the cadence up.', + anchor: draggy ? SECTION_ANCHORS.contributions : undefined, + }); + } + } + + // ── Credibility (OSS merge-rate) vs the 80% gate ───────────────────── + const cred = toNum(minerStats.credibility); + if (cred > 0 || (minerStats.totalMergedPrs ?? 0) > 0) { + const pct = Math.round(cred * 100); + const ok = cred >= 0.8; + out.push({ + id: 'credibility', + severity: ok ? 20 : 90 + (80 - pct), + tone: ok ? 'good' : 'warning', + title: `Credibility · ${pct}%`, + action: ok + ? 'Above the 80% bar — merged work counts at full weight.' + : 'Below the 80% eligibility bar — merge PRs and avoid closing them unmerged.', + anchor: ok ? undefined : SECTION_ANCHORS.repositories, + }); + } + + // ── Review quality — score-weighted average penalty ────────────────── + let revW = 0; + let revWeight = 0; + for (const pr of mergedPrs) { + const m = pr.reviewQualityMultiplier; + if (m == null) continue; + const w = toNum(pr.score) || 1; + revW += toNum(m) * w; + revWeight += w; + } + if (revWeight > 0) { + const avg = revW / revWeight; + const costPct = Math.round((1 - avg) * 100); + const costly = costPct >= 5; + out.push({ + id: 'review', + severity: costly ? 60 + costPct : 15, + tone: costly ? 'warning' : 'good', + title: `Review quality · ${avg.toFixed(2)}×`, + action: costly + ? `Change-requests cost you ~${costPct}% — resolve maintainer feedback in fewer rounds.` + : 'Clean reviews — little to no penalty.', + anchor: costly ? SECTION_ANCHORS.contributions : undefined, + }); + } + + // ── Label multiplier — boost or drag ───────────────────────────────── + let labW = 0; + let labWeight = 0; + for (const pr of mergedPrs) { + const m = pr.labelMultiplier; + if (m == null) continue; + const w = toNum(pr.score) || 1; + labW += toNum(m) * w; + labWeight += w; + } + if (labWeight > 0) { + const avg = labW / labWeight; + const draggy = avg < 0.98; + out.push({ + id: 'label', + severity: draggy ? 55 + Math.round((1 - avg) * 100) : 12, + tone: avg > 1.02 ? 'good' : draggy ? 'warning' : 'tip', + title: `Label multiplier · ${avg.toFixed(2)}×`, + action: + avg > 1.02 + ? 'Your PRs land high-value labels — keep targeting them.' + : draggy + ? 'Labels are dragging score — favour feature/bug work over low-weight labels.' + : 'Neutral labelling.', + anchor: draggy ? SECTION_ANCHORS.contributions : undefined, + }); + } + + // ── Open-PR spam — binary repo kill when over allowance ────────────── + const breaches: string[] = []; + for (const r of minerStats.repositories ?? []) { + const open = toNum(r.totalOpenPrs); + if (open <= 0) continue; + const allowance = computeOpenPrAllowance( + toNum(r.totalTokenScore), + getEligibilityThresholds( + configByRepo.get(r.repositoryFullName.toLowerCase()), + ), + ); + if (open > allowance) + breaches.push(`${r.repositoryFullName} (${open}/${allowance})`); + } + if (breaches.length > 0) { + out.push({ + id: 'spam', + severity: 200, + tone: 'critical', + title: 'Open-PR limit exceeded', + action: `Earnings are zeroed in ${breaches.join(', ')} — merge or close PRs to recover.`, + anchor: SECTION_ANCHORS.repositories, + }); + } + + return out; +}; + +/** Per-repo eligibility / score / credibility accessors, mode-aware. */ +const eligibleFor = ( + repo: MinerRepositoryEvaluation, + isIssueMode: boolean, +): boolean => (isIssueMode ? repo.isIssueEligible : repo.isEligible); + +const scoreFor = ( + repo: MinerRepositoryEvaluation, + isIssueMode: boolean, +): number => (isIssueMode ? repo.issueDiscoveryScore : repo.totalScore); + +const credibilityFor = ( + repo: MinerRepositoryEvaluation, + isIssueMode: boolean, +): number => (isIssueMode ? repo.issueCredibility : repo.credibility); + +/** + * Per-repository eligibility steps. Lifted from the former `MinerInsightsCard` + * `buildInsights`. Eligibility verdicts come from the server-computed + * `isEligible` / `isIssueEligible` flags — no fixed threshold is printed. + */ +export const buildEligibilitySteps = ( + repositories: MinerRepositoryEvaluation[], + isIssueMode: boolean, + weightsByRepo: Map = new Map(), + opts: { idPrefix?: string; trackTag?: string } = {}, +): NextStep[] => { + const { idPrefix = '', trackTag } = opts; + // Namespace ids and tag titles so the same builder can contribute both the + // OSS and the Discovery tracks to one combined list without colliding. + const finish = (steps: NextStep[]): NextStep[] => + idPrefix || trackTag + ? steps.map((s) => ({ + ...s, + id: `${idPrefix}${s.id}`, + title: trackTag ? `${trackTag} · ${s.title}` : s.title, + })) + : steps; + const out: NextStep[] = []; + + if (repositories.length === 0) { + out.push({ + id: 'no-repos', + severity: 40, + tone: 'tip', + title: 'No repository evaluations yet', + action: isIssueMode + ? 'Discover and solve issues in tracked repositories to start earning a per-repository standing.' + : 'Open and merge pull requests in tracked repositories to start earning a per-repository standing.', + }); + return finish(out); + } + + const eligibleRepos = repositories.filter((r) => eligibleFor(r, isIssueMode)); + const ineligibleRepos = repositories.filter( + (r) => !eligibleFor(r, isIssueMode), + ); + + // Ineligible repos with a server-supplied reason — name the repo + reason. + ineligibleRepos + .filter((r) => r.failedReason) + .slice(0, 2) + .forEach((repo, index) => { + out.push({ + id: `ineligible-${repo.repositoryFullName}`, + severity: 95 - index, + tone: 'warning', + title: `Ineligible in ${repo.repositoryFullName}`, + action: `${repo.failedReason} Credibility here is ${( + credibilityFor(repo, isIssueMode) * 100 + ).toFixed(1)}% — lift it to clear the gate.`, + anchor: SECTION_ANCHORS.repositories, + }); + }); + + // Ineligible with no reason string — generic prompt, still repo-named. + const unexplainedIneligible = ineligibleRepos.find((r) => !r.failedReason); + if (unexplainedIneligible && out.length < 3) { + out.push({ + id: `ineligible-generic-${unexplainedIneligible.repositoryFullName}`, + severity: 80, + tone: 'warning', + title: `Not yet eligible in ${unexplainedIneligible.repositoryFullName}`, + action: isIssueMode + ? "Raise issue credibility and solved-issue volume to clear this repository's eligibility gate." + : "Raise merge credibility and contribution volume to clear this repository's eligibility gate.", + anchor: SECTION_ANCHORS.repositories, + }); + } + + // Strongest eligible repo — an achievement that names where the miner leads. + if (eligibleRepos.length > 0) { + const topRepo = eligibleRepos.reduce((best, current) => + scoreFor(current, isIssueMode) > scoreFor(best, isIssueMode) + ? current + : best, + ); + out.push({ + id: `top-repo-${topRepo.repositoryFullName}`, + severity: 55, + tone: 'good', + title: `Strongest in ${topRepo.repositoryFullName}`, + action: `${ + isIssueMode ? 'Issue-discovery' : 'OSS' + } score here is ${scoreFor(topRepo, isIssueMode).toFixed(2)} at ${( + credibilityFor(topRepo, isIssueMode) * 100 + ).toFixed(1)}% credibility — keep this consistency to maximise earnings.`, + anchor: SECTION_ANCHORS.repositories, + }); + } + + // Eligible-everywhere achievement, or a coverage tip. + if (eligibleRepos.length === repositories.length) { + out.push({ + id: 'eligible-all', + severity: 45, + tone: 'good', + title: 'Eligible across every repository', + action: `You clear the gate in all ${repositories.length} evaluated ${ + repositories.length === 1 ? 'repository' : 'repositories' + }.`, + }); + } else if (eligibleRepos.length > 0) { + out.push({ + id: 'coverage-tip', + severity: 35, + tone: 'tip', + title: 'Expand eligible coverage', + action: `You are eligible in ${eligibleRepos.length} of ${ + repositories.length + } repositories. Lifting credibility in the rest unlocks more of the network reward pool.`, + anchor: SECTION_ANCHORS.repositories, + }); + } + + // Highest-paying repo — point the miner at the biggest reward pool. + const topPaying = repositories + .map((r) => ({ + repo: r, + pay: weightsByRepo.get(r.repositoryFullName.toLowerCase()) ?? 0, + })) + .filter((x) => x.pay > 0) + .sort((a, b) => b.pay - a.pay)[0]; + if (topPaying) { + const pct = (topPaying.pay * 100).toFixed(topPaying.pay >= 0.1 ? 1 : 2); + const eligibleThere = eligibleFor(topPaying.repo, isIssueMode); + out.push({ + id: `pay-${topPaying.repo.repositoryFullName}`, + severity: 58, + tone: eligibleThere ? 'good' : 'tip', + title: eligibleThere + ? `Keep shipping to ${topPaying.repo.repositoryFullName}` + : `${topPaying.repo.repositoryFullName} pays the most`, + action: eligibleThere + ? `Your highest-paying repo distributes ${pct}% of the OSS reward pool and you're eligible — concentrate effort here to maximise emissions.` + : `It distributes ${pct}% of the OSS reward pool — the biggest opportunity. Clear its eligibility gate to start earning from it.`, + anchor: SECTION_ANCHORS.repositories, + }); + } + + return finish(out); +}; + +/** Concat, dedupe by id (first wins), and sort by severity descending. */ +export const mergeNextSteps = (...sources: NextStep[][]): NextStep[] => { + const seen = new Set(); + const merged: NextStep[] = []; + for (const step of sources.flat()) { + if (seen.has(step.id)) continue; + seen.add(step.id); + merged.push(step); + } + return merged.sort((a, b) => b.severity - a.severity); +}; diff --git a/src/utils/minerProgress.ts b/src/utils/minerProgress.ts new file mode 100644 index 00000000..5057c6a4 --- /dev/null +++ b/src/utils/minerProgress.ts @@ -0,0 +1,345 @@ +/** + * Pure helpers for the miner performance dashboard. + * + * These compute the "unlock progress", open-PR risk allowance and network + * standing that the dashboard visualises. The validator removed the old + * Bronze/Silver/Gold tier ladder; the live API exposes no tier fields. The + * real "unlock" mechanic is the per-repository eligibility gate — a miner + * starts earning PR (or issue-discovery) score in a repo once they clear a + * minimum count of *valid* contributions AND a minimum credibility. This file + * turns those gates into progress the UI can render. + * + * Thresholds come from `repoConfig.ts` (which mirrors gittensor's + * `RepoEligibilityConfig` + `constants.py`), so nothing here hardcodes the + * 3 / 0.80 defaults — a repo override flows through automatically. + * + * No React, no MUI — keep this unit-testable and side-effect free. + */ +import type { + CommitLog, + MinerEvaluation, + MinerRepositoryEvaluation, + RepositoryConfig, +} from '../api/models/Dashboard'; +import { ELIGIBILITY_FIELD_DEFS } from './repoConfig'; + +export type ViewMode = 'prs' | 'issues'; + +/** Coerce the API's stringy numerics to a finite number (0 on failure). */ +const toNum = (value: unknown): number => { + const n = typeof value === 'string' ? parseFloat(value) : Number(value); + return Number.isFinite(n) ? n : 0; +}; + +const ELIGIBILITY_DEFAULTS: Record = Object.fromEntries( + ELIGIBILITY_FIELD_DEFS.map((def) => [def.key, def.default]), +); + +/** Resolved eligibility-gate thresholds for one repository. */ +export interface EligibilityThresholds { + minValidMergedPrs: number; + minCredibility: number; + minTokenScoreForBaseScore: number; + excessivePrPenaltyBaseThreshold: number; + openPrThresholdTokenScore: number; + maxOpenPrThreshold: number; + minValidSolvedIssues: number; + minIssueCredibility: number; +} + +const resolve = (config: RepositoryConfig | undefined, key: string): number => { + const override = + config?.eligibility?.[key as keyof typeof config.eligibility]; + return typeof override === 'number' && Number.isFinite(override) + ? override + : (ELIGIBILITY_DEFAULTS[key] ?? 0); +}; + +/** + * Resolve a repository's eligibility-gate thresholds, applying any per-repo + * override on top of the global defaults from `repoConfig.ts`. + */ +export const getEligibilityThresholds = ( + config?: RepositoryConfig, +): EligibilityThresholds => ({ + minValidMergedPrs: resolve(config, 'min_valid_merged_prs'), + minCredibility: resolve(config, 'min_credibility'), + minTokenScoreForBaseScore: resolve(config, 'min_token_score_for_base_score'), + excessivePrPenaltyBaseThreshold: resolve( + config, + 'excessive_pr_penalty_base_threshold', + ), + openPrThresholdTokenScore: resolve(config, 'open_pr_threshold_token_score'), + maxOpenPrThreshold: resolve(config, 'max_open_pr_threshold'), + minValidSolvedIssues: resolve(config, 'min_valid_solved_issues'), + minIssueCredibility: resolve(config, 'min_issue_credibility'), +}); + +/** + * Count a miner's *valid* merged PRs per repository — merged PRs whose token + * score clears the repo's `min_token_score_for_base_score` gate. This matches + * the validator's eligibility definition (raw merged count is not enough). + * + * `thresholdFor` lets callers apply per-repo token thresholds; when omitted the + * global default is used for every repo. + */ +export const countValidMergedPrsByRepo = ( + prs: CommitLog[] | undefined, + thresholdFor?: (repositoryFullName: string) => number, +): Map => { + const counts = new Map(); + if (!prs) return counts; + const defaultThreshold = + ELIGIBILITY_DEFAULTS['min_token_score_for_base_score'] ?? 5; + for (const pr of prs) { + if (!pr.mergedAt) continue; + const threshold = thresholdFor + ? thresholdFor(pr.repository) + : defaultThreshold; + if (toNum(pr.tokenScore) >= threshold) { + // Eval rows lowercase the repo name while PR rows keep GitHub's original + // casing — key by lowercase so per-repo lookups always match. + const key = pr.repository.toLowerCase(); + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + return counts; +}; + +/** Progress of a single gate (count or ratio) toward its threshold. */ +export interface GateProgress { + /** Current value (PR count, or credibility as a 0–1 ratio). */ + have: number; + /** Threshold the value must reach. */ + need: number; + /** How much is still missing (never negative). */ + remaining: number; + /** Completion fraction, clamped to [0, 1]. */ + pct: number; + met: boolean; +} + +const gate = (have: number, need: number): GateProgress => { + const remaining = Math.max(0, need - have); + const pct = need <= 0 ? 1 : Math.min(1, Math.max(0, have / need)); + return { have, need, remaining, pct, met: have >= need }; +}; + +/** A repository's unlock standing for the active track (PRs or issues). */ +export interface RepoUnlock { + /** Server-authoritative eligibility flag for the active track. */ + unlocked: boolean; + /** Contribution-count gate (valid merged PRs / valid solved issues). */ + count: GateProgress; + /** Credibility gate (0–1 ratio). */ + credibility: GateProgress; + /** Overall progress = mean of the two gate fractions, clamped to [0, 1]. */ + overallPct: number; +} + +/** + * Compute a repository's unlock progress for the active track. + * + * The `unlocked` flag comes straight from the server (`isEligible` / + * `isIssueEligible`) — the progress bars are the *explanation* of how close an + * un-unlocked repo is, never a re-derivation of the verdict. + */ +export const computeRepoUnlock = ( + repo: MinerRepositoryEvaluation, + validCount: number, + thresholds: EligibilityThresholds, + mode: ViewMode, +): RepoUnlock => { + const isIssue = mode === 'issues'; + const count = isIssue + ? gate(toNum(repo.totalValidSolvedIssues), thresholds.minValidSolvedIssues) + : gate(validCount, thresholds.minValidMergedPrs); + const credibility = isIssue + ? gate(toNum(repo.issueCredibility), thresholds.minIssueCredibility) + : gate(toNum(repo.credibility), thresholds.minCredibility); + return { + unlocked: isIssue ? repo.isIssueEligible : repo.isEligible, + count, + credibility, + overallPct: (count.pct + credibility.pct) / 2, + }; +}; + +/** + * The number of open PRs a miner may keep in a repo before the excessive-open-PR + * gate zeroes their contribution score: `min(base + floor(tokenScore / + * perSlot), max)`. Higher lifetime token score buys more open-PR headroom. + */ +export const computeOpenPrAllowance = ( + totalTokenScore: number, + thresholds: EligibilityThresholds, +): number => { + const perSlot = thresholds.openPrThresholdTokenScore; + const bonus = + perSlot > 0 ? Math.floor(Math.max(0, totalTokenScore) / perSlot) : 0; + return Math.min( + thresholds.excessivePrPenaltyBaseThreshold + bonus, + thresholds.maxOpenPrThreshold, + ); +}; + +/** + * Clean, miner-wide totals for the headline stat band. + * + * ⚠️ The single-miner endpoint (`GET /miners/:id`) returns its SUM'd float + * columns (total/base/issue score, additions/deletions, collateral, token + * score) as concatenated garbage strings — `Number()` of them is NaN. Only its + * integer counts, credibility (MAX) and earnings are trustworthy. So those + * float aggregates are taken from the clean leaderboard row (`GET /miners`) + * when the miner is registered, falling back to summing the per-repo + * `repositories[]` rows (which are clean) otherwise. + */ +export interface MinerTotals { + ossScore: number; + baseScore: number; + discScore: number; + combinedScore: number; + collateral: number; + additions: number; + deletions: number; + mergedPrs: number; + openPrs: number; + closedPrs: number; + prs: number; + solvedIssues: number; + closedIssues: number; + openIssues: number; + ossCred: number; + issueCred: number; + /** Score-weighted blend of the two credibilities (max fallback). */ + blendedCred: number; +} + +export const aggregateMinerTotals = ( + single: MinerEvaluation | undefined, + listed: MinerEvaluation | undefined, +): MinerTotals => { + const sumRepo = (field: keyof MinerRepositoryEvaluation): number => + (single?.repositories ?? []).reduce((s, r) => s + toNum(r[field]), 0); + + // Float aggregates: clean list row first, else sum the clean per-repo rows. + const ossScore = listed ? toNum(listed.totalScore) : sumRepo('totalScore'); + const baseScore = listed + ? toNum(listed.baseTotalScore) + : sumRepo('baseTotalScore'); + const discScore = listed + ? toNum(listed.issueDiscoveryScore) + : sumRepo('issueDiscoveryScore'); + const collateral = listed + ? toNum(listed.totalCollateralScore) + : sumRepo('totalCollateralScore'); + // additions/deletions only exist miner-wide (not per repo) — list row only. + const additions = toNum(listed?.totalAdditions); + const deletions = toNum(listed?.totalDeletions); + + // Credibility (MAX) is clean on both endpoints; prefer the list row. + const src = listed ?? single; + const ossCred = toNum(src?.credibility); + const issueCred = toNum(src?.issueCredibility); + const denom = ossScore + discScore; + const blendedCred = + denom > 0 + ? (ossScore * ossCred + discScore * issueCred) / denom + : Math.max(ossCred, issueCred); + + // Contribution counts: sum the authoritative per-repo rows when we have them + // (this is exactly what the repositories table shows). The miner-wide/list + // totals can disagree — they cover a wider scope than the tracked repos — so + // the per-repo sum is the one that matches the rest of the dashboard. Fall + // back to the flat totals only when there are no per-repo rows. + const hasRepos = (single?.repositories ?? []).length > 0; + const mergedPrs = hasRepos + ? sumRepo('totalMergedPrs') + : toNum(src?.totalMergedPrs); + const openPrs = hasRepos ? sumRepo('totalOpenPrs') : toNum(src?.totalOpenPrs); + const closedPrs = hasRepos + ? sumRepo('totalClosedPrs') + : toNum(src?.totalClosedPrs); + const prs = hasRepos ? mergedPrs + openPrs + closedPrs : toNum(src?.totalPrs); + const solvedIssues = hasRepos + ? sumRepo('totalSolvedIssues') + : toNum(src?.totalSolvedIssues); + const openIssues = hasRepos + ? sumRepo('totalOpenIssues') + : toNum(src?.totalOpenIssues); + const closedIssues = hasRepos + ? sumRepo('totalClosedIssues') + : toNum(src?.totalClosedIssues); + + return { + ossScore, + baseScore, + discScore, + combinedScore: ossScore + discScore, + collateral, + additions, + deletions, + mergedPrs, + openPrs, + closedPrs, + prs, + solvedIssues, + closedIssues, + openIssues, + ossCred, + issueCred, + blendedCred, + }; +}; + +/** + * Dashboard "success rate": merged PRs over *all* PRs blended with solved issues + * over *all* issues, weighted by each track's score so the dominant track leads. + * Open (still-unresolved) PRs/issues count against the rate — they are pending, + * not successes — so a miner with merges still in flight reads below 100%. + * Falls back to the larger single-track rate when neither track has scored yet. + * + * This intentionally derives the rate from real outcome counts rather than the + * upstream `credibility` field (which is a separately-modelled author trust + * score, not a merge ratio). + */ +export const blendedSuccessRate = ( + mergedPrs: number, + totalPrs: number, + solvedIssues: number, + totalIssues: number, + ossScore: number, + discScore: number, +): number => { + const ossRate = totalPrs > 0 ? mergedPrs / totalPrs : 0; + const discRate = totalIssues > 0 ? solvedIssues / totalIssues : 0; + const denom = ossScore + discScore; + if (denom > 0) return (ossScore * ossRate + discScore * discRate) / denom; + return Math.max(ossRate, discRate); +}; + +export interface NetworkRank { + rank: number; + total: number; +} + +/** + * The miner's standing on the subnet as a 1-based ordinal, computed by ranking + * every active miner by combined OSS + issue-discovery score. + * + * Note: we deliberately do NOT use `metagraphRank` here — in Bittensor that is + * the normalised rank *metric* (0–1), not a leaderboard position, so it would + * render a misleading "#0". + */ +export const computeNetworkRank = ( + allMiners: MinerEvaluation[] | undefined, + githubId: string, +): NetworkRank | null => { + if (!allMiners || allMiners.length === 0) return null; + const total = allMiners.length; + const combined = (m: MinerEvaluation): number => + toNum(m.totalScore) + toNum(m.issueDiscoveryScore); + const ordered = allMiners.slice().sort((a, b) => combined(b) - combined(a)); + const idx = ordered.findIndex((m) => m.githubId === githubId); + return idx === -1 ? null : { rank: idx + 1, total }; +}; diff --git a/src/utils/multiplierDefs.ts b/src/utils/multiplierDefs.ts index 88f0e83e..2c90ec5b 100644 --- a/src/utils/multiplierDefs.ts +++ b/src/utils/multiplierDefs.ts @@ -58,7 +58,7 @@ const PILL_CONFIGS: PillConfig[] = [ label: 'cred', field: 'credibilityMultiplier', title: 'Credibility', - desc: 'Based on your PR success rate, scaled to reward consistency.', + desc: 'Based on your PR credibility, scaled to reward consistency.', }, { key: 'issue',