diff --git a/src/app/api/github-bio/route.ts b/src/app/api/github-bio/route.ts new file mode 100644 index 0000000..a7fbb4b --- /dev/null +++ b/src/app/api/github-bio/route.ts @@ -0,0 +1,42 @@ +/* A miner's GitHub profile blurb (bio + display name) for the miner detail modal. + * Fetched via the app's GitHub client and cached in-memory (bios rarely change). + * Public GitHub user data only — NOT the local auth/users DB. */ + +import { NextRequest, NextResponse } from 'next/server'; +import { withRotation } from '@/lib/github'; + +export const dynamic = 'force-dynamic'; + +const TTL_MS = 6 * 60 * 60 * 1000; // 6h + +interface Profile { + bio: string | null; + name: string | null; + followers: number | null; + following: number | null; +} +const cache = new Map(); +const EMPTY: Profile = { bio: null, name: null, followers: null, following: null }; + +export async function GET(req: NextRequest) { + const login = (new URL(req.url).searchParams.get('login') ?? '').trim(); + if (!login) return NextResponse.json({ error: 'login required' }, { status: 400 }); + + const key = login.toLowerCase(); + const hit = cache.get(key); + if (hit && Date.now() - hit.at < TTL_MS) return NextResponse.json(hit.profile); + + try { + const { data } = await withRotation((octokit) => octokit.users.getByUsername({ username: login })); + const profile: Profile = { + bio: typeof data.bio === 'string' && data.bio.trim() ? data.bio.trim() : null, + name: typeof data.name === 'string' && data.name.trim() ? data.name.trim() : null, + followers: typeof data.followers === 'number' ? data.followers : null, + following: typeof data.following === 'number' ? data.following : null, + }; + cache.set(key, { at: Date.now(), profile }); + return NextResponse.json(profile); + } catch { + return NextResponse.json(EMPTY); + } +} diff --git a/src/app/api/gt/repos/[owner]/[name]/miners/route.ts b/src/app/api/gt/repos/[owner]/[name]/miners/route.ts index cd3a8f9..3172f8f 100644 --- a/src/app/api/gt/repos/[owner]/[name]/miners/route.ts +++ b/src/app/api/gt/repos/[owner]/[name]/miners/route.ts @@ -46,6 +46,22 @@ interface UpstreamRepoMiner { total_merged_prs?: string | number | null; totalPrs?: string | number | null; total_prs?: string | number | null; + issueDiscoveryScore?: string | number | null; + issue_discovery_score?: string | number | null; + issueTokenScore?: string | number | null; + issue_token_score?: string | number | null; + issueCredibility?: string | number | null; + issue_credibility?: string | number | null; + isIssueEligible?: boolean | null; + is_issue_eligible?: boolean | null; + totalSolvedIssues?: string | number | null; + total_solved_issues?: string | number | null; + totalValidSolvedIssues?: string | number | null; + total_valid_solved_issues?: string | number | null; + totalClosedIssues?: string | number | null; + total_closed_issues?: string | number | null; + totalOpenIssues?: string | number | null; + total_open_issues?: string | number | null; isEligible?: boolean | null; is_eligible?: boolean | null; failedReason?: string | null; @@ -184,6 +200,10 @@ function repoScopedCredibility(row: UpstreamRepoMiner): number { return num(row.credibility ?? row.repoCredibility ?? row.repo_credibility ?? row.prCredibility ?? row.pr_credibility); } +function repoScopedIssueCredibility(row: UpstreamRepoMiner): number { + return num(row.issueCredibility ?? row.issue_credibility); +} + function meaningfulRepoMiner(row: { isEligible: boolean; score: number; @@ -206,6 +226,37 @@ function meaningfulRepoMiner(row: { ); } +function meaningfulRepoEvaluation(row: { + isEligible: boolean; + isIssueEligible?: boolean; + score: number; + issueDiscoveryScore?: number; + baseScore: number; + collateralScore: number; + prCount: number; + openPrCount: number; + closedPrCount: number; + totalPrCount: number; + totalSolvedIssues?: number; + totalValidSolvedIssues?: number; + totalClosedIssues?: number; + totalOpenIssues?: number; + usdPerDay?: number; + taoPerDay?: number; +}): boolean { + return ( + meaningfulRepoMiner(row) || + Boolean(row.isIssueEligible) || + (row.issueDiscoveryScore ?? 0) > 0 || + (row.totalSolvedIssues ?? 0) > 0 || + (row.totalValidSolvedIssues ?? 0) > 0 || + (row.totalClosedIssues ?? 0) > 0 || + (row.totalOpenIssues ?? 0) > 0 || + (row.usdPerDay ?? 0) > 0 || + (row.taoPerDay ?? 0) > 0 + ); +} + export async function GET(_req: Request, ctx: { params: Promise<{ owner: string; name: string }> }) { const params = await ctx.params; const fullName = `${params.owner}/${params.name}`; @@ -220,10 +271,11 @@ export async function GET(_req: Request, ctx: { params: Promise<{ owner: string; minersByLogin.set(m.githubUsername.toLowerCase(), m); } - // OSS Contributions: per-repo validator rows. This endpoint already - // includes the repo-scoped score and eligibility gate, so do not rebuild - // the panel from global PR data or global miner score. - const ossContributions = repoMinerRows + // Full per-repo validator rows. The upstream endpoint now carries both + // PR and issue-discovery RepoEvaluation fields, so expose a complete row + // for repo-scoped dashboards while keeping ossContributions filtered for + // the existing repo detail panels. + const repoEvaluations = repoMinerRows .map((r) => { const githubId = stringValue(r.githubId ?? r.github_id); const username = r.githubUsername ?? r.github_username ?? ''; @@ -242,7 +294,14 @@ export async function GET(_req: Request, ctx: { params: Promise<{ owner: string; const openPrCount = num(r.totalOpenPrs ?? r.total_open_prs); const closedPrCount = num(r.totalClosedPrs ?? r.total_closed_prs); const totalPrCount = num(r.totalPrs ?? r.total_prs); + const issueDiscoveryScore = num(r.issueDiscoveryScore ?? r.issue_discovery_score); + const issueTokenScore = num(r.issueTokenScore ?? r.issue_token_score); + const totalSolvedIssues = num(r.totalSolvedIssues ?? r.total_solved_issues); + const totalValidSolvedIssues = num(r.totalValidSolvedIssues ?? r.total_valid_solved_issues); + const totalClosedIssues = num(r.totalClosedIssues ?? r.total_closed_issues); + const totalOpenIssues = num(r.totalOpenIssues ?? r.total_open_issues); const isEligible = (r.isEligible ?? r.is_eligible) === true; + const isIssueEligible = (r.isIssueEligible ?? r.is_issue_eligible) === true; return { githubId, githubUsername: username || m?.githubUsername || githubId, @@ -254,6 +313,14 @@ export async function GET(_req: Request, ctx: { params: Promise<{ owner: string; closedPrCount, totalPrCount, credibility: repoScopedCredibility(r), + issueDiscoveryScore: Number(issueDiscoveryScore.toFixed(2)), + issueTokenScore: Number(issueTokenScore.toFixed(2)), + issueCredibility: repoScopedIssueCredibility(r), + isIssueEligible, + totalSolvedIssues, + totalValidSolvedIssues, + totalClosedIssues, + totalOpenIssues, ossRank: githubId ? shared.ossRankByGithubId.get(githubId) ?? null : null, globalScore: m ? Number(num(m.totalScore).toFixed(2)) : null, uid: Number.isFinite(uidNum) ? uidNum : null, @@ -265,6 +332,10 @@ export async function GET(_req: Request, ctx: { params: Promise<{ owner: string; usdPerDay: num(r.usdPerDay ?? r.usd_per_day), }; }) + .filter(meaningfulRepoEvaluation); + + // OSS Contributions: per-repo PR contribution rows only. + const ossContributions = repoEvaluations .filter(meaningfulRepoMiner) .sort((a, b) => { if ((a.isEligible ? 1 : 0) !== (b.isEligible ? 1 : 0)) return a.isEligible ? -1 : 1; @@ -389,6 +460,7 @@ export async function GET(_req: Request, ctx: { params: Promise<{ owner: string; return NextResponse.json({ fullName, issueDiscoveryEnabled, + repoEvaluations, ossContributions, issueDiscoveries, fetched_at: shared.fetched_at, diff --git a/src/app/api/miner-works/route.ts b/src/app/api/miner-works/route.ts new file mode 100644 index 0000000..5e571a1 --- /dev/null +++ b/src/app/api/miner-works/route.ts @@ -0,0 +1,613 @@ +/* A miner's complete works across all tracked repos — for the miner detail modal. + * + * Pull requests come from the gittensor `/prs` feed (the authoritative scored-PR + * list, carrying author + repo + gittensor score); we cache the full mapped list + * in-memory (30s) with in-flight dedup so per-miner opens never burst upstream, and + * filter it by the requested login / githubId. Issues come from the local issues + * mirror (the same table the explorer's /api/issues uses — NOT the users table), + * queried by author. Both lists are capped. */ + +import { NextRequest, NextResponse } from 'next/server'; +import { getReadDb } from '@/lib/db'; +import { withRotation } from '@/lib/github'; +import type { MinerActivityPoint, MinerIssue, MinerPr, MinerWorksResponse } from '@/types/entities'; + +export const dynamic = 'force-dynamic'; + +const PRS_URL = 'https://api.gittensor.io/prs'; +const TTL_MS = 30_000; +const FETCH_TIMEOUT_MS = 15_000; +/** Per-list cap — the most prolific miner has ~500 works; 1000 returns the full set + * (incl. closed PRs, which sort last and were being truncated) while staying bounded. */ +const MAX = 1000; + +interface UpstreamPr { + pullRequestNumber: number; + pullRequestTitle: string; + repository: string; + author?: string | null; + githubId?: string | null; + hotkey?: string | null; + prCreatedAt: string; + mergedAt: string | null; + prState: string; + additions?: number | null; + deletions?: number | null; + commitCount?: number | null; + score?: string | number | null; + baseScore?: string | number | null; + collateralScore?: string | number | null; + tokenScore?: string | number | null; + totalNodesScored?: string | number | null; + structuralCount?: string | number | null; + structuralScore?: string | number | null; + leafCount?: string | number | null; + leafScore?: string | number | null; + label?: string | null; + labelMultiplier?: string | number | null; + reviewQualityMultiplier?: string | number | null; +} + +/** Internal PR row — the public `MinerPr` plus the keys we filter on. */ +type IndexedPr = MinerPr & { authorLc: string; githubId: string }; + +interface PrCache { + fetched_at: number; + prs: IndexedPr[]; +} + +let prCache: PrCache | null = null; +let inFlight: Promise | null = null; + +function num(v: unknown): number { + const n = typeof v === 'string' ? Number.parseFloat(v) : typeof v === 'number' ? v : 0; + return Number.isFinite(n) ? n : 0; +} + +function deriveState(p: UpstreamPr): 'OPEN' | 'MERGED' | 'CLOSED' { + if (p.mergedAt) return 'MERGED'; + if ((p.prState ?? '').toUpperCase() === 'CLOSED') return 'CLOSED'; + return 'OPEN'; +} + +function parseLinkedIssue(title: string): number | null { + const m = (title ?? '').match(/^\s*#(\d+)\b/); + if (!m) return null; + const n = Number(m[1]); + return Number.isFinite(n) && n > 0 ? n : null; +} + +type GhLabel = { name: string; color?: string }; + +/** Pull GitHub labels (name + hex color) out of a stored JSON blob. Issues keep a + * `labels` column holding the array directly; pulls only have `raw_json`, whose + * `.labels` field carries them. Returns [] on any shape mismatch. */ +function extractLabels(jsonStr: string | null, fromRawJson: boolean): GhLabel[] { + if (!jsonStr) return []; + try { + const parsed = JSON.parse(jsonStr) as unknown; + const arr = fromRawJson ? (parsed as { labels?: unknown })?.labels : parsed; + if (!Array.isArray(arr)) return []; + const out: GhLabel[] = []; + for (const l of arr) { + if (l && typeof l === 'object' && 'name' in l) { + const o = l as { name?: unknown; color?: unknown }; + if (typeof o.name === 'string' && o.name) { + out.push({ name: o.name, color: typeof o.color === 'string' ? o.color : undefined }); + } + } + if (out.length >= 8) break; + } + return out; + } catch { + return []; + } +} + +/** Per-repo GitHub label → hex color, derived from the mirror (issues carry colors + * directly; mirrored PRs carry them in raw_json). Cached per repo for the process — + * label colors are effectively static. Lets us paint feed-sourced PR labels (which + * arrive without a color) in their real GitHub color instead of a guessed fallback. */ +const repoPaletteCache = new Map>(); + +function repoLabelColors(repoLc: string): Map { + const cached = repoPaletteCache.get(repoLc); + if (cached) return cached; + const m = new Map(); + try { + const db = getReadDb(); + const irows = db + .prepare('SELECT labels FROM issues WHERE LOWER(repo_full_name) = ? AND labels IS NOT NULL LIMIT 800') + .all(repoLc) as Array<{ labels: string }>; + for (const r of irows) { + for (const l of extractLabels(r.labels, false)) { + const k = l.name.toLowerCase(); + if (l.color && !m.has(k)) m.set(k, l.color); + } + } + const prows = db + .prepare('SELECT raw_json FROM pulls WHERE LOWER(repo_full_name) = ? AND raw_json IS NOT NULL LIMIT 400') + .all(repoLc) as Array<{ raw_json: string }>; + for (const r of prows) { + for (const l of extractLabels(r.raw_json, true)) { + const k = l.name.toLowerCase(); + if (l.color && !m.has(k)) m.set(k, l.color); + } + } + } catch { + /* mirror unreadable — empty palette, callers fall back to name-based colors */ + } + repoPaletteCache.set(repoLc, m); + return m; +} + +/** Attach GitHub labels to the returned PRs. The full set (with colors) comes from the + * pulls mirror's raw_json when present; the /prs feed only mirrors a fraction of PRs, so + * we always also surface the feed's scoring `label` (itself a real GitHub label) — that + * guarantees a PR shows its label even when the local mirror lacks the row. The feed + * label has no color, so we resolve its real GitHub color from the repo palette. */ +function attachPrLabels(prs: MinerPr[]): void { + if (prs.length === 0) return; + try { + const db = getReadDb(); + const stmt = db.prepare('SELECT raw_json FROM pulls WHERE repo_full_name = ? AND number = ?'); + for (const p of prs) { + const row = stmt.get(p.repo, p.number) as { raw_json: string | null } | undefined; + if (row?.raw_json) p.labels = extractLabels(row.raw_json, true); + } + } catch { + /* pulls table absent / unreadable — feed-label fallback below still applies */ + } + for (const p of prs) { + const sl = p.label; + // Defer the catch-all "other" to enrichLabelsFromGitHub — it's usually synthetic, but + // some repos define a real "other" label, which we only know from the GitHub palette. + if (sl && sl.toLowerCase() !== 'other' && !p.labels.some((l) => l.name.toLowerCase() === sl.toLowerCase())) { + const color = repoLabelColors(p.repo.toLowerCase()).get(sl.toLowerCase()); + p.labels = [...p.labels, color ? { name: sl, color } : { name: sl }]; + } + } +} + +/** Real GitHub label palette for a repo, fetched live and cached in-memory (label + * colors are effectively static). Covers labels our local mirror never sees — e.g. + * custom PR-only labels (a repo's issues may only ever use "other") — so a feed label + * gets its true github.com color instead of the gray name-based fallback. */ +const GH_LABEL_TTL_MS = 6 * 60 * 60 * 1000; // 6h +const ghLabelCache = new Map }>(); + +async function githubRepoLabelPalette(repoFullName: string): Promise> { + const key = repoFullName.toLowerCase(); + const hit = ghLabelCache.get(key); + if (hit && Date.now() - hit.at < GH_LABEL_TTL_MS) return hit.palette; + + const palette = new Map(); + try { + const [owner, repo] = repoFullName.split('/'); + if (owner && repo) { + const res = await withRotation((octokit) => octokit.issues.listLabelsForRepo({ owner, repo, per_page: 100 })); + for (const l of res.data) { + if (l?.name && typeof l.color === 'string') palette.set(l.name.toLowerCase(), l.color); + } + } + } catch { + /* repo labels unavailable (rate limit / missing) — empty palette, callers keep fallback */ + } + ghLabelCache.set(key, { at: Date.now(), palette }); + return palette; +} + +/** A miner's PRs in one repo → their real GitHub labels, by PR number. One paginated + * listForRepo(creator) call (PRs are issues on GitHub, returned with their labels), + * cached. Fills labels the scoring feed never carries (e.g. "ci", "size:L") and the + * mirror lacks (no raw_json) — the only way to label such PRs in the contributions table. */ +const ghPrLabelCache = new Map }>(); + +async function githubRepoPrLabels(repoFullName: string, login: string): Promise> { + const key = `${repoFullName}::${login}`.toLowerCase(); + const hit = ghPrLabelCache.get(key); + if (hit && Date.now() - hit.at < GH_LABEL_TTL_MS) return hit.byNumber; + + const byNumber = new Map(); + try { + const [owner, repo] = repoFullName.split('/'); + if (owner && repo) { + for (let page = 1; page <= 5; page++) { + const res = await withRotation((octokit) => + octokit.issues.listForRepo({ owner, repo, creator: login, state: 'all', per_page: 100, page }), + ); + for (const it of res.data) { + if (!it.pull_request) continue; // issues come back too; keep only PRs + const labels: GhLabel[] = (it.labels ?? []) + .map((l) => (typeof l === 'string' ? { name: l } : { name: l.name ?? '', color: typeof l.color === 'string' ? l.color : undefined })) + .filter((l) => l.name); + byNumber.set(it.number, labels); + } + if (res.data.length < 100) break; + } + } + } catch { + /* unavailable (rate limit / missing) — empty map, callers keep existing labels */ + } + ghPrLabelCache.set(key, { at: Date.now(), byNumber }); + return byNumber; +} + +/** Fill in real GitHub labels for PRs the local mirror/feed left label-less, fetching one + * listForRepo(creator) per affected repo (cached, in parallel). Only touches PRs with no + * labels yet — PRs that already have a scoring/raw_json label keep it. */ +async function attachGithubPrLabels(prs: MinerPr[], login: string): Promise { + if (!login || prs.length === 0) return; + const needy = new Set(); + for (const p of prs) if (p.labels.length === 0) needy.add(p.repo); + if (needy.size === 0) return; + + const maps = new Map>(); + await Promise.all( + [...needy].map(async (repo) => { + maps.set(repo.toLowerCase(), await githubRepoPrLabels(repo, login)); + }), + ); + for (const p of prs) { + if (p.labels.length > 0) continue; + const got = maps.get(p.repo.toLowerCase())?.get(p.number); + if (got && got.length > 0) p.labels = got; + } +} + +/** Reconcile rendered labels with the repo's real GitHub palette, so chips match + * github.com exactly even for labels absent from our local mirror. Fetches one palette + * per distinct repo (cached, in parallel) and only when something needs it. Two jobs: + * - Fill any label still missing a color. + * - Surface gittensor's catch-all "other" scoring-label as a chip ONLY where the repo + * actually defines an "other" label (e.g. PR #509 here) — staying hidden where "other" + * is purely synthetic (the common case, no such GitHub label). */ +async function enrichLabelsFromGitHub(prs: MinerPr[], issues: MinerIssue[]): Promise { + const rows: Array<{ repo: string; labels: Array<{ name: string; color?: string }> }> = [...prs, ...issues]; + + const needy = new Set(); + for (const r of rows) { + if (r.labels.some((l) => !l.color)) needy.add(r.repo); + } + for (const p of prs) { + if ((p.label ?? '').toLowerCase() === 'other' && !p.labels.some((l) => l.name.toLowerCase() === 'other')) { + needy.add(p.repo); + } + } + if (needy.size === 0) return; + + const palettes = new Map>(); + await Promise.all( + [...needy].map(async (repo) => { + palettes.set(repo.toLowerCase(), await githubRepoLabelPalette(repo)); + }), + ); + + // Surface a real "other" label where the repo defines one. + for (const p of prs) { + if ((p.label ?? '').toLowerCase() !== 'other') continue; + if (p.labels.some((l) => l.name.toLowerCase() === 'other')) continue; + const color = palettes.get(p.repo.toLowerCase())?.get('other'); + if (color) p.labels = [...p.labels, { name: 'other', color }]; + } + + // Fill any label still lacking a color from the repo's real GitHub palette. + for (const r of rows) { + const pal = palettes.get(r.repo.toLowerCase()); + if (!pal || pal.size === 0) continue; + for (const l of r.labels) { + if (!l.color) { + const c = pal.get(l.name.toLowerCase()); + if (c) l.color = c; + } + } + } +} + +async function refreshPrs(): Promise { + const r = await fetch(PRS_URL, { cache: 'no-store', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); + if (!r.ok) throw new Error(`upstream ${PRS_URL} ${r.status}`); + const all = (await r.json()) as UpstreamPr[]; + const prs: IndexedPr[] = all.map((p) => ({ + repo: p.repository, + number: p.pullRequestNumber, + title: p.pullRequestTitle, + state: deriveState(p), + score: num(p.score), + createdAt: p.prCreatedAt, + mergedAt: p.mergedAt, + closedAt: null, // the scored feed carries merged PRs; closed-not-merged come from the mirror + additions: num(p.additions), + deletions: num(p.deletions), + linkedIssueNumber: parseLinkedIssue(p.pullRequestTitle), + author: p.author ?? '', + hotkey: typeof p.hotkey === 'string' ? p.hotkey : '', + commitCount: num(p.commitCount), + baseScore: num(p.baseScore), + collateralScore: num(p.collateralScore), + tokenScore: num(p.tokenScore), + totalNodesScored: num(p.totalNodesScored), + structuralCount: num(p.structuralCount), + structuralScore: num(p.structuralScore), + leafCount: num(p.leafCount), + leafScore: num(p.leafScore), + label: typeof p.label === 'string' ? p.label : null, + labelMultiplier: num(p.labelMultiplier), + reviewQualityMultiplier: num(p.reviewQualityMultiplier), + labels: [], + authorLc: (p.author ?? '').toLowerCase(), + githubId: p.githubId ? String(p.githubId) : '', + })); + prCache = { fetched_at: Date.now(), prs }; + return prCache; +} + +async function getPrs(): Promise { + const now = Date.now(); + if (prCache && now - prCache.fetched_at < TTL_MS) return prCache.prs; + if (inFlight) return (await inFlight).prs; + inFlight = refreshPrs().finally(() => { + inFlight = null; + }); + try { + return (await inFlight).prs; + } catch (err) { + if (prCache) return prCache.prs; // serve stale on a transient upstream failure + throw err; + } +} + +interface IssueRow { + repo_full_name: string; + number: number; + title: string; + state: string; + state_reason: string | null; + html_url: string | null; + created_at: string | null; + updated_at: string | null; + closed_at: string | null; + labels: string | null; +} + +/** A miner's issues from the local mirror, newest first. Returns [] if the issues + * table isn't present/populated in this environment (graceful — PRs still show). */ +function getIssues(login: string): MinerIssue[] { + if (!login) return []; + try { + const db = getReadDb(); + const rows = db + .prepare( + `SELECT repo_full_name, number, title, state, state_reason, html_url, created_at, updated_at, closed_at, labels + FROM issues + WHERE author_login IS NOT NULL AND LOWER(author_login) = LOWER(?) + ORDER BY updated_at DESC + LIMIT ?`, + ) + .all(login, MAX) as IssueRow[]; + return rows.map((r) => ({ + repo: r.repo_full_name, + number: r.number, + title: r.title, + state: r.state, + stateReason: r.state_reason, + htmlUrl: r.html_url, + createdAt: r.created_at, + updatedAt: r.updated_at, + closedAt: r.closed_at, + labels: extractLabels(r.labels, false), + })); + } catch { + return []; + } +} + +interface DbPullRow { + repo_full_name: string; + number: number; + title: string | null; + state: string | null; + merged: number | null; + created_at: string | null; + merged_at: string | null; + closed_at: string | null; + raw_json: string | null; + author_login: string | null; +} + +/** The miner's PRs from the pulls mirror — fills in PRs the /prs feed doesn't score + * (e.g. a maintainer's own PRs on their own repo: gittensor scores cross-repo + * contributions, not the owner's). Unscored, so score / scoring fields are 0; state, + * dates and labels come from the mirror. Returns [] if the table isn't present. */ +function getDbPulls(login: string): IndexedPr[] { + if (!login) return []; + try { + const db = getReadDb(); + const rows = db + .prepare( + `SELECT repo_full_name, number, title, state, merged, created_at, merged_at, closed_at, raw_json, author_login + FROM pulls + WHERE author_login IS NOT NULL AND LOWER(author_login) = LOWER(?) + ORDER BY COALESCE(merged_at, created_at) DESC + LIMIT ?`, + ) + .all(login, MAX) as DbPullRow[]; + return rows.map((r) => ({ + repo: r.repo_full_name, + number: r.number, + title: r.title ?? '', + state: r.merged ? 'MERGED' : (r.state ?? '').toLowerCase() === 'closed' ? 'CLOSED' : 'OPEN', + score: 0, + createdAt: r.created_at ?? '', + mergedAt: r.merged_at, + closedAt: r.merged ? null : r.closed_at, + additions: 0, + deletions: 0, + linkedIssueNumber: parseLinkedIssue(r.title ?? ''), + author: r.author_login ?? '', + hotkey: '', + commitCount: 0, + baseScore: 0, + collateralScore: 0, + tokenScore: 0, + totalNodesScored: 0, + structuralCount: 0, + structuralScore: 0, + leafCount: 0, + leafScore: 0, + label: null, + labelMultiplier: 0, + reviewQualityMultiplier: 0, + labels: extractLabels(r.raw_json, true), + authorLc: (r.author_login ?? '').toLowerCase(), + githubId: '', + })); + } catch { + return []; + } +} + +/** PR/issue lifecycle activity over the last 30 days (daily buckets), computed from + * the FULL works set — so closed PRs aren't lost to the top-N PR truncation. The /prs + * feed has no PR close date, so closed PRs' close timestamps come from the pulls + * mirror (best-effort). */ +function buildActivity(mine: IndexedPr[], issues: MinerIssue[]): MinerActivityPoint[] { + const DAYS = 30; + const DAY = 86_400_000; + const bucketStart = (ts: number) => { + const d = new Date(ts); + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + }; + const last = bucketStart(Date.now()); + const start = last - (DAYS - 1) * DAY; + const points: MinerActivityPoint[] = Array.from({ length: DAYS }, (_, i) => { + const t = start + i * DAY; + return { + label: new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + openedPrs: 0, + mergedPrs: 0, + closedPrs: 0, + openedIssues: 0, + resolvedIssues: 0, + }; + }); + const bump = (iso: string | null | undefined, key: keyof Omit) => { + if (!iso) return; + const ts = Date.parse(iso); + if (!Number.isFinite(ts)) return; + const i = Math.round((bucketStart(ts) - start) / DAY); + if (i >= 0 && i < DAYS) points[i][key] += 1; + }; + const closed: IndexedPr[] = []; + for (const p of mine) { + bump(p.createdAt, 'openedPrs'); + if (p.state === 'MERGED') bump(p.mergedAt, 'mergedPrs'); + else if (p.state === 'CLOSED') closed.push(p); + } + // Closed PRs need the pulls mirror's close timestamp (the feed carries none). + if (closed.length > 0) { + try { + const stmt = getReadDb().prepare('SELECT closed_at FROM pulls WHERE repo_full_name = ? AND number = ?'); + for (const p of closed) { + const row = stmt.get(p.repo, p.number) as { closed_at: string | null } | undefined; + bump(row?.closed_at, 'closedPrs'); + } + } catch { + /* pulls mirror absent — closed-PR series stays empty */ + } + } + for (const it of issues) { + bump(it.createdAt, 'openedIssues'); + if ((it.stateReason ?? '').toUpperCase() === 'COMPLETED') bump(it.closedAt ?? it.updatedAt, 'resolvedIssues'); + } + return points; +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const login = (url.searchParams.get('login') ?? '').trim(); + const githubId = (url.searchParams.get('githubId') ?? '').trim(); + if (!login && !githubId) { + return NextResponse.json({ error: 'login or githubId required' }, { status: 400 }); + } + + let allPrs: IndexedPr[] = []; + try { + allPrs = await getPrs(); + } catch { + allPrs = []; + } + const loginLc = login.toLowerCase(); + const mine = allPrs.filter((p) => (loginLc && p.authorLc === loginLc) || (githubId && p.githubId === githubId)); + + // Supplement with PRs from the mirror that the /prs feed doesn't score (e.g. a + // maintainer's PRs on their own repo). Deduped against the scored feed set, + // case-insensitively (the feed lowercases repo names; the mirror keeps GitHub's). + if (login) { + const feedKeys = new Set(mine.map((p) => `${p.repo.toLowerCase()}#${p.number}`)); + for (const p of getDbPulls(login)) { + if (!feedKeys.has(`${p.repo.toLowerCase()}#${p.number}`)) mine.push(p); + } + } + + // Most valuable first: merged (with score) on top, then by score, then recency. + const stateRank = (s: MinerPr['state']) => (s === 'MERGED' ? 0 : s === 'OPEN' ? 1 : 2); + mine.sort( + (a, b) => + stateRank(a.state) - stateRank(b.state) || + b.score - a.score || + (b.createdAt ?? '').localeCompare(a.createdAt ?? ''), + ); + + const prs: MinerPr[] = mine.slice(0, MAX).map((p) => ({ + repo: p.repo, + number: p.number, + title: p.title, + state: p.state, + score: p.score, + createdAt: p.createdAt, + mergedAt: p.mergedAt, + closedAt: p.closedAt, + additions: p.additions, + deletions: p.deletions, + linkedIssueNumber: p.linkedIssueNumber, + author: p.author, + hotkey: p.hotkey, + commitCount: p.commitCount, + baseScore: p.baseScore, + collateralScore: p.collateralScore, + tokenScore: p.tokenScore, + totalNodesScored: p.totalNodesScored, + structuralCount: p.structuralCount, + structuralScore: p.structuralScore, + leafCount: p.leafCount, + leafScore: p.leafScore, + label: p.label, + labelMultiplier: p.labelMultiplier, + reviewQualityMultiplier: p.reviewQualityMultiplier, + labels: p.labels, + })); + attachPrLabels(prs); + // Fill PRs the mirror/feed left label-less (e.g. external-repo PRs with "ci"/"size:L") + // with their real GitHub labels, so they show in the contributions table — not just the + // detail view. + await attachGithubPrLabels(prs, login); + const issues = getIssues(login); + // Reconcile labels with each repo's real GitHub palette (fill colors + surface a + // genuine "other" label where the repo defines one). + await enrichLabelsFromGitHub(prs, issues); + + const counts = { + prs: mine.length, + prMerged: mine.filter((p) => p.state === 'MERGED').length, + prOpen: mine.filter((p) => p.state === 'OPEN').length, + prClosed: mine.filter((p) => p.state === 'CLOSED').length, + issues: issues.length, + issuesOpen: issues.filter((i) => i.state === 'open').length, + issuesCompleted: issues.filter((i) => (i.stateReason ?? '').toUpperCase() === 'COMPLETED').length, + }; + + const activity = buildActivity(mine, issues); + const body: MinerWorksResponse = { prs, issues, counts, activity }; + return NextResponse.json(body); +} diff --git a/src/app/api/miners/activity/route.ts b/src/app/api/miners/activity/route.ts new file mode 100644 index 0000000..b7de6af --- /dev/null +++ b/src/app/api/miners/activity/route.ts @@ -0,0 +1,442 @@ +import { NextResponse } from 'next/server'; +import type { Miner, MinerRepoEvaluation, MinersResponse } from '@/types/entities'; + +export const dynamic = 'force-dynamic'; + +const MINERS_URL = 'https://api.gittensor.io/miners'; +const REPOS_URL = 'https://api.gittensor.io/dash/repos'; +const REPO_MINERS_URL_BASE = 'https://api.gittensor.io/repos'; +const MAINTAINERS_URL_BASE = 'https://mirror.gittensor.io/api/v1/repos'; +const TTL_MS = 30_000; +const MAINT_TTL_MS = 300_000; // maintainer rosters are near-static — cache 5 min +const CONCURRENCY = 4; +/** Fraction of a repo's emission paid to OSS contributors (the rest is the + * protocol treasury). Matches the repositories page incentive model so per-repo + * TAO numbers agree across both surfaces. */ +const OSS_POOL = 0.9; + +interface UpstreamRepo { + fullName?: string | null; + full_name?: string | null; + config?: { + issueDiscoveryShare?: number | string | null; + maintainerCut?: number | string | null; + emissionShare?: number | string | null; + /** Per-repo eligibility overrides (validator config). Absent fields fall back + * to the subnet defaults on the client. snake_case to match upstream. */ + eligibility?: { + min_credibility?: number | string | null; + min_issue_credibility?: number | string | null; + min_valid_merged_prs?: number | string | null; + min_valid_solved_issues?: number | string | null; + } | null; + } | null; +} + +/** A per-repo evaluation row stamped with its repo's issue-discovery emission + * share and the contributor's PR / issue TAO share for this repo (each a + * fraction of the live subnet TAO — the client multiplies by subnetTAO to get + * the per-repo emission, matching the repositories page). */ +type StampedRow = MinerRepoEvaluation & { + issueDiscoveryShare?: number; + /** Repo's emission share (fraction of the OSS pool) — surfaced so the card can + * show how lucrative a repo is, explaining the score-vs-earnings gap. */ + emissionShare?: number; + prTaoShare?: number; + issueTaoShare?: number; + /** Per-repo eligibility thresholds from the validator config. Null when the repo + * uses subnet defaults — the client then applies its own default floors (one + * source of truth), so a configured 0 (no gate, e.g. entrius/oc-1) is preserved + * distinctly from "unset". */ + minPrCred?: number | null; + minIssueCred?: number | null; + minMergedPrs?: number | null; + minSolvedIssues?: number | null; +}; + +type MinerWire = Miner & { + github_username?: string; + github_id?: string | number; + isMaintainer?: boolean; + maintainerRepos?: string[]; + maintainerCut?: number; + /** Maintainer-cut emission as a fraction of the subnet TAO (sum over the + * miner's paid repos of OSS_POOL × emissionShare × maintainerCut ÷ + * maintainerCount). The client multiplies by subnetTAO to get TAO/day. */ + maintainerTaoShare?: number; + /** Per-repo maintainer-cut share (repo full name → fraction of subnet TAO), + * so each maintained repo can show its own maintainer emission. */ + maintainerRepoTaoShares?: Record; +}; + +interface Cached { + fetched_at: number; + miners: Miner[]; +} + +let cache: Cached | null = null; +let inFlight: Promise | null = null; +// Maintainer rosters change rarely, so they get their own longer-lived cache, +// independent of the 30s miner-feed cache (avoids hammering the mirror). +let maintainerCache: { fetched_at: number; byLogin: Map; count: Map } | null = null; + +function num(value: unknown): number { + const n = typeof value === 'string' ? Number.parseFloat(value) : typeof value === 'number' ? value : 0; + return Number.isFinite(n) ? n : 0; +} + +/** Like num(), but preserves "absent" as null — so a config override of 0 (a real + * "no gate" value) stays distinct from an unset field (client applies the default). */ +function numOrNull(value: unknown): number | null { + if (value == null || value === '') return null; + const n = typeof value === 'string' ? Number.parseFloat(value) : typeof value === 'number' ? value : NaN; + return Number.isFinite(n) ? n : null; +} + +function stringValue(value: unknown): string { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return ''; +} + +function normalizedRepoName(value: unknown): string | null { + const repo = stringValue(value); + return repo.includes('/') ? repo : null; +} + +function minerKeyFromFields(githubId: unknown, githubUsername: unknown, uid: unknown): string { + const id = stringValue(githubId); + if (id) return `id:${id}`; + const login = stringValue(githubUsername).toLowerCase(); + if (login) return `login:${login}`; + const uidString = stringValue(uid); + return uidString ? `uid:${uidString}` : ''; +} + +function minerKey(miner: Miner): string { + const wire = miner as MinerWire; + return minerKeyFromFields(miner.githubId ?? wire.github_id, miner.githubUsername ?? wire.github_username, miner.uid); +} + +function repoRowKey(row: MinerRepoEvaluation): string { + return minerKeyFromFields(row.githubId ?? row.github_id, row.githubUsername ?? row.github_username, row.uid); +} + +function isRepoSignal(row: MinerRepoEvaluation): boolean { + return ( + (row.isEligible ?? row.is_eligible) === true || + (row.isIssueEligible ?? row.is_issue_eligible) === true || + num(row.totalScore ?? row.total_score) > 0 || + num(row.issueDiscoveryScore ?? row.issue_discovery_score) > 0 || + num(row.baseTotalScore ?? row.base_total_score) > 0 || + num(row.totalCollateralScore ?? row.total_collateral_score) > 0 || + num(row.totalPrs ?? row.total_prs) > 0 || + num(row.totalMergedPrs ?? row.total_merged_prs) > 0 || + num(row.totalOpenPrs ?? row.total_open_prs) > 0 || + num(row.totalClosedPrs ?? row.total_closed_prs) > 0 || + num(row.totalSolvedIssues ?? row.total_solved_issues) > 0 || + num(row.totalOpenIssues ?? row.total_open_issues) > 0 || + num(row.totalClosedIssues ?? row.total_closed_issues) > 0 || + num(row.usdPerDay ?? row.usd_per_day) > 0 || + num(row.taoPerDay ?? row.tao_per_day) > 0 + ); +} + +async function fetchJson(url: string, timeout = 15_000): Promise { + const response = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(timeout) }); + if (!response.ok) throw new Error(`upstream ${url} ${response.status}`); + return response.json() as Promise; +} + +async function fetchRepoRows(fullName: string): Promise { + const raw = await fetchJson(`${REPO_MINERS_URL_BASE}/${encodeURIComponent(fullName)}/miners`, 10_000); + const rows = Array.isArray(raw) + ? raw + : raw && typeof raw === 'object' && Array.isArray((raw as { miners?: unknown }).miners) + ? (raw as { miners: unknown[] }).miners + : []; + + const repoKey = fullName.toLowerCase(); + return rows + .filter((row): row is MinerRepoEvaluation => Boolean(row) && typeof row === 'object') + .map((row) => ({ repositoryFullName: fullName, ...row })) + .filter((row) => { + const rowRepo = normalizedRepoName(row.repositoryFullName ?? row.repository_full_name); + return (!rowRepo || rowRepo.toLowerCase() === repoKey) && Boolean(repoRowKey(row)) && isRepoSignal(row); + }); +} + +async function mapConcurrent(items: T[], limit: number, worker: (item: T) => Promise): Promise { + const results: R[] = []; + let index = 0; + + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, async () => { + for (;;) { + const current = index; + index += 1; + if (current >= items.length) return; + results[current] = await worker(items[current]); + } + }), + ); + + return results; +} + +/** Maintainer GitHub logins (lowercased) for a repo, from the mirror. Returns + * null on a failed fetch (vs [] for a genuinely empty roster) so the caller can + * avoid caching the gap during a mirror outage; the feed itself never breaks. */ +async function fetchRepoMaintainers(repo: string): Promise { + const [owner, name] = repo.split('/'); + if (!owner || !name) return []; + try { + const url = `${MAINTAINERS_URL_BASE}/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/maintainers`; + const response = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(10_000) }); + if (!response.ok) return null; + const body = (await response.json()) as unknown; + const list = Array.isArray(body) + ? body + : Array.isArray((body as { maintainers?: unknown[] })?.maintainers) + ? (body as { maintainers: unknown[] }).maintainers + : []; + const logins = list + .map((m) => { + if (typeof m === 'string') return m; + const o = (m ?? {}) as Record; + return stringValue(o.login ?? o.username ?? o.githubUsername ?? o.github_username); + }) + .map((login) => login.toLowerCase()) + .filter(Boolean); + return [...new Set(logins)]; // rosters can list a maintainer more than once + } catch { + return null; + } +} + +/** login (lowercased) → repos they maintain, across all tracked repos. Cached + * separately from the miner feed (5 min) since maintainer lists rarely change. */ +async function getMaintainerMap(repoNames: string[]): Promise<{ byLogin: Map; count: Map }> { + if (maintainerCache && Date.now() - maintainerCache.fetched_at < MAINT_TTL_MS) { + return { byLogin: maintainerCache.byLogin, count: maintainerCache.count }; + } + const perRepo = await mapConcurrent(repoNames, CONCURRENCY, async (repo) => ({ + repo, + logins: await fetchRepoMaintainers(repo), + })); + const byLogin = new Map(); + const count = new Map(); + for (const { repo, logins } of perRepo) { + if (!logins) continue; // failed fetch — skip, don't treat as "no maintainers" + count.set(repo.toLowerCase(), logins.length); + for (const login of logins) { + const repos = byLogin.get(login) ?? []; + repos.push(repo); + byLogin.set(login, repos); + } + } + // Don't cache a roster built on failed fetches: a transient mirror outage would + // otherwise suppress maintainer attribution for the whole TTL. On a TOTAL outage + // keep serving the last-good roster; on a partial one, use what we got but + // re-fetch next time instead of caching the gap. + const anyFailed = perRepo.some((r) => r.logins === null); + const allFailed = perRepo.length > 0 && perRepo.every((r) => r.logins === null); + if (allFailed && maintainerCache) return { byLogin: maintainerCache.byLogin, count: maintainerCache.count }; + if (!anyFailed) maintainerCache = { fetched_at: Date.now(), byLogin, count }; + return { byLogin, count }; +} + +async function refresh(): Promise { + const [miners, reposRaw] = await Promise.all([ + fetchJson(MINERS_URL), + fetchJson(REPOS_URL), + ]); + const repos = reposRaw + .map((repo) => { + const elig = repo.config?.eligibility ?? null; + return { + name: normalizedRepoName(repo.fullName ?? repo.full_name), + issueDiscoveryShare: num(repo.config?.issueDiscoveryShare), + maintainerCut: num(repo.config?.maintainerCut), + emissionShare: num(repo.config?.emissionShare), + // Per-repo eligibility floors — null when the repo uses subnet defaults, so + // the client applies its own defaults (a configured 0 is kept distinct). + minPrCred: numOrNull(elig?.min_credibility), + minIssueCred: numOrNull(elig?.min_issue_credibility), + minMergedPrs: numOrNull(elig?.min_valid_merged_prs), + minSolvedIssues: numOrNull(elig?.min_valid_solved_issues), + }; + }) + .filter((repo): repo is typeof repo & { name: string } => Boolean(repo.name)); + const repoCut = new Map(repos.map((repo) => [repo.name.toLowerCase(), repo.maintainerCut])); + const repoShare = new Map(repos.map((repo) => [repo.name.toLowerCase(), repo.emissionShare])); + + const minerByKey = new Map(); + for (const miner of miners) { + const key = minerKey(miner); + if (!key) continue; + minerByKey.set(key, { ...miner, repoEvaluations: [] }); + } + + const [repoResults, maintainers] = await Promise.all([ + mapConcurrent(repos, CONCURRENCY, async (repo) => { + try { + const rows = await fetchRepoRows(repo.name); + // Per-repo TAO-share stamping — the repositories page's incentive model. + // A repo's contributor pool is OSS_POOL × emissionShare × (1 − cut), + // split into a PR pool (× (1 − issueShare)) and an issue-discovery pool + // (× issueShare). Each eligible contributor's slice is their score over + // the SUM of all eligible scores on this repo (the true on-chain share, + // not a top-N display subset). We stamp the resulting fraction-of-subnet- + // TAO on each row so the client only multiplies by the live subnet TAO. + const q = repo.issueDiscoveryShare; + const prPoolShare = OSS_POOL * repo.emissionShare * (1 - repo.maintainerCut) * (1 - q); + const issuePoolShare = OSS_POOL * repo.emissionShare * (1 - repo.maintainerCut) * q; + let prScoreSum = 0; + let issueScoreSum = 0; + for (const row of rows) { + if ((row.isEligible ?? row.is_eligible) === true) prScoreSum += num(row.totalScore ?? row.total_score); + if ((row.isIssueEligible ?? row.is_issue_eligible) === true) + issueScoreSum += num(row.issueDiscoveryScore ?? row.issue_discovery_score); + } + for (const row of rows) { + const stamped = row as StampedRow; + stamped.issueDiscoveryShare = q; + stamped.emissionShare = repo.emissionShare; + // Per-repo eligibility floors (null = use client defaults). + stamped.minPrCred = repo.minPrCred; + stamped.minIssueCred = repo.minIssueCred; + stamped.minMergedPrs = repo.minMergedPrs; + stamped.minSolvedIssues = repo.minSolvedIssues; + const prEligible = (row.isEligible ?? row.is_eligible) === true; + const issueEligible = (row.isIssueEligible ?? row.is_issue_eligible) === true; + stamped.prTaoShare = + prEligible && prScoreSum > 0 ? prPoolShare * (num(row.totalScore ?? row.total_score) / prScoreSum) : 0; + stamped.issueTaoShare = + issueEligible && issueScoreSum > 0 + ? issuePoolShare * (num(row.issueDiscoveryScore ?? row.issue_discovery_score) / issueScoreSum) + : 0; + } + return { rows, failed: false }; + } catch { + return { rows: [] as MinerRepoEvaluation[], failed: true }; + } + }), + getMaintainerMap(repos.map((repo) => repo.name)), + ]); + if (repos.length > 0 && repoResults.every((result) => result.failed)) { + throw new Error('all upstream repo miner fetches failed'); + } + + for (const row of repoResults.flatMap((result) => result.rows)) { + const key = repoRowKey(row); + if (!key) continue; + const miner = minerByKey.get(key); + if (miner) { + const existing = Array.isArray(miner.repoEvaluations) ? miner.repoEvaluations : []; + miner.repoEvaluations = [...existing, row]; + continue; + } + + const fallbackMiner: MinerWire = { + id: key, + uid: Math.trunc(num(row.uid)), + hotkey: '', + githubUsername: stringValue(row.githubUsername ?? row.github_username ?? row.githubId ?? row.github_id), + githubId: stringValue(row.githubId ?? row.github_id), + isEligible: (row.isEligible ?? row.is_eligible) === true, + isIssueEligible: (row.isIssueEligible ?? row.is_issue_eligible) === true, + failedReason: null, + credibility: String(num(row.credibility)), + issueCredibility: String(num(row.issueCredibility ?? row.issue_credibility)), + issueDiscoveryScore: String(num(row.issueDiscoveryScore ?? row.issue_discovery_score)), + issueTokenScore: String(num(row.issueTokenScore ?? row.issue_token_score)), + totalScore: String(num(row.totalScore ?? row.total_score)), + baseTotalScore: String(num(row.baseTotalScore ?? row.base_total_score)), + totalSolvedIssues: num(row.totalSolvedIssues ?? row.total_solved_issues), + totalValidSolvedIssues: num(row.totalValidSolvedIssues ?? row.total_valid_solved_issues), + totalOpenIssues: num(row.totalOpenIssues ?? row.total_open_issues), + totalClosedIssues: num(row.totalClosedIssues ?? row.total_closed_issues), + totalOpenPrs: num(row.totalOpenPrs ?? row.total_open_prs), + totalClosedPrs: num(row.totalClosedPrs ?? row.total_closed_prs), + totalMergedPrs: num(row.totalMergedPrs ?? row.total_merged_prs), + totalPrs: num(row.totalPrs ?? row.total_prs), + uniqueReposCount: 1, + alphaPerDay: num(row.alphaPerDay ?? row.alpha_per_day), + taoPerDay: num(row.taoPerDay ?? row.tao_per_day), + usdPerDay: num(row.usdPerDay ?? row.usd_per_day), + repoEvaluations: [row], + }; + minerByKey.set(key, fallbackMiner); + } + + // Flag maintainers (by GitHub login) so the derivation layer can surface + // maintainer-cut earnings — a reward stream distinct from PRs / issue discovery. + for (const miner of minerByKey.values()) { + const login = stringValue(miner.githubUsername ?? miner.github_username).toLowerCase(); + const maintainerRepos = login ? maintainers.byLogin.get(login) : undefined; + if (maintainerRepos && maintainerRepos.length > 0) { + // Only repos that actually pay a maintainer cut (> 0) count — a GitHub + // maintainer of a 0-cut repo earns nothing from maintaining it. + const paidRepos = maintainerRepos.filter((repo) => (repoCut.get(repo.toLowerCase()) ?? 0) > 0); + if (paidRepos.length > 0) { + miner.isMaintainer = true; + miner.maintainerRepos = paidRepos; + miner.maintainerCut = Math.max(...paidRepos.map((repo) => repoCut.get(repo.toLowerCase()) ?? 0)); + // Maintainer-cut emission as a fraction of subnet TAO: each paid repo pays + // OSS_POOL × emissionShare × maintainerCut, split across its maintainers + // (matching the repositories page). Kept per-repo so each maintained repo + // can show its own maintainer emission, plus summed for the split bar; the + // client multiplies by the live subnet TAO. + const repoShares: Record = {}; + for (const repo of paidRepos) { + const key = repo.toLowerCase(); + const share = repoShare.get(key) ?? 0; + const cut = repoCut.get(key) ?? 0; + const maintainerCount = Math.max(1, maintainers.count.get(key) ?? 1); + repoShares[repo] = (OSS_POOL * share * cut) / maintainerCount; + } + miner.maintainerRepoTaoShares = repoShares; + miner.maintainerTaoShare = Object.values(repoShares).reduce((sum, value) => sum + value, 0); + } + } + } + + const next: Cached = { + fetched_at: Date.now(), + miners: [...minerByKey.values()], + }; + cache = next; + return next; +} + +function payload(cached: Cached, source: 'live' | 'cache' | 'stale', error?: string): MinersResponse & { error?: string } { + return { + count: cached.miners.length, + fetched_at: cached.fetched_at, + source, + miners: cached.miners, + ...(error ? { error } : {}), + }; +} + +export async function GET() { + const now = Date.now(); + if (cache && now - cache.fetched_at < TTL_MS) { + return NextResponse.json(payload(cache, 'cache')); + } + + if (!inFlight) { + inFlight = refresh().finally(() => { + inFlight = null; + }); + } + + try { + const fresh = await inFlight; + return NextResponse.json(payload(fresh, 'live')); + } catch (err) { + if (cache) return NextResponse.json(payload(cache, 'stale', String(err))); + return NextResponse.json({ error: String(err) }, { status: 502 }); + } +} diff --git a/src/app/api/sn74-emission/route.ts b/src/app/api/sn74-emission/route.ts index 2f5ca49..878c573 100644 --- a/src/app/api/sn74-emission/route.ts +++ b/src/app/api/sn74-emission/route.ts @@ -21,8 +21,9 @@ import { NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; -const SUBNET_URL = 'https://api.taomarketcap.com/internal/v1/subnets/74/'; -const NEURONS_URL = 'https://api.taomarketcap.com/internal/v1/subnets/neurons/74/'; +const NETUID = 74; +const SUBNET_URL = `https://api.taomarketcap.com/internal/v1/subnets/${NETUID}/`; +const NEURONS_URL = `https://api.taomarketcap.com/internal/v1/subnets/neurons/${NETUID}/`; const CACHE_TTL_MS = 60_000; const FETCH_TIMEOUT_MS = 10_000; const RECYCLE_UID = 0; @@ -91,6 +92,11 @@ export interface Sn74EmissionSnapshot { /** Count of UIDs in each category, for context on the cards. */ minerCount: number; validatorCount: number; + /** Per-UID actual daily TAO (alpha_per_day × price) for every neuron — exactly + * the figure TaoMarketCap shows per UID. The miners page looks each miner up by + * uid for its authoritative headline emission (the score-share model only + * approximates this). */ + perUidTaoPerDay: Record; /** Alpha → TAO price used for the per-UID conversion. */ alphaPriceInTao: number; /** TaoMarketCap's `miners_tao_per_day` for cross-reference (a narrower @@ -107,8 +113,8 @@ export interface Sn74EmissionSnapshot { // Renamed on each schema change so Next.js HMR drops any stale // pre-refactor cache that lacked newer fields — those would otherwise // read as undefined → 0 on the client. -let cacheV4: Sn74EmissionSnapshot | null = null; -let inflightV4: Promise | null = null; +let cacheV6: Sn74EmissionSnapshot | null = null; +let inflightV6: Promise | null = null; function num(v: unknown): number | null { if (v == null) return null; @@ -153,8 +159,11 @@ async function refresh(): Promise { let minerAlpha = 0; let validatorCount = 0; let minerCount = 0; + // Per-UID actual daily TAO — the exact value TaoMarketCap renders per neuron. + const perUidTaoPerDay: Record = {}; for (const n of neurons) { const a = num(n.alpha_per_day) ?? 0; + if (typeof n.uid === 'number') perUidTaoPerDay[n.uid] = a * alphaPrice; if (n.uid === RECYCLE_UID) { recycleAlpha += a; } else if (n.uid === TREASURY_UID) { @@ -211,6 +220,7 @@ async function refresh(): Promise { ownerTaoPerDay, minerCount, validatorCount, + perUidTaoPerDay, alphaPriceInTao: alphaPrice, minersTaoPerDayUpstream: num(snap.miners_tao_per_day), taoPerDay: totalTaoPerDay, @@ -219,18 +229,18 @@ async function refresh(): Promise { alphaBurnPerDay: num(snap.dtao?.daily_burn), fetched_at: Date.now(), }; - cacheV4 = next; + cacheV6 = next; return next; } async function getCached(): Promise { const now = Date.now(); - if (cacheV4 && now - cacheV4.fetched_at < CACHE_TTL_MS) return cacheV4; - if (inflightV4) return inflightV4; - inflightV4 = refresh().finally(() => { - inflightV4 = null; + if (cacheV6 && now - cacheV6.fetched_at < CACHE_TTL_MS) return cacheV6; + if (inflightV6) return inflightV6; + inflightV6 = refresh().finally(() => { + inflightV6 = null; }); - return inflightV4; + return inflightV6; } export async function GET() { @@ -238,7 +248,7 @@ export async function GET() { const fresh = await getCached(); return NextResponse.json({ ...fresh, source: 'live' }); } catch (err) { - if (cacheV4) return NextResponse.json({ ...cacheV4, source: 'stale', error: String(err) }); + if (cacheV6) return NextResponse.json({ ...cacheV6, source: 'stale', error: String(err) }); return NextResponse.json({ error: String(err) }, { status: 502 }); } } diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 51a256e..7b2e851 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -26,6 +26,7 @@ import type { RepoEntry } from '@/lib/repos'; import { formatRelativeTime } from '@/lib/format'; import SearchInput from '@/components/SearchInput'; import { SkeletonBar } from '@/components/Skeleton'; +import { ActivityLineChart, smoothPath, type ActivityKey, type DayPoint } from '@/components/ActivityLineChart'; const OSS_SHARE = 0.9; const DAY_MS = 24 * 60 * 60 * 1000; @@ -188,29 +189,6 @@ interface PullsResp { pulls: PullDto[]; } -type ActivityKey = 'mergedPrs' | 'closedPrs' | 'resolvedIssues' | 'openedPrs' | 'openedIssues'; - -interface DayPoint { - label: string; - mergedPrs: number; - closedPrs: number; - resolvedIssues: number; - openedPrs: number; - openedIssues: number; -} - -// Chart palette — curated dashboard colors (Tailwind-500 family) that read -// distinctly against both light and dark canvases when used at 18-70% opacity -// in stacked bands. Order = legend display order (lifecycle-grouped: opens -// then completions then closes). -const ACTIVITY_SERIES: Array<{ key: ActivityKey; label: string; color: string }> = [ - { key: 'openedPrs', label: 'PRs Opened', color: '#3b82f6' }, // blue-500 - { key: 'mergedPrs', label: 'PRs Merged', color: '#10b981' }, // emerald-500 - { key: 'closedPrs', label: 'PRs Closed', color: '#ef4444' }, // red-500 - { key: 'openedIssues', label: 'Issues Opened', color: '#8b5cf6' }, // violet-500 - { key: 'resolvedIssues', label: 'Issues Resolved', color: '#f59e0b' }, // amber-500 -]; - interface RecentActivityItem { id: string; kind: 'pr' | 'issue'; @@ -265,7 +243,6 @@ function fmtNumber(value: number): string { return value.toFixed(3); } - function relative(value: string | number | null | undefined): string { if (!value) return 'pending'; const iso = typeof value === 'number' ? new Date(value).toISOString() : value; @@ -1384,7 +1361,7 @@ export default function DashboardPage() { }> - + View all}> @@ -1429,7 +1406,6 @@ export default function DashboardPage() { ); } - function Source({ label, ok, detail }: { label: string; ok: boolean; detail: string }) { return ( ): string { - if (points.length === 0) return ''; - if (points.length === 1) return 'M ' + points[0].x + ' ' + points[0].y; - return points - .map((point, index) => { - if (index === 0) return 'M ' + point.x + ' ' + point.y; - const prev = points[index - 1]; - const cpX = prev.x + (point.x - prev.x) / 2; - return 'C ' + cpX + ' ' + prev.y + ' ' + cpX + ' ' + point.y + ' ' + point.x + ' ' + point.y; - }) - .join(' '); -} - - -function niceCeil(value: number): number { - if (value <= 4) return 4; - const exp = Math.floor(Math.log10(value)); - const mag = Math.pow(10, exp); - const norm = value / mag; - const nice = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10; - return Math.ceil(nice * mag); -} - -function LineChart({ points }: { points: DayPoint[] }) { - const [hoveredIndex, setHoveredIndex] = useState(null); - // Track the real rendered width so the viewBox matches the box 1:1 — a fixed - // viewBox with `width="100%"` letterboxes (shrinks + centers) on narrow - // screens, leaving big empty bands above/below the plot. - const containerRef = useRef(null); - const [measuredWidth, setMeasuredWidth] = useState(900); - useEffect(() => { - const el = containerRef.current; - if (!el || typeof ResizeObserver === 'undefined') return; - const ro = new ResizeObserver((entries) => { - const w = entries[0]?.contentRect.width; - if (w && w > 0) setMeasuredWidth(w); - }); - ro.observe(el); - return () => ro.disconnect(); - }, []); - const width = Math.max(280, measuredWidth); - const height = 300; - const pad = { left: 40, right: 16, top: 14, bottom: 32 }; - const plotWidth = width - pad.left - pad.right; - const plotHeight = height - pad.top - pad.bottom; - const maxValue = Math.max(4, ...points.flatMap((point) => ACTIVITY_SERIES.map((series) => point[series.key]))); - // Round yMax up to a "nice" step (10/25/50/100/250…) so y-axis labels are - // readable round numbers. - const yMax = niceCeil(maxValue); - const active = hoveredIndex === null ? null : { index: hoveredIndex, point: points[hoveredIndex] }; - // Keep the last hovered index so the indicator can smoothly stay in place - // while it fades out after the cursor leaves the chart. - const lastHoveredRef = useRef(0); - useEffect(() => { - if (hoveredIndex !== null) lastHoveredRef.current = hoveredIndex; - }, [hoveredIndex]); - // Clamp to current data length — when the user switches duration the data - // shrinks but the ref still holds an index from the previous (longer) range. - const rawDisplayIndex = hoveredIndex ?? lastHoveredRef.current; - const displayIndex = points.length > 0 - ? Math.min(Math.max(0, rawDisplayIndex), points.length - 1) - : 0; - const displayPoint = points[displayIndex] ?? points[0]; - const x = (idx: number) => pad.left + (idx * plotWidth) / Math.max(1, points.length - 1); - const y = (value: number) => pad.top + (1 - value / yMax) * plotHeight; - const tickStep = Math.max(1, Math.ceil(points.length / 7)); - const totals = ACTIVITY_SERIES.map((series) => ({ ...series, total: points.reduce((sum, point) => sum + point[series.key], 0) })); - const tooltipWidth = 196; - // Height grows with series count — 5 rows × ~18px + header + total row + - // padding. Recomputed so new series don't get clipped. - const tooltipHeight = 56 + ACTIVITY_SERIES.length * 19 + 36; - // Always compute a tooltip position (using displayIndex) so the tooltip can - // slide smoothly even between hover transitions. - const tooltipX = Math.min(width - tooltipWidth - 10, Math.max(10, x(displayIndex) - tooltipWidth / 2)); - const tooltipY = pad.top + 8; - - return ( - - - - {totals.map((series) => )} - - - setHoveredIndex(null)}> - {/* Stacked bands intentionally use a flat fill (no gradient) — the - previous top→bottom gradient faded each band's lower edge to ~18% - opacity, which over a dark canvas became near-black and made the - colors look muddy. The 1.5px top-edge stroke alone gives enough - separation between layers. */} - {[0, 0.25, 0.5, 0.75, 1].map((tick) => { - const value = Math.round(yMax * (1 - tick)); - const lineY = pad.top + tick * plotHeight; - return ( - - - {value} - - ); - })} - {/* Lines — one per series, drawn with a stroke-dashoffset animation - so each one "draws itself in" left-to-right on first render. */} - {ACTIVITY_SERIES.map((series, seriesIndex) => { - const seriesPoints = points.map((point, idx) => ({ x: x(idx), y: y(point[series.key]) })); - return ( - - ); - })} - {points.map((point, idx) => { - const showLabel = idx === 0 || idx === points.length - 1 || idx % tickStep === 0; - return ( - - {showLabel && {point.label}} - setHoveredIndex(idx)} - onFocus={() => setHoveredIndex(idx)} - onBlur={() => setHoveredIndex(null)} - /> - - ); - })} - {/* Hover indicator — always mounted (so CSS transitions can interpolate - between hover positions instead of snapping). Group opacity controls - show/hide, child elements transition their positional attributes. - Guarded by points.length so it doesn't try to read stackedRows[0] - when there's no data yet. */} - {points.length > 0 && ( - - - {/* Dots at each line's value for the hovered x. CSS transitions on - cx/cy give a glide effect when moving between days. */} - {ACTIVITY_SERIES.map((series) => ( - - ))} - {/* Tooltip — HTML inside foreignObject for themed CSS vars. The `x` - attribute on foreignObject is transitionable in modern browsers. */} - - - - {displayPoint.label} - - - {ACTIVITY_SERIES.map((series) => ( - - - {series.label} - - {fmtCount(displayPoint[series.key])} - - - ))} - - - TOTAL - - {fmtCount(ACTIVITY_SERIES.reduce((sum, series) => sum + displayPoint[series.key], 0))} - - - - - - )} - - - - ); -} - -function ActivityLegend({ color, label, total }: { color: string; label: string; total: number }) { - return ( - - - {label} - {fmtCount(total)} - - ); -} - - function pullHref(pr: PullDto): string { return pr.html_url ?? 'https://github.com/' + pr.repo_full_name + '/pull/' + pr.number; } @@ -2740,7 +2462,6 @@ function PipelineColumnBars({ pulls, stage, label, color, duration, selectedInde ); } - function IssueColumnTrend({ issues, stage, label, color, duration, selectedIndex, onSelectIndex }: { issues: IssueDto[]; stage: IssuePipelineColumn['key']; label: string; color: string; duration: ReturnType; selectedIndex: number | null; onSelectIndex: (index: number) => void }) { const [hoveredIndex, setHoveredIndex] = useState(null); const values = bucketValues(duration, issues, (issue) => issuePipelineTimestamp(issue, stage)); diff --git a/src/app/miners/_components/EmissionHeader.tsx b/src/app/miners/_components/EmissionHeader.tsx new file mode 100644 index 0000000..386c43d --- /dev/null +++ b/src/app/miners/_components/EmissionHeader.tsx @@ -0,0 +1,214 @@ +'use client'; + +/* SN74 emission overview — the daily TAO headline + per-recipient cards + * (miners / validators / recycling / treasury / owner). Each card follows the + * reference UI: a tinted icon chip top-left, a share pill top-right, the daily + * TAO value, label, and source, on a bordered card with a subtle corner accent. + * Data is the live /api/sn74-emission feed (proxied from TaoMarketCap). */ + +import React from 'react'; +import { ArchiveIcon, KeyIcon, PeopleIcon, ShieldCheckIcon, SyncIcon, ZapIcon } from '@primer/octicons-react'; +import styles from '../page.module.css'; +import type { EmissionData, MinerView } from '../_lib/miners'; +import MinerDistribution from './MinerDistribution'; + +export type { EmissionData }; + +// Corner-decorator colors, grouped by emission tier: the top-level network +// split (owner / miners / validators) vs. the OSS / miner-pool components +// (recycling / treasury / active miners). Cool blue vs warm amber so the two +// groups read as distinct at a glance. +const DECO_EMISSION = 'var(--accent-emphasis)'; +const DECO_OSSPOOL = 'var(--attention-emphasis)'; + +function EmissionStat({ + icon, + label, + value, + color, + sub, + share, + deco, +}: { + icon: React.ReactNode; + label: string; + value: string; + color: string; + sub: string; + share: string; + deco: string; +}) { + return ( +
+ {share} + + {icon} + + + {value} + τ/day + + {label} + {sub} +
+ ); +} + +/** Placeholder card shown while the emission feed loads — same shell as + * EmissionStat with shimmer blocks instead of real values. */ +function SkeletonStat() { + return ( +
+ + + + + + + + + + + + + +
+ ); +} + +/** Histogram placeholder shown while the miner feed loads, so the right side of + * the header doesn't sit empty until the distribution arrives. Mirrors the + * MinerDistribution panel: header line + a row of bars with bucket labels. */ +function DistSkeleton() { + const bars = [85, 18, 30, 12, 16, 10, 4, 7, 3]; + return ( +
+
+ + +
+
+ {bars.map((h, i) => ( +
+ + +
+ ))} +
+
+ ); +} + +export default function EmissionHeader({ + emission, + views = [], + onSelectMiner, +}: { + emission?: EmissionData | null; + views?: MinerView[]; + onSelectMiner?: (view: MinerView) => void; +}) { + const loaded = emission?.totalTaoPerDay != null; + const total = emission?.totalTaoPerDay ?? 30; + const miners = emission?.minerTaoPerDay ?? null; + const validators = emission?.validatorTaoPerDay ?? null; + const recycle = emission?.recycleTaoPerDay ?? null; + const treasury = emission?.treasuryTaoPerDay ?? null; + const owner = emission?.ownerTaoPerDay ?? null; + const minerCount = emission?.minerCount ?? null; + const validatorCount = emission?.validatorCount ?? null; + + // Green group (owner / miners / validators) — share of TOTAL daily emission. + // total = owner + miners + validators, so these three sum to 100%. + const totalShareBase = total || 1; + const pctOf = (v: number | null) => `${Math.round((Math.max(0, v ?? 0) / totalShareBase) * 100)}%`; + + // Amber group (recycling / treasury / active miners) — share of the MINER / OSS + // pool, NOT of total. Built from the ACTUAL per-UID values: recycle (UID 0), + // treasury (UID 111) and activeMinerTaoPerDay (Σ miner UIDs), with the pool = + // their sum (the same subnetTAO base the per-repo math uses). This guarantees the + // three always sum to 100% and 'active' is the real distributed amount. + // (Deriving active = minerTaoPerDay − recycle − treasury broke when the recycle + // sink exceeded the theoretical 41% miner split — active went negative, hiding + // its card and pushing recycle past 100%.) + const active = Math.max(0, emission?.activeMinerTaoPerDay ?? 0); + const ossPool = active + Math.max(0, recycle ?? 0) + Math.max(0, treasury ?? 0); + const poolPctOf = (v: number | null) => `${Math.round((Math.max(0, v ?? 0) / (ossPool || 1)) * 100)}%`; + + return ( +
+
+
+
+ SN74 emissions today +

+ {loaded ? ( + {total.toFixed(2)} + ) : ( + + )}{' '} + TAO/day +

+

+ Live daily TAO emission for SN74, pulled from{' '} + + taomarketcap + + . Miner earnings below are funded from this pool. +

+
+ {views.length > 0 ? ( + + ) : !loaded ? ( + + ) : null} +
+ +
+ {!loaded ? ( + Array.from({ length: 6 }).map((_, i) => ) + ) : ( + <> + {owner != null ? ( + } label="owner" value={owner.toFixed(2)} color="var(--success-fg)" deco={DECO_EMISSION} share={pctOf(owner)} sub="paid to owner_hotkey" /> + ) : null} + } + label="miners" + value={(miners ?? 0).toFixed(2)} + color="var(--success-fg)" + deco={DECO_EMISSION} + share={pctOf(miners)} + sub={minerCount != null ? `${minerCount} miner UIDs` : 'miner UIDs'} + /> + {validators != null ? ( + } + label="validators" + value={validators.toFixed(2)} + color="var(--success-fg)" + deco={DECO_EMISSION} + share={pctOf(validators)} + sub={validatorCount != null ? `${validatorCount} validator UIDs` : 'validator UIDs'} + /> + ) : null} + } label="recycling" value={(recycle ?? 0).toFixed(2)} color="var(--attention-fg)" deco={DECO_OSSPOOL} share={poolPctOf(recycle)} sub="UID 0" /> + } label="treasury" value={(treasury ?? 0).toFixed(2)} color="var(--attention-fg)" deco={DECO_OSSPOOL} share={poolPctOf(treasury)} sub="UID 111" /> + {active > 0 ? ( + } + label="active miners" + value={active.toFixed(2)} + color="var(--attention-fg)" + deco={DECO_OSSPOOL} + share={poolPctOf(active)} + sub="of OSS pool" + /> + ) : null} + + )} +
+
+
+ ); +} diff --git a/src/app/miners/_components/Headline.tsx b/src/app/miners/_components/Headline.tsx new file mode 100644 index 0000000..b92791d --- /dev/null +++ b/src/app/miners/_components/Headline.tsx @@ -0,0 +1,482 @@ +'use client'; + +/* eslint-disable @next/next/no-img-element */ + +/* Treemap headline — a map of the SN74 miner emission slice. + * + * Most subnet emission recycles (UID 0) or funds the issues treasury (UID 111), + * so those would dwarf every miner if drawn as tiles. Instead the allocation + * bar up top shows the full pool split (miners vs recycle vs treasury), and the + * treemap below is miners-only — each tile sized by daily TAO, colored by track. + * Hovering a tile drives the overview panel; clicking a miner opens the drawer. */ + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { GraphIcon } from '@primer/octicons-react'; +import { formatCount, formatNumber, formatUsd } from '@/lib/format'; +import styles from '../page.module.css'; +import { buildPoolTiles, score, shareText, type EmissionData, type MinerView, type PoolTile } from '../_lib/miners'; +import { fillBadge, repoStreamColor, streamBackground, streamColor, streamLabel, ISSUE_COLOR, MAINTAINER_COLOR, PR_COLOR } from '../_lib/streams'; +import { squarify } from '../_lib/squarify'; +import { RankMedal } from './shared'; +import StreamTags from './StreamTags'; + +interface HeadlineProps { + views: MinerView[]; + /** The signed-in user's own miner row, if any — the overview defaults to it. */ + myView: MinerView | null; + lastSync: string; + emission?: EmissionData | null; + /** True until the first miner data arrives — drives the overview skeleton. */ + loading?: boolean; + onSelectMiner: (view: MinerView) => void; + onBrowse: () => void; +} + +/** Max individual miner tiles by width — fewer on small screens so every tile + * stays big enough to show its avatar + UID. The rest fold into one "Others" + * tile. */ +function maxMinerTiles(width: number): number { + if (width >= 1000) return 20; + if (width >= 720) return 16; + if (width >= 500) return 13; + return 11; +} + +const OTHERS_COLOR = 'var(--border-strong)'; + +function tileColor(tile: PoolTile): string { + return tile.kind === 'miner' && tile.view ? streamColor(tile.view) : OTHERS_COLOR; +} + +function useMeasuredWidth(): [React.RefObject, number] { + const ref = useRef(null); + const [width, setWidth] = useState(0); + useEffect(() => { + const el = ref.current; + if (!el) return; + const update = () => setWidth(el.clientWidth); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + return [ref, width]; +} + +export default function Headline({ views, myView, lastSync, emission, loading, onSelectMiner, onBrowse }: HeadlineProps) { + return ( +
+
+ +
+
+ + hover or tap a tile for details · synced {lastSync} +
+
+ ); +} + +// ─── Treemap ──────────────────────────────────────────────────────────────── + +// Placeholder weights for the loading skeleton — a long-tailed distribution (a +// few big tiles, a long tail of small ones) so the squarified skeleton reads +// like a real miner treemap at any width, instead of flat full-height bars. +const SKELETON_WEIGHTS = [8, 5, 3.4, 2.6, 2, 1.7, 1.4, 1.2, 1, 0.9, 0.8, 0.7, 0.6, 0.5]; + +function Treemap({ + views, + emission, + myView, + loading, + onSelect, + onBrowse, +}: { + views: MinerView[]; + emission?: EmissionData | null; + myView: MinerView | null; + loading?: boolean; + onSelect: (view: MinerView) => void; + onBrowse: () => void; +}) { + const [ref, width] = useMeasuredWidth(); + const [hoveredKey, setHoveredKey] = useState(null); + const [pinnedKey, setPinnedKey] = useState(null); + // Responsive height: narrower viewports get a taller map so the 20+ tiles + // don't collapse into unreadable slivers. Wide screens stay compact. + const height = width >= 1024 ? 320 : width >= 680 ? 380 : width >= 480 ? 430 : 470; + const myKey = myView?.key ?? null; + + const maxTiles = maxMinerTiles(width); + const tiles = useMemo(() => buildPoolTiles(views, maxTiles), [views, maxTiles]); + + // Pool split: miners (sum of tiles) + recycle (UID 0) + treasury (UID 111). + const minerPool = tiles.reduce((s, t) => s + t.tao, 0); + const recycle = emission?.recycleTaoPerDay ?? 0; + const treasury = emission?.treasuryTaoPerDay ?? 0; + const fullPool = minerPool + recycle + treasury; + const minerCount = tiles.filter((t) => t.kind === 'miner').length; + const maxTile = tiles.length > 0 ? Math.max(...tiles.map((t) => t.tao)) : 1; + + const rects = useMemo(() => { + if (width <= 0 || tiles.length === 0) return []; + // The "Others" aggregate sums the whole tail, so cap its DISPLAY weight to + // the smallest visible miner. That keeps the input in descending order, so + // squarify packs it cleanly as the last tile (bottom-right) WITHOUT the thin + // sliver a large out-of-order tile would create. The inspector still reports + // Others' true TAO/count. + const minerTaos = tiles.filter((t) => t.kind === 'miner').map((t) => t.tao); + const othersCap = minerTaos.length ? minerTaos[minerTaos.length - 1] : 0; + const segs = tiles.map((t) => ({ + w: t.kind === 'others' && othersCap > 0 ? Math.min(t.tao, othersCap) : t.tao, + data: t, + })); + return squarify(segs, 0, 0, width, height, { sort: false }); + }, [tiles, width, height]); + + // Loading skeleton tiles — squarified with the same packer as the real map so + // the placeholder mosaic matches the treemap's shape on every viewport. + const skeletonRects = useMemo( + () => + loading && width > 0 + ? squarify(SKELETON_WEIGHTS.map((w) => ({ w, data: null })), 0, 0, width, height, { sort: false }) + : [], + [loading, width, height], + ); + + // Inspector target: whatever the pointer is over, else whatever a click has + // pinned. No default tile — on first load nothing is hovered or pinned, so the + // inspector shows its empty "tap a tile" prompt (mirroring the repositories + // overview) instead of auto-selecting a miner. mouseleave lives on the + // container (not per-tile) so moving between tiles never flickers. + const byKey = useMemo(() => new Map(tiles.map((t) => [t.key, t])), [tiles]); + + const target: PoolTile | null = + (hoveredKey ? byKey.get(hoveredKey) : null) ?? (pinnedKey ? byKey.get(pinnedKey) : null) ?? null; + + // Clicking a miner tile both updates the panel and opens its detail drawer. + const onTileClick = (tile: PoolTile) => { + if (tile.kind === 'miner' && tile.view) { + setPinnedKey(tile.key); + onSelect(tile.view); + } else { + onBrowse(); + } + }; + + return ( +
+
setHoveredKey(null)}> + {rects.map(({ x, y, w, h, data }) => { + const big = w > 64 && h > 34; + const tiny = w < 34 || h < 22; + const huge = w > 180 && h > 120; + const isMiner = data.kind === 'miner' && Boolean(data.view); + // Show the avatar background + UID on any non-tiny miner tile; only the + // larger ones also show the $/day line, to avoid clutter. + const useAvatar = isMiner && w >= 40 && h >= 34; + // The "others" tile renders a 2x2 face mosaic of its largest miners. + const useMosaic = data.kind === 'others' && data.avatars.length > 0 && w >= 40 && h >= 34; + const showUid = isMiner && data.view?.uid != null && w >= 48 && h >= 28; + const showValue = w >= 76 && h >= 48; + const showRank = isMiner && data.rank > 0 && data.rank <= 3 && w >= 44 && h >= 32; + const tone = tileColor(data); + const intensity = Math.max(0.14, Math.min(0.48, 0.16 + (data.tao / maxTile) * 0.34)); + + const tileStyle: React.CSSProperties = { + left: x, + top: y, + width: Math.max(0, w - 2), + height: Math.max(0, h - 2), + }; + if (useAvatar && data.view) { + // Request a crisper avatar for the larger background. + tileStyle.backgroundImage = `url(${data.view.avatarUrl.replace(/size=\d+/, 'size=288')})`; + } else if (useMosaic) { + // The 2x2 face mosaic is rendered as real s below (object-fit: + // cover) so square avatars crop cleanly instead of stretching to the + // tile's aspect ratio (which looked distorted); just set the backing + // color here in case any avatar fails to load. + tileStyle.backgroundColor = 'var(--bg-inset)'; + } else { + tileStyle.background = `color-mix(in srgb, ${tone} ${Math.round(intensity * 100)}%, var(--bg-canvas))`; + } + return ( + + ); + })} + {loading && tiles.length === 0 ? ( + skeletonRects.length > 0 ? ( +
+ {skeletonRects.map((r, i) => ( + + ))} +
+ ) : null + ) : width <= 0 ? ( +
Measuring layout…
+ ) : tiles.length === 0 ? ( +
No earning miners to map yet.
+ ) : null} +
+ +
+ + + Pull requests + + + + Issue discovery + + + + Maintainer + + top {minerCount} miners · sized by TAO/day +
+ + +
+ ); +} + +// ─── Overview inspector ─────────────────────────────────────────────────────── + +function TreemapInspector({ + tile, + pool, + myKey, + count, + loading, + onOpen, + onBrowse, +}: { + tile: PoolTile | null; + pool: number; + myKey: string | null; + count: number; + loading?: boolean; + onOpen: (view: MinerView) => void; + onBrowse: () => void; +}) { + // While the feed is loading there's no miner to show — render a skeleton in + // the overview's shape so the section reads as "loading", not empty. + if (!tile && loading) { + return ( +
+ +
+ + +
+
+ {Array.from({ length: 5 }).map((_, i) => ( + + + + + ))} +
+ +
+ ); + } + + if (!tile) { + return ( +
+
+
Tap any tile above to inspect
+
Or browse the full list — easier when tiles are small
+ +
+
+ ); + } + + // The "Others" aggregate tile gets a compact info panel. + if (tile.kind !== 'miner' || !tile.view) { + return ( +
+ {tile.avatars.length > 0 ? ( + + {Array.from({ length: 4 }, (_, i) => tile.avatars[i % tile.avatars.length]).map((u, i) => ( + + ))} + + ) : ( + + )} +
+
+ Other miners + {tile.sub} +
+
Smaller earners outside the top tiles
+ +
+
+ + + + +
+
+ ); + } + + const view = tile.view; + return ( +
+
+ {view.login} + {tile.rank > 0 && tile.rank <= 3 ? : null} +
+ +
+
+ {view.login} + + {view.key === myKey ? You : null} +
+
+ uid {view.uid ?? '-'} + {shareText(view.taoPerDay, pool)} of pool + {formatNumber(view.taoPerDay, { digits: 3, fallback: '0' })} τ/day + score {score(view.totalScore + view.issueScore)} + {view.isMaintainer && view.maintainerCut > 0 ? ( + {Math.round(view.maintainerCut * 100)}% maintainer cut + ) : null} +
+ +
+ + {view.topRepos.length > 0 ? ( + <> + +
+ Top repos +
+ {view.topRepos.slice(0, 3).map((r) => { + const owner = r.repo.split('/')[0]; + const repoColor = repoStreamColor(r, view.maintainerRepos); + return ( + + + {r.repo} + + {score(r.prScore + r.issueScore)} + + + ); + })} +
+
+ + ) : null} + + +
+ ); +} + +function InspectorStat({ + label, + value, + tone, + sub, + color, +}: { + label: string; + value: string; + tone?: 'green' | 'purple'; + sub?: string; + color?: string; +}) { + return ( +
+ {label} + + {value} + {sub ? {sub} : null} + +
+ ); +} diff --git a/src/app/miners/_components/MinerCard.tsx b/src/app/miners/_components/MinerCard.tsx new file mode 100644 index 0000000..d3829df --- /dev/null +++ b/src/app/miners/_components/MinerCard.tsx @@ -0,0 +1,184 @@ +'use client'; + +/* eslint-disable @next/next/no-img-element */ + +import React from 'react'; +import { InfoIcon } from '@primer/octicons-react'; +import { formatCount, formatNumber } from '@/lib/format'; +import styles from '../page.module.css'; +import { incentiveNote, pct, repoTaoOf, score, shareText, type MinerView, type RepoSignal } from '../_lib/miners'; +import { + BlockedRepos, + ContribSpark, + IssueActivityStats, + PrActivityStats, + RankMedal, + RepoEmissionBar, + TrackButton, +} from './shared'; + +interface MinerCardProps { + view: MinerView; + rank: number; + /** Total miner emission pool (TAO/day) — denominator for "% of pool". */ + poolTao: number; + /** Whole-subnet daily emission (TAO/day) — denominator for "% of total". */ + totalTao: number; + /** Per-repo TAO base (active miners + recycle + treasury) — multiplies the + * server-stamped per-repo/per-stream shares into TAO/day. */ + subnetTao: number; + /** Per-repo ACTUAL distributed emission (lowercased repo → τ/day), summed across + * all contributors — the denominator for "repo total / your share". */ + repoTotals: Map; + selected: boolean; + mine: boolean; + tracked: boolean; + onSelect: () => void; + onToggleTrack: () => void; +} + +export default function MinerCard({ + view, + rank, + poolTao, + totalTao, + subnetTao, + repoTotals, + selected, + mine, + tracked, + onSelect, + onToggleTrack, +}: MinerCardProps) { + // Per-repo emission (TAO/day) — server-stamped per-repo shares × live subnet + // TAO. Each repo's figure already sums its PR, issue-discovery, and maintainer + // streams, matching the repositories page (e.g. MkDev11 ≈ 0.039 on gittensory). + // The per-repo bar replaces the old card-wide reward-stream split bar (which was + // a flat single-color bar for all but a handful of multi-stream miners). + const repoTao = (row: RepoSignal) => repoTaoOf(row, subnetTao); + // Repo's actual distributed pool (all contributors) — the denominator for the + // "repo total / your share" line; falls back to the notional pool if a repo is + // somehow absent from the aggregate. + const repoTotal = (row: RepoSignal) => repoTotals.get(row.repo.toLowerCase()) ?? subnetTao * row.emissionShare * 0.9; + + // Repos to show: the miner's earning repos, or — when they earn from none yet — + // their most active contributions (so the card still has substance + context). + const repoRows = view.topRepos.length > 0 ? view.topRepos : view.rows.slice(0, 3); + + // Plain-language note for the confusing cases (penalty, or active-but-not-earning) + // — e.g. why 829 issues + a PR still pay 0, and how earning actually happens. + const note = incentiveNote(view); + + // "+N more" reconciliation — the card shows the top few earning repos, but the + // headline sums ALL of them; surface the remainder so nothing hides silently. + const reposShown = 4; + const shownTao = view.topRepos.slice(0, reposShown).reduce((sum, row) => sum + repoTao(row), 0); + const moreCount = Math.max(0, view.earningRepoCount - reposShown); + const moreTao = Math.max(0, view.taoPerDay - shownTao); + + return ( +
{ + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onSelect(); + }} + > +
+ +
+ +
+
+ {view.login} + {rank > 0 && rank <= 3 ? : null} +
+
+
+ {view.login} + {mine ? You : null} + {view.isMaintainer ? {pct(view.maintainerCut)} maintainer cut : null} +
+
+ #{rank || '-'} · uid {view.uid ?? '-'} +
+
+
+ +
+
+
+ {formatNumber(view.taoPerDay, { digits: 3, fallback: '0' })} + TAO/day +
+
+ {shareText(view.taoPerDay, poolTao)} of pool + · + {shareText(view.taoPerDay, totalTao)} of total emission +
+
+
+
{score(view.totalScore + view.issueScore)}
+
score
+
+
+ +
+
+
PR activity
+ +
+
+
Issue activity
+ +
+
+
Contributions
+ +
{formatCount(view.uniqueRepos, { fallback: '0' })} repos
+
+
+ +
Top repos
+ + {moreCount > 0 ? ( +
+ +{moreCount} more earning {moreCount === 1 ? 'repo' : 'repos'} · {formatNumber(moreTao, { digits: 3, fallback: '0' })} τ/d +
+ ) : null} + + {view.blockedRepos.length > 0 ? ( + <> +
+ Working toward earning +
+ + + ) : null} + + {note ? ( +
+ + {note} +
+ ) : null} +
+ ); +} diff --git a/src/app/miners/_components/MinerDistribution.tsx b/src/app/miners/_components/MinerDistribution.tsx new file mode 100644 index 0000000..87bda91 --- /dev/null +++ b/src/app/miners/_components/MinerDistribution.tsx @@ -0,0 +1,99 @@ +'use client'; + +/* eslint-disable @next/next/no-img-element */ + +/* Miner earnings distribution — a compact histogram bucketing the current field + * by $/day (snapshot-only data, so bars animate live on each refresh). Hovering + * (or focusing) a bar opens a popover listing the miners in that bucket; each + * row is clickable to open that miner's drawer. */ + +import React, { useMemo, useState } from 'react'; +import { formatCount, formatUsd } from '@/lib/format'; +import styles from '../page.module.css'; +import type { MinerView } from '../_lib/miners'; + +// Bucket 0 is the "$0" bucket: miners who submitted PRs/issues but earn nothing +// (low credibility, etc.). The remaining buckets split earners by $/day, labeled +// by their upper edge ("<$X"); the last is the 200+ tail. +const BUCKET_EDGES = [1, 5, 10, 25, 50, 100, 200]; +const BUCKET_TICKS = ['$0', '<$1', '<$5', '<$10', '<$25', '<$50', '<$100', '<$200', '$200+']; +const BUCKET_RANGES = ['Submitted · $0 earned', 'Under $1', '$1 – $5', '$5 – $10', '$10 – $25', '$25 – $50', '$50 – $100', '$100 – $200', '$200+']; + +export default function MinerDistribution({ views, onSelectMiner }: { views: MinerView[]; onSelectMiner?: (view: MinerView) => void }) { + const [hover, setHover] = useState(null); + + const { buckets, shown } = useMemo(() => { + const b: MinerView[][] = Array.from({ length: BUCKET_TICKS.length }, () => []); + let count = 0; + for (const v of views) { + if (v.usdPerDay > 0) { + let idx = BUCKET_EDGES.findIndex((edge) => v.usdPerDay < edge); + if (idx === -1) idx = BUCKET_EDGES.length; // 200+ + b[idx + 1].push(v); // +1: bucket 0 is reserved for $0 earners + count++; + } else if (v.totalPrs > 0 || v.totalIssues > 0 || v.rows.length > 0) { + // active (submitted PRs/issues / scored on a repo) but no incentive yet + b[0].push(v); + count++; + } + } + b.forEach((arr) => arr.sort((a, c) => c.usdPerDay - a.usdPerDay)); + return { buckets: b, shown: count }; + }, [views]); + const max = Math.max(...buckets.map((b) => b.length), 1); + + return ( +
+
+ Earnings spread · $/day + Total {formatCount(shown, { fallback: '0' })} active miners +
+ +
setHover(null)}> + {buckets.map((miners, i) => { + const has = miners.length > 0; + return ( +
has && setHover(i)} + onFocus={() => has && setHover(i)} + onBlur={() => setHover(null)} + aria-label={has ? `${BUCKET_RANGES[i]} per day: ${miners.length} miners` : undefined} + > + {hover === i && has ? ( +
+
+ + {BUCKET_RANGES[i]} + {i > 0 ? / day : null} + + + {miners.length} miner{miners.length === 1 ? '' : 's'} + +
+
+ {miners.map((v) => ( + + ))} +
+
+ ) : null} + + {miners.length} + + {BUCKET_TICKS[i]} +
+ ); + })} +
+ ); +} diff --git a/src/app/miners/_components/MinerListRow.tsx b/src/app/miners/_components/MinerListRow.tsx new file mode 100644 index 0000000..584c14d --- /dev/null +++ b/src/app/miners/_components/MinerListRow.tsx @@ -0,0 +1,109 @@ +'use client'; + +/* eslint-disable @next/next/no-img-element */ + +import React from 'react'; +import { formatCount, formatNumber } from '@/lib/format'; +import styles from '../page.module.css'; +import { pct, repoTaoOf, score, shareText, type MinerView, type RepoSignal } from '../_lib/miners'; +import { ContribSpark, IssueActivityStats, PrActivityStats, RepoMiniStrip, TrackButton } from './shared'; + +interface MinerListRowProps { + view: MinerView; + rank: number; + /** Total miner emission pool (TAO/day) — denominator for "% of pool". */ + poolTao: number; + /** Per-repo TAO base — turns each repo's stream shares into TAO/day. */ + subnetTao: number; + selected: boolean; + mine: boolean; + tracked: boolean; + onSelect: () => void; + onToggleTrack: () => void; +} + +/* List view of a miner — the same content the card surfaces (emission, score, PR + * and issue activity, top repos, contributions), laid out as a dense table row in + * the repositories list-view style and reusing the card's own components so the + * colours and styling match exactly. */ +export default function MinerListRow({ + view, + rank, + poolTao, + subnetTao, + selected, + mine, + tracked, + onSelect, + onToggleTrack, +}: MinerListRowProps) { + const repoTao = (row: RepoSignal) => repoTaoOf(row, subnetTao); + // Mirror the card: show the earning repos, or the most active rows when none earn. + const repoRows = view.topRepos.length > 0 ? view.topRepos : view.rows.slice(0, 3); + return ( +
{ + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + onSelect(); + }} + > +
+ +
+ +
+ {view.login} +
+
+ {view.login} + {mine ? You : null} + {view.isMaintainer ? {pct(view.maintainerCut)} cut : null} +
+ + #{rank || '-'} · uid {view.uid ?? '-'} + +
+
+ +
+
+ {formatNumber(view.taoPerDay, { digits: 3, fallback: '0' })} +
+
{shareText(view.taoPerDay, poolTao)} pool
+
+ +
+
{score(view.totalScore + view.issueScore)}
+
score
+
+ +
+ +
+ +
+ +
+ +
+ + {formatCount(view.uniqueRepos, { fallback: '0' })} repos +
+ +
+ +
+
+ ); +} diff --git a/src/app/miners/_components/MinerModal.tsx b/src/app/miners/_components/MinerModal.tsx new file mode 100644 index 0000000..0e178af --- /dev/null +++ b/src/app/miners/_components/MinerModal.tsx @@ -0,0 +1,963 @@ +'use client'; + +/* eslint-disable @next/next/no-img-element */ + +/* Miner detail modal — a near-fullscreen 2-pane dashboard opened by clicking a + * miner card / list row. Left: an identity sidebar (avatar, status, points, stats, + * links). Right: a dashboard of the miner's gittensor (SN74) work — a contribution + * timeline (daily PR score + cumulative), the Pull-requests & Issues table, a + * GitHub-style activity heatmap, and the emission / reward-stream breakdown. + * Built on the shared modal scaffold (.modalOuter/.modalBg/.modalBox); all extra + * styling rides dedicated .mm* / .modalBoxWide classes. */ + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { + AlertIcon, + CalendarIcon, + CheckCircleIcon, + ChevronLeftIcon, + ChevronRightIcon, + GraphIcon, + InfoIcon, + LightBulbIcon, + LockIcon, + MarkGithubIcon, + PersonIcon, + ScreenFullIcon, + ScreenNormalIcon, + StarFillIcon, + StarIcon, + XIcon, + ZapIcon, +} from '@primer/octicons-react'; +import { formatCount, formatNumber } from '@/lib/format'; +import styles from '../page.module.css'; +import { eligibilityLabel, pct, repoRegisteredMs, repoTaoOf, score, type MinerView, type RepoSignal } from '../_lib/miners'; +import { ISSUE_COLOR, MAINTAINER_COLOR, NEUTRAL_COLOR, PR_COLOR } from '../_lib/streams'; +import type { MinerPr, MinerWorksResponse } from '@/types/entities'; +import { PrsIssuesTable, buildHeatGrid, heatFill, decayMultiplier, PR_LOOKBACK_DAYS } from './MinerWorks'; +import { + ActivityLineChart, + EarningForecastChart, + type ForecastPoint, + type ForecastRepo, + type ForecastSeries, +} from '@/components/ActivityLineChart'; + +interface MinerModalProps { + view: MinerView | null; + /** Per-repo TAO base — turns each repo's stream shares into TAO/day. */ + subnetTao: number; + tracked: boolean; + onClose: () => void; + onToggleTrack: () => void; + onPrev?: () => void; + onNext?: () => void; +} + +const fmtTao = (n: number) => formatNumber(n, { digits: 3, fallback: '0' }); + +/** Why an inactive reward stream isn't earning yet. The gittensor gate is PER-REPO — + * each repo has its own merged/solved counts, its own PR/issue credibility, and its own + * thresholds (count ≥ minMerged/minSolved, then credibility ≥ the repo's min) — so a + * miner-wide number would be meaningless. We surface the single repo the miner is + * CLOSEST to clearing, with that repo's own numbers. Null if there's no work in the + * stream yet. */ +function bestRepoGap(rows: RepoSignal[], stream: 'pr' | 'issue'): { repo: string; text: string } | null { + const cand = rows + .map((r) => + stream === 'pr' + ? // Not-yet-eligible repos that route some emission to PRs (share < 1). + { repo: r.repo, has: r.prs > 0 && r.issueDiscoveryShare < 1 && !r.prEligible, count: r.mergedPrs, need: r.minMergedPrs, cred: r.prCred, needCred: r.minPrCred, noun: 'merged' } + : // Not-yet-eligible repos that route some emission to issue discovery (share > 0). + // validSolvedIssues = the count the gate actually checks (validity-filtered). + { repo: r.repo, has: r.issues > 0 && r.issueDiscoveryShare > 0 && !r.issueEligible, count: r.validSolvedIssues, need: r.minSolvedIssues, cred: r.issueCred, needCred: r.minIssueCred, noun: 'solved' }, + ) + .filter((c) => c.has); + if (cand.length === 0) return null; + // Closest = smallest remaining count to the repo's gate, then highest credibility. + cand.sort((a, b) => Math.max(0, a.need - a.count) - Math.max(0, b.need - b.count) || b.cred - a.cred); + const c = cand[0]; + const text = c.count < c.need ? `${c.count}/${c.need} ${c.noun}` : `cred ${pct(c.cred)} / ${pct(c.needCred)}`; + return { repo: c.repo, text }; +} + +type InsightKind = 'strong' | 'warn' | 'action' | 'info'; +interface Insight { + kind: InsightKind; + title: string; + body: string; +} + +/** Replace each repo's PR-side stats (merged count, credibility, eligibility) with values + * recomputed LIVE from the miner's actual PRs over the trailing PR_LOOKBACK_DAYS window, + * rather than the scoring feed's snapshot — which lags as PRs roll out of the window. A + * repo with no live PR data keeps its feed values; issue-side stats are left untouched + * (the works feed lacks the solved/closed-issue dates needed to redo them). */ +function applyLivePrStats(rows: RepoSignal[], prs: MinerPr[] | undefined, nowMs: number): RepoSignal[] { + if (!prs || prs.length === 0) return rows; + const cutoff = nowMs - PR_LOOKBACK_DAYS * 86_400_000; + const stat = new Map(); + for (const p of prs) { + const key = p.repo.toLowerCase(); + let s = stat.get(key); + if (!s) { + s = { merged: 0, closed: 0 }; + stat.set(key, s); + } + if (p.mergedAt) { + if (Date.parse(p.mergedAt) >= cutoff) s.merged += 1; + } else if (p.closedAt && Date.parse(p.closedAt) >= cutoff) { + s.closed += 1; + } + } + return rows.map((r) => { + const s = stat.get(r.repo.toLowerCase()); + if (!s) return r; + const total = s.merged + s.closed; + const prCred = total > 0 ? s.merged / total : 0; + const prEligible = s.merged >= r.minMergedPrs && prCred >= r.minPrCred; + return { ...r, mergedPrs: s.merged, prCred, prEligible }; + }); +} + +/** "Insights & next actions" — actionable observations from the miner's per-repo + * signals (+ the decay forecast). All gates are per-repo, never miner-wide. Built in + * priority order (protect what you have → unlock more → reinforce) and capped so the + * panel surfaces the few most relevant rather than sprawling. */ +function buildInsights(rows: RepoSignal[], forecast: ForecastSeries | null): Insight[] { + if (rows.length === 0) return []; + const out: Insight[] = []; + + // 1. Credibility at risk (warn) — an eligible repo whose credibility (merged ÷ total) + // sits just above its own gate, with no buffer. Only when the repo has a real gate + // (min > 0). We compute the EXACT number of further rejections that would drop it + // below — "a couple" understates the risk for low-volume repos (often it's just 1). + type Risk = { repo: string; cred: number; min: number; count: number; noun: string }; + const risk = rows + .map((r): Risk | null => { + // Only warn about a stream the repo actually pays for: PRs where the repo routes + // some emission to PRs (share < 1), issue discovery where it routes some (share > 0). + if (r.prEligible && r.minPrCred > 0 && r.issueDiscoveryShare < 1 && r.prCred < r.minPrCred + 0.05) + return { repo: r.repo, cred: r.prCred, min: r.minPrCred, count: r.mergedPrs, noun: 'closed PR' }; + if (r.issueEligible && r.minIssueCred > 0 && r.issueDiscoveryShare > 0 && r.issueCred < r.minIssueCred + 0.05) + return { repo: r.repo, cred: r.issueCred, min: r.minIssueCred, count: r.solvedIssues, noun: 'closed issue' }; + return null; + }) + .filter((x): x is Risk => x !== null) + .sort((a, b) => a.cred - a.min - (b.cred - b.min))[0]; + if (risk) { + const total = risk.cred > 0 ? Math.round(risk.count / risk.cred) : risk.count; // merged + rejected + const extra = Math.max(1, Math.ceil(risk.count / risk.min - total)); // rejections to fall below the gate + out.push({ + kind: 'warn', + title: `Credibility at risk on ${risk.repo}`, + body: `Credibility here is ${pct(risk.cred)} over the last 30 days, just above the ${pct(risk.min)} gate — ${extra} more ${risk.noun}${extra === 1 ? '' : 's'} would drop you below it and stop this repo's earnings.`, + }); + } + + // 2. Freshness decay (warn) — the time-decay curve will erode the live score if no + // new merges land (reuses the forecast's projected drop). + if (forecast && forecast.dropPct != null && forecast.dropPct >= 10 && forecast.liveNow > 0) { + out.push({ + kind: 'warn', + title: 'Offset freshness decay', + body: `Your decay-weighted score will erode ~${forecast.dropPct}% over the next ${forecast.projDays} days as merges age. Merge new PRs to keep it fresh.`, + }); + } + + // 3. Closest gate (action) — the single repo nearest to clearing (PR first, then issue). + const gap = bestRepoGap(rows, 'pr') ?? bestRepoGap(rows, 'issue'); + if (gap) { + out.push({ + kind: 'action', + title: `Not yet eligible in ${gap.repo}`, + body: `Currently ${gap.text} in the trailing 30-day window. Keep merging here (and your credibility up) to clear — or re-clear — this repository's eligibility gate.`, + }); + } + + // 4. Strongest (strong) — rank by the eligible stream's OSS score (a contribution + // metric, so it aligns with the credibility shown and excludes maintainer-cut). + const ossScore = (r: RepoSignal) => (r.prEligible ? r.prScore : 0) + (r.issueEligible ? r.issueScore : 0); + const strongest = rows.filter((r) => ossScore(r) > 0).sort((a, b) => ossScore(b) - ossScore(a))[0]; + if (strongest) { + const cred = strongest.prEligible ? strongest.prCred : strongest.issueCred; + out.push({ + kind: 'strong', + title: `Strongest in ${strongest.repo}`, + body: `${score(ossScore(strongest), 2)} OSS score at ${pct(cred)} credibility. Keep this consistency to maximize earnings.`, + }); + } + + // 5. Biggest untapped pool (info) — the highest-emission repo the miner isn't eligible + // in yet (skipped if it's already the "closest gate" repo shown above). + const blocked = rows.filter( + (r) => !r.prEligible && !r.issueEligible && ((r.prs > 0 && r.issueDiscoveryShare < 1) || (r.issues > 0 && r.issueDiscoveryShare > 0)), + ); + const biggest = [...blocked].sort((a, b) => b.emissionShare - a.emissionShare)[0]; + if (biggest && biggest.emissionShare > 0 && biggest.repo !== gap?.repo) { + out.push({ + kind: 'info', + title: `Biggest opportunity: ${biggest.repo}`, + body: `This repo carries the largest reward pool (${pct(biggest.emissionShare)} of OSS emission) of the repos you're not eligible in yet.`, + }); + } + + // 6. Coverage (info) — scoped to repos where becoming eligible is achievable and pays. + const relevant = rows.filter( + (r) => (r.prs > 0 && r.issueDiscoveryShare < 1) || (r.issues > 0 && r.issueDiscoveryShare > 0), + ); + const eligibleInRelevant = relevant.filter((r) => r.prEligible || r.issueEligible).length; + if (relevant.length > 0 && eligibleInRelevant < relevant.length) { + out.push({ + kind: 'info', + title: 'Expand eligible coverage', + body: `Eligible in ${eligibleInRelevant} of ${relevant.length} repositories. Lifting credibility in the rest unlocks more of the network reward pool.`, + }); + } + + return out.slice(0, 4); +} + +/** Stream segments for the emission donut (only non-zero streams). */ +function streamSegments(view: MinerView, subnetTao: number) { + return [ + { key: 'pr', label: 'Pull requests', color: PR_COLOR, tao: view.prTaoShare * subnetTao }, + { key: 'issue', label: 'Issue discovery', color: ISSUE_COLOR, tao: view.issueTaoShare * subnetTao }, + { key: 'maintainer', label: 'Maintainer cut', color: MAINTAINER_COLOR, tao: view.maintainerTaoShare * subnetTao }, + ].filter((s) => s.tao > 0); +} + +/** Sidebar status dot — by what the miner actually earns from. */ +function statusOf(view: MinerView): { label: string; color: string } { + const label = eligibilityLabel(view); // 'Dual' | 'PR' | 'Issue' | 'Inactive' + if (label === 'Dual') return { label: 'Dual', color: 'var(--accent-emphasis)' }; + if (label === 'PR') return { label: 'PR', color: PR_COLOR }; + if (label === 'Issue') return { label: 'Issue', color: ISSUE_COLOR }; + return { label: 'Inactive', color: NEUTRAL_COLOR }; +} + + +function lastActiveIso(works: MinerWorksResponse | undefined): string | null { + if (!works) return null; + let best = 0; + for (const p of works.prs) { + const t = Date.parse(p.mergedAt ?? p.createdAt ?? ''); + if (Number.isFinite(t) && t > best) best = t; + } + for (const i of works.issues) { + const t = Date.parse(i.createdAt ?? ''); + if (Number.isFinite(t) && t > best) best = t; + } + return best > 0 ? new Date(best).toISOString() : null; +} + +/** Start of the miner's SN74 working life, for "working age". Each contribution is + * clamped forward to its repo's gittensor registration date: a repo's GitHub history + * predates SN74 (repos are often years old before joining), so a PR/issue opened before + * the repo was registered isn't SN74 work and must not back-date the age. The earliest + * such clamped date across all contributions is when their SN74 clock started. */ +function firstActiveMs(works: MinerWorksResponse | undefined): number | null { + if (!works) return null; + let first = Infinity; + for (const p of works.prs) { + const t = Date.parse(p.createdAt ?? ''); + if (!Number.isFinite(t)) continue; + const eff = Math.max(t, repoRegisteredMs(p.repo)); + if (eff < first) first = eff; + } + for (const i of works.issues) { + const t = Date.parse(i.createdAt ?? ''); + if (!Number.isFinite(t)) continue; + const eff = Math.max(t, repoRegisteredMs(i.repo)); + if (eff < first) first = eff; + } + return Number.isFinite(first) ? first : null; +} + +/** A creative tenure badge by SN74 working age — recognises how long the miner has been + * contributing, from first week to subnet veteran. */ +function tenureBadge(ageDays: number | null): { label: string; tier: 'new' | 'rookie' | 'regular' | 'veteran' | 'pioneer' } | null { + if (ageDays == null) return null; + if (ageDays < 7) return { label: 'Newcomer', tier: 'new' }; + if (ageDays < 30) return { label: 'Rookie', tier: 'rookie' }; + if (ageDays < 90) return { label: 'Regular', tier: 'regular' }; + if (ageDays < 180) return { label: 'Veteran', tier: 'veteran' }; + return { label: 'Pioneer', tier: 'pioneer' }; +} + +/** Compact relative time with " ago" (sidebar). */ +function relTime(iso: string | null): string { + if (!iso) return '—'; + const t = Date.parse(iso); + if (!Number.isFinite(t)) return '—'; + const d = Math.floor((Date.now() - t) / 86_400_000); + if (d <= 0) return 'today'; + if (d < 30) return `${d}d ago`; + const mo = Math.floor(d / 30); + if (mo < 12) return `${mo}mo ago`; + return `${Math.floor(mo / 12)}y ago`; +} + +// ─── Activity over time (PR/issue lifecycle, last 30 days) ─────────────────────── + +/** Decay-weighted earning power over the last 30 days + a 14-day forward projection, + * for EarningForecastChart. Each merged PR contributes score × freshness-decay and + * drops out past the 30-day lookback — reconstructed historically, then projected + * forward (portfolio aging, no new merges). Each day also carries a per-repo + * breakdown (top contributors + their current emission) for the hover tooltip. */ +function buildForecast(prs: MinerPr[] | undefined): ForecastSeries | null { + const DAY = 86_400_000; + const HIST = 30; + const PROJ = 14; + // Merged, scored PRs grouped by repo (the freshness curve only weights merged work). + const byRepo = new Map>(); + for (const p of prs ?? []) { + if (p.state !== 'MERGED' || !p.mergedAt || !(p.score > 0)) continue; + const t = Date.parse(p.mergedAt); + if (!Number.isFinite(t)) continue; + const arr = byRepo.get(p.repo); + if (arr) arr.push({ t, score: p.score }); + else byRepo.set(p.repo, [{ t, score: p.score }]); + } + if (byRepo.size === 0) return null; + const bucketStart = (ts: number) => { + const d = new Date(ts); + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + }; + const now = Date.now(); + const start = bucketStart(now) - (HIST - 1) * DAY; + const liveScore = (tMs: number, arr: Array<{ t: number; score: number }>) => { + let s = 0; + for (const m of arr) { + if (m.t > tMs) continue; + const age = (tMs - m.t) / DAY; + if (age > PR_LOOKBACK_DAYS) continue; + s += m.score * decayMultiplier(age); + } + return s; + }; + const total = HIST + PROJ; + const points: ForecastPoint[] = []; + for (let i = 0; i < total; i++) { + const t = start + i * DAY; + const evalT = i === HIST - 1 ? now : t; + let earned = 0; + const repos: ForecastRepo[] = []; + for (const [repo, arr] of byRepo) { + const sc = liveScore(evalT, arr); + earned += sc; + if (sc > 0.005) repos.push({ repo, score: sc }); + } + repos.sort((a, b) => b.score - a.score); + points.push({ + label: new Date(t).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + earned, + projected: i >= HIST, + repos: repos.slice(0, 6), + }); + } + const liveNow = points[HIST - 1]?.earned ?? 0; + const liveEnd = points[total - 1]?.earned ?? liveNow; + const dropPct = liveNow > 0 ? Math.round((1 - liveEnd / liveNow) * 100) : null; + return { points, nowIdx: HIST - 1, dropPct, projDays: PROJ, liveNow }; +} + +export default function MinerModal({ + view, + subnetTao, + tracked, + onClose, + onToggleTrack, + onPrev, + onNext, +}: MinerModalProps) { + const boxRef = useRef(null); + const [maximized, setMaximized] = useState(false); + const [mainTab, setMainTab] = useState<'overview' | 'contributions'>('overview'); + + const worksLogin = view?.login ?? ''; + const worksGithubId = view?.githubId ?? ''; + const { data: works, isLoading: worksLoading } = useQuery({ + queryKey: ['miner-works', worksLogin, worksGithubId], + enabled: Boolean(view) && (worksLogin !== '' || worksGithubId !== ''), + staleTime: 60_000, + queryFn: async ({ signal }) => { + const params = new URLSearchParams(); + if (worksLogin) params.set('login', worksLogin); + if (worksGithubId) params.set('githubId', worksGithubId); + const r = await fetch(`/api/miner-works?${params.toString()}`, { signal }); + if (!r.ok) throw new Error(`works ${r.status}`); + return (await r.json()) as MinerWorksResponse; + }, + }); + // The miner's GitHub profile bio — shown in the sidebar. + const { data: profile } = useQuery<{ + bio: string | null; + name: string | null; + followers: number | null; + following: number | null; + }>({ + queryKey: ['github-bio', worksLogin], + enabled: Boolean(view) && worksLogin !== '', + staleTime: 6 * 60 * 60 * 1000, + queryFn: async ({ signal }) => { + const r = await fetch(`/api/github-bio?login=${encodeURIComponent(worksLogin)}`, { signal }); + if (!r.ok) return { bio: null, name: null, followers: null, following: null }; + return (await r.json()) as { bio: string | null; name: string | null; followers: number | null; following: number | null }; + }, + }); + + // Derived datasets (hooks must run before the early return). + const activity = works?.activity ?? []; + const hasActivity = activity.some( + (p) => p.openedPrs + p.mergedPrs + p.closedPrs + p.openedIssues + p.resolvedIssues > 0, + ); + const heat = useMemo(() => buildHeatGrid(works?.prs, works?.issues), [works?.prs, works?.issues]); + // Per-repo emission (τ/day) + contribution score for this miner — feeds the + // Contributions repo dropdown (keyed by lowercased repo). + const repoMeta = useMemo(() => { + const m = new Map(); + for (const row of view?.rows ?? []) { + m.set(row.repo.toLowerCase(), { tao: repoTaoOf(row, subnetTao), score: row.prScore + row.issueScore }); + } + return m; + }, [view?.rows, subnetTao]); + const forecast = useMemo(() => buildForecast(works?.prs), [works?.prs]); + // Per-repo signal (incl. credibility) keyed by lowercased repo — feeds the + // Contributions tab's per-repo credibility strip. + const repoSignalMap = useMemo(() => { + const m = new Map(); + for (const row of view?.rows ?? []) m.set(row.repo.toLowerCase(), row); + return m; + }, [view?.rows]); + + // Esc closes; ←/→ step between miners — its own effect so the handler always sees the + // latest callbacks (which the parent re-creates each render) without re-running focus. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + else if (e.key === 'ArrowLeft' && onPrev) onPrev(); + else if (e.key === 'ArrowRight' && onNext) onNext(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose, onPrev, onNext]); + + // Lock body scroll for the modal's lifetime and restore focus to the opener on close. + // Mount-only ([] deps): must NOT re-run on parent re-renders, or it would steal focus + // from controls inside the modal and capture the box itself as the "previous" element. + useEffect(() => { + const prevFocus = document.activeElement as HTMLElement | null; + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prevOverflow; + prevFocus?.focus?.(); + }; + }, []); + + // Move focus into the dialog when it opens and when stepping to another miner. + useEffect(() => { + if (view?.key) boxRef.current?.focus(); + }, [view?.key]); + + // Reset transient UI when switching miners. + useEffect(() => { + setMaximized(false); + setMainTab('overview'); + }, [view?.key]); + + if (!view) return null; + + const status = statusOf(view); + const segments = streamSegments(view, subnetTao); + const segTotal = segments.reduce((sum, s) => sum + s.tao, 0) || 1; + const dominant = [ + { c: PR_COLOR, v: view.prTaoShare }, + { c: ISSUE_COLOR, v: view.issueTaoShare }, + { c: MAINTAINER_COLOR, v: view.maintainerTaoShare }, + ].reduce((a, b) => (b.v > a.v ? b : a)); + const streamColor = dominant.v > 0 ? dominant.c : 'var(--fg-subtle)'; + + // Pending = score withheld as collateral on open PRs (20% of their potential, + // summed across repos), released to the live score as they merge. + const pendingScore = view.collateralScore; + // Headline contribution score (PR + issue-discovery) and SN74 working age. + const totalScore = view.totalScore + view.issueScore; + const firstMs = firstActiveMs(works); + const ageDays = firstMs != null ? Math.max(0, Math.floor((Date.now() - firstMs) / 86_400_000)) : null; + const firstDate = + firstMs != null ? new Date(firstMs).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : null; + const tenure = tenureBadge(ageDays); + const tenureCls = tenure + ? tenure.tier === 'new' + ? styles.mmTenureNew + : tenure.tier === 'rookie' + ? styles.mmTenureRookie + : tenure.tier === 'regular' + ? styles.mmTenureRegular + : tenure.tier === 'veteran' + ? styles.mmTenureVeteran + : styles.mmTenurePioneer + : ''; + // Recompute PR-side stats live from the actual PRs (trailing 30-day window) so the + // insights match the live PR list rather than the lagging scoring snapshot. + const liveRows = applyLivePrStats(view.rows, works?.prs, Date.now()); + const insights = buildInsights(liveRows, forecast); + + // Heatmap geometry (fixed-size SVG, horizontally scrollable). + const CELL = 11; + const STEP = 14; + const LABEL_W = 24; + const MONTH_H = 14; + const hmW = LABEL_W + heat.weeks.length * STEP; + const hmH = MONTH_H + 7 * STEP; + + return ( +
+
+
+ {/* Tenure ribbon — a diagonal corner banner by SN74 working-age tier. */} + {tenure ? ( +
+ + {tenure.label} +
+ ) : null} + + {/* ── Top bar ───────────────────────────────────────────────── */} +
+ {onPrev || onNext ? ( +
+ + +
+ ) : ( + + )} +
+ + + +
+
+ +
+ {/* ===== LEFT SIDEBAR ===== */} + + + {/* ===== RIGHT MAIN ===== */} +
+ {view.failedReason ?
{view.failedReason}
: null} + +
+ {( + [ + ['overview', 'Overview'], + ['contributions', 'Contributions'], + ] as const + ).map(([k, l]) => ( + + ))} +
+ + {mainTab === 'overview' ? ( + <> + {/* Insights & next actions (derived from per-repo signals) — no card frame; + the rows are their own cards, so an outer card would just double-nest. */} + {insights.length > 0 ? ( +
+
+

+ Insights & next actions +

+
+
+ {insights.map((ins, i) => { + const Icon = + ins.kind === 'strong' + ? CheckCircleIcon + : ins.kind === 'warn' + ? AlertIcon + : ins.kind === 'action' + ? LockIcon + : LightBulbIcon; + const kindClass = + ins.kind === 'strong' + ? styles.mmInsightStrong + : ins.kind === 'warn' + ? styles.mmInsightWarn + : ins.kind === 'action' + ? styles.mmInsightAction + : styles.mmInsightInfo; + return ( +
+ + + +
+
{ins.title}
+
{ins.body}
+
+
+ ); + })} +
+
+ ) : null} + + {/* CARD 1 — Activity (PRs / issues / score over the range) */} +
+
+

+ Activity + + + +

+ Last 30 days +
+ + {worksLoading && !works ? ( +
Loading activity…
+ ) : !hasActivity ? ( +
No activity in this range.
+ ) : ( + + )} +
+ + {/* CARD 1b — Earning-power decay forecast */} +
+
+

+ Decay-weighted score + + + +

+ 30d + 14d forecast +
+ + {worksLoading && !works ? ( +
Loading forecast…
+ ) : !forecast ? ( +
No merged PRs to forecast.
+ ) : ( + + )} +
+ + {/* CARDS 2 + 3 — heatmap + emission */} +
+ {/* CARD 3 — Repository activity heatmap */} +
+
+

+ Repository activity +

+ {formatCount(heat.total, { fallback: '0' })} events +
+ {worksLoading && !works ? ( +
+ ) : heat.empty ? ( +
No activity in the last 26 weeks.
+ ) : ( + <> +
+ + {heat.monthTicks.map((m) => ( + + {m.label} + + ))} + {[0, 2, 4].map((row) => ( + + {heat.weekdayLabels[row]} + + ))} + {heat.weeks.map((col, ci) => + col.map((cell, ri) => + cell.pad ? null : ( + + {`${cell.date}: ${cell.count} event${cell.count === 1 ? '' : 's'}`} + + ), + ), + )} + +
+
+ Less + {[0, 1, 2, 3, 4].map((l) => ( + + ))} + More +
+ + )} +
+ + {/* CARD 4 — Emission & reward streams */} +
+
+

+ Emission & reward streams +

+ + {eligibilityLabel(view)} + +
+ +
+ + + {segments.length > 0 ? ( + + {(() => { + const circ = 2 * Math.PI * 44; + let acc = 0; + return segments.map((s) => { + const len = (s.tao / segTotal) * circ; + const node = ( + + ); + acc += len; + return node; + }); + })()} + + ) : null} + + {fmtTao(view.taoPerDay)} + + + τ/day + + +
+ {segments.map((s) => ( +
+ + {s.label} + {fmtTao(s.tao)} τ/d + {Math.round((s.tao / segTotal) * 100)}% +
+ ))} + {segments.length === 0 ?
No active reward stream.
: null} +
+
+
+
+ + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/src/app/miners/_components/MinerWorks.tsx b/src/app/miners/_components/MinerWorks.tsx new file mode 100644 index 0000000..db505ab --- /dev/null +++ b/src/app/miners/_components/MinerWorks.tsx @@ -0,0 +1,1299 @@ +'use client'; + +/* eslint-disable @next/next/no-img-element */ + +/* The "all works" lists for the miner detail modal: every repo the miner has a + * signal on, their scored pull requests, and their issues. Presentational only — + * the modal owns the tab state and the works fetch. */ + +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { useQuery } from '@tanstack/react-query'; +import { + CheckCircleIcon, + ChevronDownIcon, + ChevronLeftIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestIcon, + IssueClosedIcon, + IssueOpenedIcon, + LinkExternalIcon, + XCircleIcon, +} from '@primer/octicons-react'; +import { formatCount, formatNumber, formatRelativeTime, isRecent } from '@/lib/format'; +import { IssueLabels } from '@/components/IssueLabels'; +import styles from '../page.module.css'; +import type { MinerIssue, MinerPr } from '@/types/entities'; +import { + isBlockedContribution, + repoEarnsIssueDiscovery, + repoEarnsPr, + repoTaoOf, + score as fmtScore, + type MinerView, + type RepoSignal, +} from '../_lib/miners'; +import { ISSUE_COLOR, MAINTAINER_COLOR, PR_COLOR } from '../_lib/streams'; +import { RepoRingAvatar } from './shared'; + +const fmtTao = (n: number) => formatNumber(n, { digits: 3, fallback: '0' }); + +/** Compact relative time. Client-only (the modal never SSRs), so Date.now() here + * can't cause a hydration mismatch. */ +function relTime(iso: string | null): string { + if (!iso) return ''; + const t = Date.parse(iso); + if (!Number.isFinite(t)) return ''; + const d = Math.floor((Date.now() - t) / 86_400_000); + if (d <= 0) return 'today'; + if (d < 30) return `${d}d`; + const mo = Math.floor(d / 30); + if (mo < 12) return `${mo}mo`; + return `${Math.floor(mo / 12)}y`; +} + +function repoAvatar(repo: string): string { + return `https://github.com/${encodeURIComponent(repo.split('/')[0])}.png?size=40`; +} + +/** Skeleton placeholder rows while works load. */ +function RowsSkeleton({ rows = 6 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + +
+ ))} +
+ ); +} + +function FilterChips({ + options, + active, + onChange, +}: { + options: Array<{ key: T; label: string; n?: number }>; + active: T; + onChange: (k: T) => void; +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +// ─── Repositories ────────────────────────────────────────────────────────────── + +type RepoRole = { label: string; color: string }; + +function repoRole(view: MinerView, row: RepoSignal): RepoRole { + const maintained = view.maintainerRepos.some((r) => r.toLowerCase() === row.repo.toLowerCase()); + if (maintained) return { label: 'Maintainer', color: MAINTAINER_COLOR }; + if (repoEarnsIssueDiscovery(row)) return { label: 'Issue discovery', color: ISSUE_COLOR }; + if (repoEarnsPr(row)) return { label: 'Earning', color: PR_COLOR }; + if (isBlockedContribution(row, false)) return { label: 'Working toward', color: 'var(--attention-fg)' }; + return { label: 'Contributing', color: 'var(--fg-subtle)' }; +} + +export function RepoWorkList({ view, subnetTao }: { view: MinerView; subnetTao: number }) { + const rows = [...view.rows].sort( + (a, b) => repoTaoOf(b, subnetTao) - repoTaoOf(a, subnetTao) || b.prScore + b.issueScore - (a.prScore + a.issueScore), + ); + if (rows.length === 0) return
No repo activity in the feed.
; + return ( +
+ {rows.map((row) => { + const role = repoRole(view, row); + const tao = repoTaoOf(row, subnetTao); + const contribScore = row.prScore + row.issueScore; + return ( + + +
+
+ {row.repo} + + {role.label} + +
+
+ + + {formatCount(row.prs, { fallback: '0' })} + + + + {formatCount(row.issues, { fallback: '0' })} + + {contribScore > 0 ? score {fmtScore(contribScore)} : null} +
+
+ {tao > 0 ? `${fmtTao(tao)} τ/d` : '—'} +
+ ); + })} +
+ ); +} + +// ─── Pull requests ────────────────────────────────────────────────────────────── + +const PR_STATE: Record = { + MERGED: { Icon: GitMergeIcon, color: 'var(--done-fg)' }, + OPEN: { Icon: GitPullRequestIcon, color: 'var(--success-fg)' }, + CLOSED: { Icon: GitPullRequestClosedIcon, color: 'var(--danger-fg)' }, +}; + +export function PrWorkList({ + prs, + counts, + loading, +}: { + prs: MinerPr[] | undefined; + counts: { prs: number; prMerged: number; prOpen: number; prClosed: number } | undefined; + loading: boolean; +}) { + const [filter, setFilter] = useState<'all' | 'MERGED' | 'OPEN' | 'CLOSED'>('all'); + if (loading && !prs) return ; + const list = prs ?? []; + if (list.length === 0) return
No scored pull requests found.
; + const shown = filter === 'all' ? list : list.filter((p) => p.state === filter); + return ( + <> + + + + ); +} + +// ─── Issues ────────────────────────────────────────────────────────────────────── + +function issueState(i: MinerIssue): { Icon: typeof IssueOpenedIcon; color: string; key: 'open' | 'completed' | 'closed' } { + if (i.state === 'open') return { Icon: IssueOpenedIcon, color: 'var(--attention-fg)', key: 'open' }; + if ((i.stateReason ?? '').toUpperCase() === 'COMPLETED') return { Icon: CheckCircleIcon, color: 'var(--success-fg)', key: 'completed' }; + return { Icon: XCircleIcon, color: 'var(--danger-fg)', key: 'closed' }; +} + +export function IssueWorkList({ + issues, + counts, + loading, +}: { + issues: MinerIssue[] | undefined; + counts: { issues: number; issuesOpen: number; issuesCompleted: number } | undefined; + loading: boolean; +}) { + const [filter, setFilter] = useState<'all' | 'open' | 'completed'>('all'); + if (loading && !issues) return ; + const list = issues ?? []; + if (list.length === 0) return
No issues found for this miner.
; + const shown = + filter === 'all' + ? list + : list.filter((i) => issueState(i).key === filter); + return ( + <> + +
+ {shown.map((iss) => { + const st = issueState(iss); + const Icon = st.Icon; + const href = iss.htmlUrl ?? `https://github.com/${iss.repo}/issues/${iss.number}`; + return ( + + + + +
+
{iss.title}
+
+ {iss.repo} + #{iss.number} +
+
+
+ {relTime(iss.createdAt)} + +
+
+ ); + })} + {shown.length === 0 ?
No {filter} issues.
: null} +
+ + ); +} + +// ─── Dashboard: combined PR/issue table + heatmap grid ────────────────────────── + +export type WorkStatus = 'merged' | 'open' | 'review' | 'closed' | 'completed'; +export interface WorkRow { + kind: 'pr' | 'issue'; + repo: string; + number: number; + title: string; + href: string; + status: WorkStatus; + createdAt: string | null; + updatedAt: string | null; + /** Sort key — most-recently-updated first. */ + ts: number; + /** Source PR — full scoring breakdown for the detail view (kind:'pr' only). */ + pr?: MinerPr; + /** Issue close reason (kind:'issue'). */ + stateReason?: string | null; + /** GitHub labels (name + hex color) shown inline on the row. */ + labels: Array<{ name: string; color?: string }>; +} + +function prStatus(p: MinerPr): WorkStatus { + if (p.state === 'MERGED') return 'merged'; + if (p.state === 'OPEN') return 'open'; + return 'closed'; +} +function issueStatus(i: MinerIssue): WorkStatus { + if ((i.state ?? '').toLowerCase() === 'open') return 'open'; + if ((i.stateReason ?? '').toUpperCase() === 'COMPLETED') return 'completed'; + return 'closed'; +} +function parseTs(iso: string | null): number { + if (!iso) return 0; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : 0; +} + +/** Merge PRs + issues into one list of table rows, most-recently-updated first. + * PRs have no generic updatedAt in the feed, so mergedAt (or createdAt) stands in. */ +export function buildWorkRows(prs: MinerPr[] | undefined, issues: MinerIssue[] | undefined): WorkRow[] { + const rows: WorkRow[] = []; + for (const p of prs ?? []) { + const updatedAt = p.mergedAt ?? p.createdAt; + rows.push({ + kind: 'pr', + repo: p.repo, + number: p.number, + title: p.title, + href: `https://github.com/${p.repo}/pull/${p.number}`, + status: prStatus(p), + createdAt: p.createdAt, + updatedAt, + ts: parseTs(updatedAt) || parseTs(p.createdAt), + pr: p, + labels: p.labels ?? [], + }); + } + for (const i of issues ?? []) { + rows.push({ + kind: 'issue', + repo: i.repo, + number: i.number, + title: i.title, + href: i.htmlUrl ?? `https://github.com/${i.repo}/issues/${i.number}`, + status: issueStatus(i), + createdAt: i.createdAt, + updatedAt: i.updatedAt ?? i.createdAt, + ts: parseTs(i.updatedAt ?? i.createdAt), + stateReason: i.stateReason, + labels: i.labels ?? [], + }); + } + rows.sort((a, b) => b.ts - a.ts); + return rows; +} + +/** Relative time cell matching the explorer's RecentTime (recent → green + pulse). */ +function RecentTime({ iso }: { iso: string | null }) { + if (!iso) return ; + if (isRecent(iso)) { + return ( + + + {formatRelativeTime(iso)} + + ); + } + return <>{formatRelativeTime(iso)}; +} + +// Solid emphasis pills matching the explorer's StatusBadge (same colors + octicons). +const STATUS_META: Record = { + merged: { label: 'Merged', bg: 'var(--done-emphasis)' }, + open: { label: 'Open', bg: 'var(--success-emphasis)' }, + review: { label: 'Review', bg: 'var(--attention-emphasis)' }, + completed: { label: 'Completed', bg: 'var(--done-emphasis)' }, + closed: { label: 'Closed', bg: 'var(--danger-emphasis)' }, +}; + +function statusIcon(status: WorkStatus, kind: 'pr' | 'issue') { + if (kind === 'issue') return status === 'open' ? IssueOpenedIcon : IssueClosedIcon; + if (status === 'merged') return GitMergeIcon; + if (status === 'closed') return GitPullRequestClosedIcon; + return GitPullRequestIcon; +} + +function StatusPill({ status, kind }: { status: WorkStatus; kind: 'pr' | 'issue' }) { + const st = STATUS_META[status]; + const Icon = statusIcon(status, kind); + return ( + + + {st.label} + + ); +} + +interface WorkDetailRow { + body?: string | null; + html_url?: string | null; + author_login?: string | null; +} + +// ─── PR/issue detail (rich scoring view) ──────────────────────────────────────── + +// Default subnet time-decay config (see DEFAULT_SCORING.timeDecay in lib/repos). +const DECAY = { graceHours: 12, midpointDays: 10, steepness: 0.4, minMult: 0.05 }; +/** PRs older than this drop out of the validator's scoring window entirely — a merged + * PR's earning-power contribution goes to 0 past this age. */ +export const PR_LOOKBACK_DAYS = 30; +/** Sigmoid time-decay multiplier: fresh ≈ 1×, decaying toward minMultiplier with + * 50% near the midpoint — reproduces the validator's freshness curve. */ +export function decayMultiplier(ageDays: number): number { + const eff = Math.max(0, ageDays - DECAY.graceHours / 24); + const sig = 1 / (1 + Math.exp(DECAY.steepness * (eff - DECAY.midpointDays))); + return DECAY.minMult + (1 - DECAY.minMult) * sig; +} +const fmtMult = (m: number) => `${m.toFixed(2)}×`; +const fmtNum = (n: number) => formatNumber(n, { digits: n >= 100 ? 0 : 2, fallback: '0' }); + +/** Earned (peak, at merge) vs live (time-decayed) score for a row. The /prs feed score + * is the un-decayed peak; merged PRs shed value over time via the freshness curve. Only + * PRs carry a per-item score — issues return null (no per-issue scoring). */ +function workScores(row: WorkRow): { earned: number; live: number } | null { + if (row.kind !== 'pr' || !row.pr || !(row.pr.score > 0)) return null; + const earned = row.pr.score; + const days = row.pr.mergedAt ? Math.max(0, (Date.now() - Date.parse(row.pr.mergedAt)) / 86_400_000) : null; + const live = days != null ? earned * decayMultiplier(days) : earned; + return { earned, live }; +} + +/** Freshness tier from the retained fraction (live / initial) — drives the Current + * chip's color so decay reads at a glance: fresh → fading → stale. */ +function freshnessClass(ratio: number): string { + if (ratio >= 0.7) return styles.mmScoreFresh; + if (ratio >= 0.3) return styles.mmScoreFading; + return styles.mmScoreStale; +} +const fmtDate = (iso: string | null) => + iso ? new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; +const truncMid = (s: string, head = 8, tail = 8) => (s.length > head + tail + 1 ? `${s.slice(0, head)}…${s.slice(-tail)}` : s); + +/** Time-decay sigmoid curve (0–30 days) with a "Now" marker. */ +/** A single PR's value decaying over the 30-day window — styled to match the Overview + * EarningForecastChart (bordered container, dashed gridlines, smooth indigo line + area, + * dashed "now" divider + dot). */ +function TimeDecayChart({ daysSinceMerge, peakScore }: { daysSinceMerge: number; peakScore: number }) { + const LINE = '#6366f1'; // indigo-500 — same as EarningForecastChart + // Measure the real width so the viewBox is 1:1 (no label stretching), like the forecast. + const boxRef = useRef(null); + const svgRef = useRef(null); + const [hoverDay, setHoverDay] = useState(null); + const [measured, setMeasured] = useState(560); + useEffect(() => { + const el = boxRef.current; + if (!el) return; + setMeasured(el.clientWidth); + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w && w > 0) setMeasured(w); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + const W = Math.max(280, measured); + const Hc = 220; + const padL = 36; + const padR = 14; + const padT = 14; + const padB = 32; + const plotW = W - padL - padR; + const plotH = Hc - padT - padB; + const maxDays = 30; + const x = (d: number) => padL + (Math.min(maxDays, Math.max(0, d)) / maxDays) * plotW; + const y = (m: number) => padT + (1 - m) * plotH; + // Split the curve at "now" — solid for the elapsed decay, dashed for the future + // (same solid-history + dashed-projection treatment as the EarningForecastChart). + const baseY = padT + plotH; + const nowD = Math.min(maxDays, Math.max(0, daysSinceMerge)); + const histArr: Array<[number, number]> = []; + const projArr: Array<[number, number]> = []; + for (let d = 0; d <= maxDays; d += 0.5) { + const p: [number, number] = [x(d), y(decayMultiplier(d))]; + if (d < nowD) histArr.push(p); + else projArr.push(p); + } + const nowP: [number, number] = [x(nowD), y(decayMultiplier(nowD))]; + histArr.push(nowP); + projArr.unshift(nowP); + const toPath = (arr: Array<[number, number]>) => + arr.map(([px, py], i) => `${i ? 'L' : 'M'}${px.toFixed(1)} ${py.toFixed(1)}`).join(' '); + const histLine = toPath(histArr); + const projLine = toPath(projArr); + const area = `${histLine} L ${nowP[0].toFixed(1)} ${baseY.toFixed(1)} L ${histArr[0][0].toFixed(1)} ${baseY.toFixed(1)} Z`; + const midDay = DECAY.graceHours / 24 + DECAY.midpointDays; + const nowX = x(daysSinceMerge); + const nowY = y(decayMultiplier(daysSinceMerge)); + + // Hover anywhere on the plot → read the multiplier at that day (same vertical-line + + // dot + foreignObject tooltip as the Overview forecast chart). + const onMove = (e: React.MouseEvent) => { + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return; + const vx = (e.clientX - rect.left) * (W / rect.width); + setHoverDay(Math.min(maxDays, Math.max(0, ((vx - padL) / plotW) * maxDays))); + }; + const hover = + hoverDay == null + ? null + : { + day: hoverDay, + mult: decayMultiplier(hoverDay), + hx: x(hoverDay), + hy: y(decayMultiplier(hoverDay)), + forecast: hoverDay > daysSinceMerge, + }; + const tipW = 176; + const tipH = 80; + const tipX = hover ? Math.min(W - tipW - 8, Math.max(8, hover.hx - tipW / 2)) : 0; + const tipY = padT + 8; + + return ( +
+ + + + + + + + {[0, 0.25, 0.5, 0.75, 1].map((g) => { + const gy = padT + (1 - g) * plotH; + return ( + + + + {Math.round(g * 100)} + + + ); + })} + {[0, 5, 10, 15, 20, 25, 30].map((d) => ( + + {d} + + ))} + + days since merge + + + + + + + 50% @ midpoint + + + + + Now {fmtMult(decayMultiplier(daysSinceMerge))} + + setHoverDay(null)} + /> + {hover ? ( + + + + +
+
+ {hover.day.toFixed(1)}d since merge + {hover.forecast ? · forecast : null} +
+
+ + Multiplier + {fmtMult(hover.mult)} +
+
+ + Score + {fmtNum(peakScore * hover.mult)} +
+
+
+
+ ) : null} +
+
+ ); +} + +/** Structural vs leaf token-score donut. */ +function TokenDonut({ pr }: { pr: MinerPr }) { + const segs = [ + { key: 's', val: pr.structuralScore, color: 'var(--success-emphasis)' }, + { key: 'l', val: pr.leafScore, color: 'var(--fg-subtle)' }, + ].filter((s) => s.val > 0); + const total = segs.reduce((a, b) => a + b.val, 0) || 1; + const circ = 2 * Math.PI * 44; + let acc = 0; + return ( +
+
Token composition
+ + + + {segs.map((s) => { + const len = (s.val / total) * circ; + const node = ( + + ); + acc += len; + return node; + })} + + + {fmtNum(pr.tokenScore)} + + + token score + + +
+ + Structural + + + Leaf + +
+
+ ); +} + +/** The scoring breakdown table (base/token/structural/leaf/changes/commits/hotkey). */ +function ScoreBreakdown({ pr }: { pr: MinerPr }) { + const rows: Array<[string, React.ReactNode]> = [ + ['Base score', fmtNum(pr.baseScore)], + ['Tokens scored', formatCount(pr.totalNodesScored, { fallback: '0' })], + ['Token score', fmtNum(pr.tokenScore)], + ['Structural', `${formatCount(pr.structuralCount, { fallback: '0' })} · score ${fmtNum(pr.structuralScore)}`], + ['Leaf', `${formatCount(pr.leafCount, { fallback: '0' })} · score ${fmtNum(pr.leafScore)}`], + [ + 'Changes', + + +{formatCount(pr.additions, { fallback: '0' })}{' / '} + −{formatCount(pr.deletions, { fallback: '0' })} + , + ], + ['Commits', formatCount(pr.commitCount, { fallback: '0' })], + [ + 'Hotkey', + + {pr.hotkey ? truncMid(pr.hotkey) : '—'} + , + ], + ]; + return ( +
+ {rows.map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+ ); +} + +/** Master-detail view shown when a table row is clicked. PRs get the full scoring + * story (multipliers, time-decay curve, breakdown, token donut); issues show the + * fetched description. */ +function WorkDetail({ row, onBack }: { row: WorkRow; onBack: () => void }) { + const [owner, name] = row.repo.split('/'); + const isPr = row.kind === 'pr'; + const pr = row.pr; + // PRs render entirely from the feed; only issues need the body fetch. + const { data, isLoading, isError } = useQuery({ + queryKey: ['work-detail', row.kind, row.repo, row.number], + enabled: !isPr, + staleTime: 300_000, + queryFn: async ({ signal }) => { + const r = await fetch(`/api/${isPr ? 'pull' : 'issue'}/${owner}/${name}/${row.number}`, { signal }); + if (!r.ok) throw new Error(String(r.status)); + return (await r.json()) as WorkDetailRow; + }, + }); + const ghHref = data?.html_url ?? row.href; + const dateLabel = row.status === 'merged' ? 'Merged' : row.status === 'closed' ? 'Closed' : isPr ? 'Opened' : 'Updated'; + const dateIso = row.status === 'merged' ? row.updatedAt : row.createdAt; + const daysSinceMerge = isPr && pr?.mergedAt ? Math.max(0, (Date.now() - Date.parse(pr.mergedAt)) / 86_400_000) : null; + + const bodyBlock = isLoading ? ( +
+ ) : data?.body ? ( +
{data.body}
+ ) : ( +
{isError ? 'Description unavailable on this server.' : 'No description.'}
+ ); + + return ( +
+
+ + + #{row.number} + + {row.labels.length > 0 ? : null} + {isPr && pr ? ( +
+ Score + {fmtNum(pr.score)} +
+ ) : null} +
+ +

+ + {row.title} + + +

+ + {row.repo} + +
+ + {dateLabel} {fmtDate(dateIso)} + +
+ + {isPr && pr ? ( +
+ {daysSinceMerge != null ? ( +
+
+
Time decay
+ + {fmtMult(decayMultiplier(daysSinceMerge))} · {daysSinceMerge.toFixed(1)}d since merge + +
+ +
+ ) : null} +
+ + +
+
+ ) : ( +
+ {bodyBlock} + + View on GitHub + +
+ )} +
+ ); +} + +const fmtTaoVal = (n: number) => formatNumber(n, { digits: 3, fallback: '0' }); + +/** Fallback avatar for a repo with works but no per-repo scoring row (so no + * credibility) — a neutral gray ring at the same size as the credibility ring + * avatars, keeping the list visually consistent ("no credibility data" here). */ +function NeutralRingAvatar({ repo, size = 34 }: { repo: string; size?: number }) { + const imgSize = size - 7; + const r = (size - 2.5) / 2; + return ( + + + + + + + ); +} + +/** Custom repository dropdown — a rich row per repo: avatar, the miner's τ/day + + * score there, and their per-repo credibility gauges (also shown on the trigger). */ +function RepoDropdown({ + repos, + value, + onChange, + meta, + repoSignals, + maintainerRepos, +}: { + repos: Array<{ repo: string; n: number; prs: number; issues: number }>; + value: string; + onChange: (r: string) => void; + meta: Map; + repoSignals: Map; + maintainerRepos: string[]; +}) { + const [open, setOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const [pos, setPos] = useState<{ top: number; left: number; width: number; maxHeight: number } | null>(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + + useEffect(() => setMounted(true), []); + + // Anchor the (body-portaled, fixed) menu to the trigger, clamped to the viewport + // so it never spills off-screen on narrow / mobile layouts. + useLayoutEffect(() => { + if (!open || !triggerRef.current) return; + const place = () => { + const r = triggerRef.current!.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + const width = Math.min(360, vw - 16); + let left = r.right - width; // right-align to the trigger + left = Math.min(left, vw - width - 8); + left = Math.max(8, left); + const top = Math.min(r.bottom + 4, vh - 80); + const maxHeight = Math.max(180, vh - top - 12); + setPos({ top, left, width, maxHeight: Math.min(360, maxHeight) }); + }; + place(); + window.addEventListener('resize', place); + return () => window.removeEventListener('resize', place); + }, [open]); + + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + const t = e.target as Node; + if (menuRef.current?.contains(t) || triggerRef.current?.contains(t)) return; + setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + // Close when the page/modal scrolls — but NOT when scrolling inside the menu itself. + const onScroll = (e: Event) => { + if (menuRef.current?.contains(e.target as Node)) return; + setOpen(false); + }; + document.addEventListener('mousedown', onDoc); + document.addEventListener('keydown', onKey); + window.addEventListener('scroll', onScroll, true); + return () => { + document.removeEventListener('mousedown', onDoc); + document.removeEventListener('keydown', onKey); + window.removeEventListener('scroll', onScroll, true); + }; + }, [open]); + + return ( +
+ + {mounted && open && pos + ? createPortal( +
+ {repos.map(({ repo, prs, issues }) => { + const m = meta.get(repo.toLowerCase()); + const sig = repoSignals.get(repo.toLowerCase()); + const maintained = maintainerRepos.some((r) => r.toLowerCase() === repo.toLowerCase()); + // Dual-cred repos → show only the dominant stream's ring so every + // avatar stays a single ring at the same size (no shrunk inner image). + const only: 'pr' | 'issue' | undefined = + sig && sig.issueDiscoveryShare > 0 && sig.issueDiscoveryShare < 1 + ? sig.issueDiscoveryShare >= 0.5 + ? 'issue' + : 'pr' + : undefined; + const sel = repo === value; + const slash = repo.indexOf('/'); + const owner = slash >= 0 ? repo.slice(0, slash + 1) : ''; + const name = slash >= 0 ? repo.slice(slash + 1) : repo; + const hasTao = !!(m && m.tao > 0); + const countParts: string[] = []; + if (prs > 0) countParts.push(`${prs} ${prs === 1 ? 'PR' : 'PRs'}`); + if (issues > 0) countParts.push(`${issues} ${issues === 1 ? 'issue' : 'issues'}`); + const countText = countParts.join(' + ') || '0'; + return ( + + ); + })} +
, + document.body, + ) + : null} +
+ ); +} + +/** The Pull-requests & Issues card — a sortable table with PR/issue filters and a + * per-repository dropdown (avatar + the miner's τ/day + score per repo). */ +export function PrsIssuesTable({ + prs, + issues, + loading, + login, + repoMeta, + repoSignals, + maintainerRepos, +}: { + prs: MinerPr[] | undefined; + issues: MinerIssue[] | undefined; + loading: boolean; + login: string; + repoMeta: Map; + repoSignals: Map; + maintainerRepos: string[]; +}) { + const [filter, setFilter] = useState<'pr' | 'issue'>('pr'); + const [repoFilter, setRepoFilter] = useState(''); + const [selected, setSelected] = useState(null); + // Drop the open detail when the miner (works) changes. + useEffect(() => setSelected(null), [prs, issues]); + const all = useMemo(() => buildWorkRows(prs, issues), [prs, issues]); + // Repos this miner has works on, most-active first — drives the per-repo dropdown. + // Grouped case-insensitively (the /prs feed lowercases repo names while the issues + // mirror keeps GitHub's canonical case — otherwise the same repo shows up twice). + const repoList = useMemo(() => { + // lowercased → display-variant counts + per-kind tallies + const groups = new Map; prs: number; issues: number }>(); + for (const r of all) { + const key = r.repo.toLowerCase(); + let g = groups.get(key); + if (!g) { + g = { variants: new Map(), prs: 0, issues: 0 }; + groups.set(key, g); + } + g.variants.set(r.repo, (g.variants.get(r.repo) ?? 0) + 1); + if (r.kind === 'pr') g.prs += 1; + else g.issues += 1; + } + return [...groups.values()] + .map(({ variants, prs, issues }) => { + let repo = ''; + let best = -1; + for (const [variant, count] of variants) { + if (count > best) { + best = count; + repo = variant; // display the most-common (usually GitHub-canonical) case + } + } + return { repo, n: prs + issues, prs, issues }; + }) + .sort((a, b) => b.n - a.n || a.repo.localeCompare(b.repo)); + }, [all]); + // Always scoped to ONE repo — defaults to the most-active, and falls back to it + // when the selection isn't in this miner's set (e.g. after stepping to another). + const effectiveRepo = repoList.some((r) => r.repo === repoFilter) ? repoFilter : repoList[0]?.repo ?? ''; + const effectiveRepoLc = effectiveRepo.toLowerCase(); + const repoScoped = effectiveRepo ? all.filter((r) => r.repo.toLowerCase() === effectiveRepoLc) : all; + const nPr = repoScoped.filter((r) => r.kind === 'pr').length; + const nIssue = repoScoped.filter((r) => r.kind === 'issue').length; + // Never default onto an empty tab. + const effFilter = filter === 'pr' && nPr === 0 && nIssue > 0 ? 'issue' : filter === 'issue' && nIssue === 0 && nPr > 0 ? 'pr' : filter; + const shown = repoScoped.filter((r) => r.kind === effFilter); + const footHref = effectiveRepo + ? `https://github.com/${effectiveRepo}/pulls?q=is:pr+author:${encodeURIComponent(login)}` + : `https://github.com/${login}`; + + if (selected) { + return ( +
+ setSelected(null)} /> +
+ ); + } + + return ( +
+
+

+ Pull requests & issues +

+
+ {repoList.length > 0 ? ( + + ) : null} +
+ {( + [ + { key: 'pr', label: 'PRs', n: nPr }, + { key: 'issue', label: 'Issues', n: nIssue }, + ] as const + ).map((t) => ( + + ))} +
+
+
+ + {loading && !prs && !issues ? ( + + ) : shown.length === 0 ? ( +
+ No {effFilter === 'pr' ? 'pull requests' : 'issues'} found + {effectiveRepo ? ` in ${effectiveRepo}` : ''}. +
+ ) : ( +
+ + + + + + + + + + + + + {shown.slice(0, 150).map((r) => { + const sc = workScores(r); + return ( + setSelected(r)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelected(r); + } + }} + > + + + + + + + + ); + })} + +
State{effFilter === 'pr' ? 'Pull request' : 'Issue'} + Base + + Live + CreatedUpdated
+ + + + {r.title} + #{r.number} + {r.labels.length > 0 ? : null} + + + {sc ? {fmtNum(sc.earned)} : } + + {sc ? ( + + {fmtNum(sc.live)} + + ) : ( + + )} + {formatRelativeTime(r.createdAt)} + +
+
+ )} + + {shown.length > 0 ? ( + + ) : null} +
+ ); +} + +// ─── Repository-activity heatmap grid (GitHub-style) ──────────────────────────── + +const HM_WEEKS = 26; +const HM_DAY_MS = 86_400_000; +const HM_WD = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const; + +export interface HeatCell { + ts: number; + date: string; + count: number; + level: 0 | 1 | 2 | 3 | 4; + pad: boolean; +} +export interface HeatGrid { + weeks: HeatCell[][]; + monthTicks: Array<{ col: number; label: string }>; + weekdayLabels: readonly string[]; + total: number; + busiestCount: number; + empty: boolean; +} + +function hmDayStart(d: Date): number { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); +} +function hmIso(ts: number): string { + const d = new Date(ts); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +/** Bucket activity events into a 26-week Mon..Sun calendar grid with 5 green + * intensity levels (relative to the busiest day). Each PR contributes an event on + * the day it was opened AND the day it was merged; each issue, on the day it was + * opened — so a merged PR shows up as two distinct days of activity. */ +export function buildHeatGrid(prs: MinerPr[] | undefined, issues: MinerIssue[] | undefined): HeatGrid { + const counts = new Map(); + const bump = (iso: string | null) => { + if (!iso) return; + const t = Date.parse(iso); + if (!Number.isFinite(t)) return; + const k = hmDayStart(new Date(t)); + counts.set(k, (counts.get(k) ?? 0) + 1); + }; + for (const p of prs ?? []) { + bump(p.createdAt); + bump(p.mergedAt); // a merge is its own activity event, on its own day + } + for (const i of issues ?? []) bump(i.createdAt); + + const todayStart = hmDayStart(new Date()); + const todayWdMon0 = (new Date(todayStart).getDay() + 6) % 7; + const lastSunday = todayStart + (6 - todayWdMon0) * HM_DAY_MS; + const startTs = lastSunday - (HM_WEEKS * 7 - 1) * HM_DAY_MS; + // Normalise intensity over the VISIBLE window only — a busier day outside the rendered + // 26 weeks must not dim the in-window cells and understate recent activity. + let busiest = 0; + for (const [day, v] of counts) if (day >= startTs && day <= lastSunday && v > busiest) busiest = v; + const level = (c: number): HeatCell['level'] => { + if (c <= 0) return 0; + if (busiest <= 1) return 4; + const q = c / busiest; + if (q <= 0.25) return 1; + if (q <= 0.5) return 2; + if (q <= 0.75) return 3; + return 4; + }; + + const weeks: HeatCell[][] = []; + const monthTicks: Array<{ col: number; label: string }> = []; + let prevMonth = -1; + let total = 0; + for (let col = 0; col < HM_WEEKS; col++) { + const colCells: HeatCell[] = []; + for (let row = 0; row < 7; row++) { + const ts = startTs + (col * 7 + row) * HM_DAY_MS; + const future = ts > todayStart; + const c = counts.get(ts) ?? 0; + total += future ? 0 : c; + colCells.push({ ts, date: hmIso(ts), count: future ? 0 : c, level: future ? 0 : level(c), pad: future }); + } + weeks.push(colCells); + const m = new Date(colCells[0].ts).getMonth(); + if (m !== prevMonth) { + monthTicks.push({ col, label: new Date(colCells[0].ts).toLocaleString(undefined, { month: 'short' }) }); + prevMonth = m; + } + } + return { weeks, monthTicks, weekdayLabels: HM_WD, total, busiestCount: busiest, empty: total === 0 }; +} + +/** Green fill for a heat level (theme-safe via color-mix on the success token). */ +export function heatFill(level: number): string { + if (level <= 0) return 'color-mix(in srgb, var(--fg-subtle) 12%, transparent)'; + const pctByLevel = [0, 32, 52, 74, 100][level] ?? 100; + return `color-mix(in srgb, var(--success-emphasis) ${pctByLevel}%, var(--bg-inset))`; +} diff --git a/src/app/miners/_components/Palette.tsx b/src/app/miners/_components/Palette.tsx new file mode 100644 index 0000000..63b6292 --- /dev/null +++ b/src/app/miners/_components/Palette.tsx @@ -0,0 +1,155 @@ +'use client'; + +/* eslint-disable @next/next/no-img-element */ + +/* ⌘K command palette — fuzzy-jump to any miner by login, UID, GitHub ID, or + * a repo they work in. Enter opens that miner's drawer. Mirrors the + * repositories palette. */ + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { FlameIcon } from '@primer/octicons-react'; +import { formatNumber, formatTao, formatUsd } from '@/lib/format'; +import styles from '../page.module.css'; +import { type MinerView } from '../_lib/miners'; +import StreamTags from './StreamTags'; + +interface PaletteProps { + open: boolean; + views: MinerView[]; + onClose: () => void; + onSelect: (key: string) => void; +} + +export default function Palette({ open, views, onClose, onSelect }: PaletteProps) { + const [q, setQ] = useState(''); + const [active, setActive] = useState(0); + const inputRef = useRef(null); + const itemRefs = useRef>([]); + + useEffect(() => { + if (open) { + setQ(''); + setActive(0); + const t = window.setTimeout(() => inputRef.current?.focus(), 30); + return () => window.clearTimeout(t); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + const matched = useMemo(() => { + const needle = q.toLowerCase().trim(); + const list = needle + ? views.filter((v) => { + const hay = `${v.login} ${v.githubId} ${v.uid ?? ''} ${v.rows.map((r) => r.repo).join(' ')}`.toLowerCase(); + return hay.includes(needle); + }) + : [...views].sort((a, b) => b.activity - a.activity); + // Show every miner: searching filters, no query lists all of them sorted by + // activity. No cap — the list scrolls — so zero-earning miners (active but + // unpaid, low activity) stay browsable instead of being cut with the tail. + return list; + }, [q, views]); + + useEffect(() => { + setActive((i) => (matched.length === 0 ? 0 : Math.min(i, matched.length - 1))); + }, [matched]); + + useEffect(() => { + itemRefs.current[active]?.scrollIntoView({ block: 'nearest' }); + }, [active]); + + return ( +
+
+
+
+ + + + + setQ(e.target.value)} + onKeyDown={(e) => { + if (matched.length === 0) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActive((i) => (i + 1) % matched.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActive((i) => (i - 1 + matched.length) % matched.length); + } else if (e.key === 'Enter') { + e.preventDefault(); + const view = matched[active]; + if (view) { + onSelect(view.key); + onClose(); + } + } + }} + /> + ESC +
+
+ {matched.length === 0 ? ( +
No miners match.
+ ) : ( + matched.map((view, idx) => { + return ( + + ); + }) + )} +
+
+
+ ); +} diff --git a/src/app/miners/_components/StreamTags.tsx b/src/app/miners/_components/StreamTags.tsx new file mode 100644 index 0000000..7f38419 --- /dev/null +++ b/src/app/miners/_components/StreamTags.tsx @@ -0,0 +1,21 @@ +// Reward-stream swatches shown beside a miner's name (palette rows + the overview +// inspector). A miner with no attributable stream gets a single neutral swatch +// rather than a misleading green one. Shared so the two render sites stay in sync. + +import styles from '../page.module.css'; +import { type MinerView } from '../_lib/miners'; +import { ISSUE_COLOR, MAINTAINER_COLOR, NEUTRAL_COLOR, PR_COLOR, streamsOf } from '../_lib/streams'; + +export default function StreamTags({ view }: { view: MinerView }) { + const { pr, issue, maintainer } = streamsOf(view); + return ( + + {pr ? : null} + {issue ? : null} + {maintainer ? : null} + {!pr && !issue && !maintainer ? ( + + ) : null} + + ); +} diff --git a/src/app/miners/_components/shared.tsx b/src/app/miners/_components/shared.tsx new file mode 100644 index 0000000..1ecfd3d --- /dev/null +++ b/src/app/miners/_components/shared.tsx @@ -0,0 +1,1017 @@ +'use client'; + +/* eslint-disable @next/next/no-img-element */ + +/* Small presentational primitives shared by the card, list row, drawer, + * headline, and palette. Keeping them here (rather than re-declaring per + * surface) keeps the visual language consistent. */ + +import React, { useId } from 'react'; +import { + CheckCircleIcon, + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestIcon, + IssueOpenedIcon, + StarFillIcon, + StarIcon, + XCircleIcon, +} from '@primer/octicons-react'; +import { formatCount, formatNumber } from '@/lib/format'; +import styles from '../page.module.css'; +import { blockGate, eligibilityLabel, pct, score, type MinerView, type RepoSignal } from '../_lib/miners'; +import { fillBadge, streamsOf, ISSUE_COLOR, MAINTAINER_COLOR, NEUTRAL_COLOR, PR_COLOR } from '../_lib/streams'; + +export type Tone = 'green' | 'purple' | undefined; + +export function MiniStat({ label, value, tone }: { label: string; value: string; tone?: Tone }) { + return ( +
+ {label} + {value} +
+ ); +} + +/* Gold / silver / bronze medal palettes for the top-3 rank badges. */ +const MEDALS: Record = { + 1: { light: '#ffe88a', mid: '#f5cf63', dark: '#c9941f', text: '#4a3500', ribbon: '#b8860b' }, + 2: { light: '#ffffff', mid: '#d4dae3', dark: '#9ba2af', text: '#2a2d33', ribbon: '#828a97' }, + 3: { light: '#f6c39c', mid: '#dd9266', dark: '#a05e3a', text: '#3a1e0d', ribbon: '#8a4d2e' }, +}; + +/** A real medal icon (ribbon + metallic disc) wrapping the rank number — gold/ + * silver/bronze for ranks 1–3. Shared by the treemap tiles, the cards, and the + * tile inspector so the top-3 marker reads identically everywhere. The wrapper + * takes a `className` for positioning per surface. */ +export function RankMedal({ rank, className }: { rank: number; className?: string }) { + const c = MEDALS[rank] ?? MEDALS[1]; + // Unique gradient id per instance (the same rank can render on a tile AND a + // card at once, so a fixed id would collide). + const gid = `medal${useId().replace(/[^a-zA-Z0-9]/g, '')}r${rank}`; + return ( + + + + + + + + + + {/* ribbon V (behind the disc) */} + + + {/* metallic disc */} + + + + {rank} + + + + ); +} + +/** Named, color-tinted chips for the reward streams a miner actually earns — + * green PRs, purple issue discovery, orange maintainer cut (with the cut %). + * A miner with no attributable stream gets a single neutral chip. */ +export function StreamBadges({ view }: { view: MinerView }) { + const { pr, issue, maintainer } = streamsOf(view); + if (!pr && !issue && !maintainer) { + return ( +
+ + No active reward stream + +
+ ); + } + return ( +
+ {pr ? ( + + Pull requests + + ) : null} + {issue ? ( + + Issue discovery + + ) : null} + {maintainer ? ( + + {pct(view.maintainerCut)} maintainer cut + + ) : null} +
+ ); +} + +export function TrackButton({ tracked, login, onClick }: { tracked: boolean; login: string; onClick: () => void }) { + return ( + + ); +} + +/** A shimmer block on the shared `.gt-skeleton` animation. */ +function SkelBar({ w, h, r = 4 }: { w: number | string; h: number; r?: number }) { + return ; +} + +/** Loading placeholder shaped like a real MinerCard — same container, header, + * headline + score, 3-up activity row and top-repo rows (reusing the card's own + * CSS classes for spacing) — so the loading state previews the actual layout + * instead of the generic two-bars-and-a-floating-block placeholder. */ +export function MinerCardSkeleton({ opacity = 1 }: { opacity?: number }) { + return ( +
+
+ + +
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+ +
+
+
+ + +
+
+ +
+ {[0, 1].map((i) => ( +
+
+ +
+ +
+ +
+
+ ))} +
+
+ +
+
+ {[11, 17, 9, 21, 14, 19, 12, 23].map((h, i) => ( + + ))} +
+
+ +
+
+
+ +
+ +
+
+ {[0, 1, 2, 3].map((i) => ( +
+ +
+
+ + +
+
+ + +
+
+
+ ))} +
+
+ ); +} + +/** The miner card grid in its loading state — N structured skeletons in the same + * responsive grid as the real cards, fading down to hint more are on the way. */ +export function MinerCardGridSkeleton({ count = 6 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +} + +export function EligibilityPill({ view }: { view: MinerView }) { + const label = eligibilityLabel(view); + const eligible = label !== 'Inactive'; + return ( + + {eligible ? : } + {label} + + ); +} + +export function ActivityPills({ view }: { view: MinerView }) { + return ( +
+ + + {formatCount(view.totalPrs, { fallback: '0' })} + + + + {formatCount(view.totalIssues, { fallback: '0' })} + +
+ ); +} + +/** A small circular progress ring for one credibility value (0..1), its arc + * colored by the reward stream and the rounded percentage in the center. A tick + * marks the 0.80 eligibility threshold; the optional `detail` (merged/closed + * counts) rides along in the tooltip. */ +function CredRing({ + value, + color, + label, + detail, + threshold = 0.8, +}: { + value: number; + color: string; + label: string; + detail?: string; + /** This repo's credibility floor (0..1) — positions the tick and the "to earn" + * text, and reddens the center % when the value falls short. */ + threshold?: number; +}) { + const v = Math.max(0, Math.min(1, value)); + const t = Math.max(0, Math.min(1, threshold)); + const below = v < t; + const r = 13; + const circ = 2 * Math.PI * r; + // Per-repo eligibility marker — measured from north (12 o'clock), t clockwise. + const angle = t * 2 * Math.PI; + const tx = 16 + r * Math.sin(angle); + const ty = 16 - r * Math.cos(angle); + return ( + + + + + + + + {Math.round(v * 100)}% + + + ); +} + +/** Per-repo credibility as circular progress rings — one per cred type the repo + * actually rewards. issueDiscoveryShare splits the repo's emission: 0 = PRs only + * (PR cred), 1 = issue discovery only (issue cred), in between = both. A + * maintained repo pays a cut rather than scored work, so it shows a single + * maintainer badge. Credibility is per-repo — a contributor can be trusted on + * one repo and unproven on another. */ +export function RepoCred({ row, maintainerRepos = [] }: { row: RepoSignal; maintainerRepos?: string[] }) { + const maintained = maintainerRepos.some((repo) => repo.toLowerCase() === row.repo.toLowerCase()); + if (maintained) { + return ( + + + maintainer + + + ); + } + const supportsPr = row.issueDiscoveryShare < 1; + const supportsIssue = row.issueDiscoveryShare > 0; + const prDetail = `${formatCount(row.mergedPrs, { fallback: '0' })} merged · ${formatCount(row.closedPrs, { + fallback: '0', + })} closed${row.openPrs > 0 ? ` · ${formatCount(row.openPrs)} open` : ''}`; + const issueDetail = `${formatCount(row.solvedIssues, { fallback: '0' })} solved`; + return ( + + {supportsPr ? ( + + ) : null} + {supportsIssue ? ( + + ) : null} + + ); +} + +/** Top repositories for a miner — each with its owner avatar and the per-repo + * credibility badges (see RepoCredBadges). */ +/** Repo emission weight as a "% pool" — its share of the OSS emission pool, so a + * miner can see why one repo pays more than another despite similar scores. */ +function poolText(emissionShare: number): string { + const p = emissionShare * 100; + return `${formatNumber(p, { digits: p < 1 ? 2 : 1, fallback: '0' })}% pool`; +} + +export function RepoSignals({ + rows, + maintainerRepos = [], + repoTao, + subnetTao = 0, + limit = 3, +}: { + rows: RepoSignal[]; + maintainerRepos?: string[]; + /** Optional per-repo emission estimate (TAO/day) — shown in place of the raw + * repo score when provided (the card has the pool to compute it). */ + repoTao?: (row: RepoSignal) => number; + /** Per-repo TAO base — used to express each repo's emission weight as a pool. */ + subnetTao?: number; + limit?: number; +}) { + if (rows.length === 0) { + return
No repo-level scoring rows in the miner feed.
; + } + + return ( +
+ {rows.slice(0, limit).map((row) => { + const owner = row.repo.split('/')[0]; + const repoPool = subnetTao * row.emissionShare * 0.9; + return ( +
+ +
+
{row.repo}
+
+ + + {formatCount(row.prs, { fallback: '0' })} + + + + {formatCount(row.issues, { fallback: '0' })} + + {repoTao ? ( + 0 + ? ` — out of this repo's ${formatNumber(repoPool, { digits: 3, fallback: '0' })} τ/d contributor pool` + : '' + }`} + > + {formatNumber(repoTao(row), { digits: 3, fallback: '0' })} τ/d + + ) : ( + score {score(row.prScore + row.issueScore)} + )} + {row.emissionShare > 0 ? ( + 0 ? ` (${formatNumber(repoPool, { digits: 3, fallback: '0' })} τ/d contributor pool)` : '' + }`} + > + {poolText(row.emissionShare)} + + ) : null} +
+
+ +
+ ); + })} +
+ ); +} + +// ─── "Top repos" per-row list (avatar ring = credibility, bar = emission) ────── + +const fmtTao = (n: number) => formatNumber(n, { digits: 3, fallback: '0' }); +const isMaintained = (repo: string, maintainerRepos: string[]) => + maintainerRepos.some((r) => r.toLowerCase() === repo.toLowerCase()); + +/** A repo's dominant reward stream color (PR green / issue purple / maintainer + * orange) — used to tint its per-row emission bar. */ +function repoStreamColor(row: RepoSignal, maintained: boolean): string { + const maint = maintained ? row.maintainerTaoShare : 0; + if (maint > 0 && maint >= row.prTaoShare && maint >= row.issueTaoShare) return MAINTAINER_COLOR; + if (row.issueTaoShare > row.prTaoShare) return ISSUE_COLOR; + return PR_COLOR; +} + +function repoTitle(row: RepoSignal, tao: number): string { + return `${row.repo} · ${fmtTao(tao)} τ/d · ${formatCount(row.prs, { fallback: '0' })} PR · ${formatCount(row.issues, { + fallback: '0', + })} iss · ${poolText(row.emissionShare)}`; +} + +/** Tinted chip style for a credibility badge, colored by its reward stream. */ +function chipStyle(color: string): React.CSSProperties { + return { + color, + background: `color-mix(in srgb, ${color} 22%, var(--bg-canvas))`, + borderColor: `color-mix(in srgb, ${color} 55%, transparent)`, + }; +} + +/** Below-threshold credibility reads as a SOLID danger chip (white on red) rather + * than a tinted one. A pale red tint is hard to tell from the pale issue-discovery + * purple tint on light mode (both desaturate to similar muted darks); a solid fill + * is unmistakable and signals "below this repo's bar" at a glance. */ +const DANGER_CHIP: React.CSSProperties = { + color: '#fff', + background: 'var(--danger-emphasis)', + borderColor: 'var(--danger-emphasis)', +}; + +/** Inline style for one number inside the dual pill: a solid red mini-fill when + * below that stream's threshold, else plain stream-colored text. */ +function dualNumStyle(below: boolean, base: string): React.CSSProperties { + return below ? { color: '#fff', background: 'var(--danger-emphasis)', borderRadius: 4, padding: '0 2px' } : { color: base }; +} + +/** Small circular repo avatar wrapped by a credibility ring per cred the repo + * pays: outer green = PR cred, inner purple = issue-discovery cred — so a + * dual-cred repo shows two clean concentric rings; a maintained repo shows one + * full orange ring. The precise values also sit in the colored corner badges. */ +export function RepoRingAvatar({ + row, + maintained, + size = 34, + only, + showBadges = true, +}: { + row: RepoSignal; + maintained: boolean; + size?: number; + /** Restrict to a single credibility ring/badge (the gating stream) instead of + * showing both — keeps the badge from overflowing a small avatar. */ + only?: 'pr' | 'issue'; + /** Show the numeric cred corner badges. Off for tiny avatars (e.g. the list + * strip) where the ring alone conveys credibility. */ + showBadges?: boolean; +}) { + const owner = row.repo.split('/')[0]; + const ctr = size / 2; + const sw = 2.5; + const rOuter = (size - sw) / 2; + const rInner = rOuter - sw - 1.5; + const supportsPr = !maintained && row.issueDiscoveryShare < 1 && only !== 'issue'; + const supportsIssue = !maintained && row.issueDiscoveryShare > 0 && only !== 'pr'; + const rings: Array<{ r: number; color: string; value: number }> = []; + if (maintained) { + rings.push({ r: rOuter, color: MAINTAINER_COLOR, value: 1 }); + } else { + if (supportsPr) rings.push({ r: rOuter, color: PR_COLOR, value: row.prCred }); + if (supportsIssue) rings.push({ r: supportsPr ? rInner : rOuter, color: ISSUE_COLOR, value: row.issueCred }); + } + const dual = rings.length > 1; + const imgSize = size - (dual ? 14 : 7); + // Each stream is judged against THIS repo's OWN credibility floor (validator + // config, defaulted 0.8 PR / 0.7 issue) — so a repo that lowers the bar (e.g. + // taopedia-articles at 0.5) doesn't false-flag. Below it → solid danger chip. + const belowPr = row.prCred < row.minPrCred; + const belowIssue = row.issueCred < row.minIssueCred; + return ( + + + {rings.map((ring, i) => { + const circ = 2 * Math.PI * ring.r; + const val = Math.max(0, Math.min(1, ring.value)); + return ( + + + + + ); + })} + + + {showBadges ? ( + + {maintained ? ( + + - + + ) : dual ? ( + + {Math.round(row.prCred * 100)} + {Math.round(row.issueCred * 100)} + + ) : ( + <> + {supportsPr ? ( + + {Math.round(row.prCred * 100)} + + ) : null} + {supportsIssue ? ( + + {Math.round(row.issueCred * 100)} + + ) : null} + + )} + + ) : null} + + ); +} + +type RepoLayoutProps = { + rows: RepoSignal[]; + maintainerRepos?: string[]; + repoTao?: (row: RepoSignal) => number; + /** A repo's ACTUAL distributed emission (τ/day, all contributors) — the + * denominator for "repo total / your share". When omitted, falls back to the + * notional pool (emissionShare × subnetTAO × 0.9), which overstates it by the + * recycled portion. */ + repoTotal?: (row: RepoSignal) => number; + /** Per-repo TAO base — to show each repo's TOTAL daily emission alongside the + * miner's share. */ + subnetTao?: number; + limit?: number; +}; + +// Outcome tones for activity states — bright fg colors that read on the card. +const STAT_TONES: Record = { + green: 'var(--success-fg)', + amber: 'var(--attention-fg)', + red: 'var(--danger-fg)', +}; + +type StatItem = { n: number; tone: string; Icon: typeof GitMergeIcon; label: string }; + +/** Per-repo activity, grouped into one badge per type: a PR badge (open / merged + * / closed) and — only on repos that actually pay issue discovery + * (issueDiscoveryShare > 0) — an issue badge (open / closed / completed, where + * completed = solved by a MERGED PR, distinct from plain closed). On PR-only repos + * the "issues" are really issue-*solving* (paid via the PR pool), so no issue badge + * is shown there. Each state is an icon + count, tinted green = merged/completed, + * amber = open, red = closed; only non-zero states show. */ +function RepoBreakdown({ row }: { row: RepoSignal }) { + const c = (n: number) => formatCount(n, { fallback: '0' }); + const pr: StatItem[] = [ + { n: row.openPrs, tone: 'amber', Icon: GitPullRequestIcon, label: 'open PRs' }, + { n: row.mergedPrs, tone: 'green', Icon: GitMergeIcon, label: 'merged PRs' }, + { n: row.closedPrs, tone: 'red', Icon: GitPullRequestClosedIcon, label: 'closed PRs' }, + ].filter((s) => s.n > 0); + const iss: StatItem[] = + row.issueDiscoveryShare > 0 + ? [ + { n: row.openIssues, tone: 'amber', Icon: IssueOpenedIcon, label: 'open issues' }, + { n: row.closedIssues, tone: 'red', Icon: XCircleIcon, label: 'closed issues' }, + { n: row.solvedIssues, tone: 'green', Icon: CheckCircleIcon, label: 'completed issues (solved by a merged PR)' }, + ].filter((s) => s.n > 0) + : []; + if (pr.length === 0 && iss.length === 0) return null; + const badge = (stats: StatItem[], title: string, accent: string) => + stats.length ? ( + + {stats.map((s) => { + const Icon = s.Icon; + return ( + + + {c(s.n)} + + ); + })} + + ) : null; + return ( +
+ {badge(pr, 'Pull requests — open / merged / closed', PR_COLOR)} + {badge(iss, 'Issue discovery — open / closed / completed', ISSUE_COLOR)} +
+ ); +} + +/** Card-header activity readout — a fixed icon+count triplet tinted by outcome + * (green merged/completed, amber open, red closed); zero counts are muted. Same + * visual vocabulary as the per-repo RepoBreakdown badges so the card reads + * consistently. NOTE: counts are cumulative (all-time) — the per-miner feed has + * no 30-day window (see MinerView.prOpen). */ +function ActivityStats({ stats }: { stats: StatItem[] }) { + const c = (n: number) => formatCount(n, { fallback: '0' }); + return ( +
+ {stats.map((s, i) => { + const Icon = s.Icon; + return ( + + {i > 0 ? ( + + / + + ) : null} + + + {c(s.n)} + + + ); + })} +
+ ); +} + +/** PR outcome triplet for the card header — open / merged / closed. */ +export function PrActivityStats({ view }: { view: MinerView }) { + return ( + + ); +} + +/** Issue outcome triplet — open / closed / completed, where completed = solved by + * a MERGED PR (distinct from plain closed). */ +export function IssueActivityStats({ view }: { view: MinerView }) { + return ( + + ); +} + +/* Per-repo contributions sparkline — one bar per active repo (height ∝ PRs+issues, + * stacked issue-over-PR). Shared by the card's Contributions cell and the list. */ +export function ContribSpark({ rows }: { rows: RepoSignal[] }) { + const data = rows + .map((r) => ({ pr: r.prs, issue: r.issues, total: r.prs + r.issues })) + .filter((d) => d.total > 0) + .sort((a, b) => b.total - a.total) + .slice(0, 14); + if (data.length === 0) return
; + const max = Math.max(...data.map((d) => d.total), 1); + return ( +
+ {data.map((d, i) => { + const h = Math.max(10, (d.total / max) * 100); + const issuePct = (d.issue / d.total) * 100; + return ( + + ); + })} +
+ ); +} + +/** Top repos as a per-row list: a small circular avatar ringed by its + * credibility, the repo name + its τ/d, and a per-repo bar whose fill is the + * miner's share of that repo's total emission pool (yourShare ÷ repoTotal — + * directly the ratio of the two τ/d numbers shown), tinted by reward stream. */ +export function RepoEmissionBar({ + rows, + maintainerRepos = [], + repoTao, + repoTotal, + subnetTao = 0, + limit = 4, +}: RepoLayoutProps) { + const items = rows.slice(0, limit).map((row) => ({ + row, + tao: repoTao ? repoTao(row) : 0, + maintained: isMaintained(row.repo, maintainerRepos), + })); + if (items.length === 0) + return
No repo-level scoring rows in the miner feed.
; + return ( +
+ {items.map(({ row, tao, maintained }) => { + // The repo's TOTAL daily contributor emission (actual distribution across + // all contributors), and this miner's share of it. Falls back to the + // notional pool when no aggregate is supplied. + const repoTotalTao = repoTotal ? repoTotal(row) : subnetTao * row.emissionShare * 0.9; + // The row itself is the bar: a stream-tinted fill from the left up to the + // miner's share of THIS repo's pool (yourShare / repoTotal — exactly the + // ratio of the two τ/d numbers shown), on a neutral track. + const frac = repoTotalTao > 0 ? tao / repoTotalTao : 0; + const contribScore = row.prScore + row.issueScore; + const fillPct = (tao > 0 ? Math.max(Math.min(frac, 1), 0.04) * 100 : 0).toFixed(1); + const rowBg = `linear-gradient(to right, color-mix(in srgb, ${repoStreamColor(row, maintained)} 30%, var(--soft-fill)) ${fillPct}%, var(--soft-fill) ${fillPct}%)`; + return ( +
+ +
+
+ {row.repo} + + {repoTotalTao > 0 ? {fmtTao(repoTotalTao)} / : null} + {tao > 0 ? fmtTao(tao) : '—'} τ/d + +
+
+ + {contribScore > 0 || row.collateralScore > 0 ? ( + + score + + {contribScore > 0 ? score(contribScore) : '0'} + {row.collateralScore > 0 ? ( + /+{formatNumber(row.collateralScore, { digits: 1, fallback: '0' })} + ) : null} + + + ) : null} +
+
+
+ ); + })} +
+ ); +} + +/** Compact top-repos list for dense contexts (the list view) — one tight line per + * repo: a small avatar, the name, and the miner's τ/d, on a stream-tinted emission + * fill (yourShare ÷ repoTotal). A leaner alternative to RepoEmissionBar's full + * card rows, so a table row stays short. */ +export function RepoMiniStrip({ + rows, + maintainerRepos = [], + repoTao, + limit = 4, + totalCount, +}: RepoLayoutProps & { totalCount?: number }) { + const items = rows.slice(0, limit); + if (items.length === 0) return ; + const top = items[0]; + const topTao = repoTao ? repoTao(top) : 0; + const more = Math.max(0, (totalCount ?? rows.length) - items.length); + return ( +
+
+ {items.map((row) => { + const maintained = isMaintained(row.repo, maintainerRepos); + const t = repoTao ? repoTao(row) : 0; + return ( + 0 ? fmtTao(t) : '—'} τ/d`}> + row.prTaoShare ? 'issue' : 'pr'} + /> + + ); + })} +
+ + {top.repo} + + {topTao > 0 ? `${fmtTao(topTao)} τ/d` : '—'} + {more > 0 ? ( + + +{more} + + ) : null} +
+ ); +} + +/** Repos the miner is contributing to but not yet earning from — a compact, + * avatar-less list (secondary to the earning "top repos") showing the gate reason + * (≥80% credibility / ≥3 merged PRs or solved issues) and the repo's emission + * weight, so the best "almost earning" opportunities stand out without inflating + * the card. Capped at `limit`, with a "+N more" tail. */ +export function BlockedRepos({ + rows, + total, + subnetTao = 0, + limit = 2, +}: { + rows: RepoSignal[]; + total?: number; + subnetTao?: number; + limit?: number; +}) { + if (rows.length === 0) return null; + const shown = rows.slice(0, limit); + const extra = Math.max(0, (total ?? rows.length) - shown.length); + return ( +
    + {shown.map((row) => { + const gate = blockGate(row); + const isPr = gate.stream === 'pr'; + // Outcome split for the binding stream — the positive outcome (merged PRs + // or solved issues) in the stream's color, plus closed in red, so the bar + // shows BOTH the volume and the credibility ratio that gates eligibility. + const good = isPr ? row.mergedPrs : row.solvedIssues; + const closed = isPr ? row.closedPrs : row.closedIssues; + const goodColor = isPr ? PR_COLOR : ISSUE_COLOR; + const total = good + closed; + // Bar fills toward the count threshold, so 1 of 3 reads as a third full + // (not "done"); once attempts exceed the threshold the same widths become + // the merged-vs-closed credibility ratio. + const denom = Math.max(gate.target, total) || 1; + const repoPool = subnetTao * row.emissionShare * 0.9; + return ( +
  • + +
    +
    + {row.repo} + {row.emissionShare > 0 ? ( + 0 ? `; ${formatNumber(repoPool, { digits: 3, fallback: '0' })} τ/d pool to compete for` : '' + }`} + > + {poolText(row.emissionShare)} + + ) : null} +
    +
    + + {good > 0 ? ( + + ) : null} + {closed > 0 ? ( + + ) : null} + + + + {formatCount(good, { fallback: '0' })} + {' '} + {isPr ? 'merged' : 'solved'} ·{' '} + + {formatCount(closed, { fallback: '0' })} + {' '} + closed + {gate.need ? ` · ${gate.need}` : ''} + +
    +
    +
  • + ); + })} + {extra > 0 ?
  • +{extra} more
  • : null} +
+ ); +} + +export function TrackRow({ label, value, good }: { label: string; value: string; good?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +export function ProgressBar({ label, value, tone }: { label: string; value: number; tone: 'green' | 'purple' }) { + const clamped = Math.max(0, Math.min(value, 1)); + return ( +
+
+ {label} + {Math.round(clamped * 100)}% +
+ + + +
+ ); +} diff --git a/src/app/miners/_lib/miners.ts b/src/app/miners/_lib/miners.ts new file mode 100644 index 0000000..ba12ecc --- /dev/null +++ b/src/app/miners/_lib/miners.ts @@ -0,0 +1,986 @@ +/* Miner derivation layer. + * + * The `/api/miners/activity` feed returns raw `Miner[]` rows (camelCase from + * our DTO, but with snake_case fallbacks mirroring the upstream scorer). This + * module turns each raw miner into a single `MinerView` — the shape every + * surface on the page renders (cards, list rows, drawer, podium, treemap, + * market bar, compare modal, palette). Keeping all the parsing + scoring math + * here means the components stay presentational and the wire-quirks live in + * exactly one place. */ + +import type { Miner, MinerRepoEvaluation } from '@/types/entities'; + +// ─── Public enums ───────────────────────────────────────────────────────────── + +export type SortKey = 'activity' | 'earnings' | 'score' | 'repos' | 'name'; +export type SortDir = 'asc' | 'desc'; +export type ViewMode = 'card' | 'list'; +/** Which headline visualization is showing. The page lets users flip between + * all four so they can pick whichever reads best for their question. */ +export type HeadlineMode = 'podium' | 'treemap' | 'market' | 'metrics'; + +// ─── Wire shape ─────────────────────────────────────────────────────────────── + +export type MinerWire = Miner & { + github_username?: string; + github_id?: string | number; + isMaintainer?: boolean; + maintainerRepos?: string[]; + maintainerCut?: number; + maintainerTaoShare?: number; + maintainerRepoTaoShares?: Record; + failed_reason?: string | null; + total_score?: string | number; + issue_discovery_score?: string | number; + issue_credibility?: string | number; + total_prs?: string | number; + total_merged_prs?: string | number; + total_open_prs?: string | number; + total_closed_prs?: string | number; + total_solved_issues?: string | number; + total_valid_solved_issues?: string | number; + total_open_issues?: string | number; + total_closed_issues?: string | number; + usd_per_day?: string | number; + tao_per_day?: string | number; + alpha_per_day?: string | number; + unique_repos_count?: string | number; + // Scoring internals + code volume — surfaced in the miner modal. camelCase from + // the activity DTO (not all are on the Miner type), snake_case from the raw scorer. + totalCollateralScore?: string | number; + total_collateral_score?: string | number; + totalTokenScore?: string | number; + total_token_score?: string | number; + totalNodesScored?: string | number; + total_nodes_scored?: string | number; + totalStructuralCount?: string | number; + total_structural_count?: string | number; + totalStructuralScore?: string | number; + total_structural_score?: string | number; + totalLeafCount?: string | number; + total_leaf_count?: string | number; + totalLeafScore?: string | number; + total_leaf_score?: string | number; + base_total_score?: string | number; + total_additions?: string | number; + total_deletions?: string | number; +}; + +// ─── Derived shapes ─────────────────────────────────────────────────────────── + +export interface RepoSignal { + repo: string; + prScore: number; + issueScore: number; + /** Issue-solving reward score (tokens for solved issues). Distinct from + * issueScore (issue discovery) and NOT gated by issueDiscoveryShare — e.g. + * matthewevans earns on phase-rs/phase via solving, with share = 0. */ + issueTokenScore: number; + baseScore: number; + collateralScore: number; + prs: number; + mergedPrs: number; + openPrs: number; + closedPrs: number; + issues: number; + solvedIssues: number; + /** Solved issues whose solving PR cleared the token-score validity bar — the subset + * the issue-discovery eligibility gate actually counts. */ + validSolvedIssues: number; + openIssues: number; + closedIssues: number; + prCred: number; + issueCred: number; + /** This repo's OWN eligibility floors (validator config, defaulted when the repo + * uses subnet defaults): min PR credibility (default 0.8), min issue-discovery + * credibility (default 0.7), and the min merged-PR / solved-issue counts + * (default 3 / 3). Per-repo — a repo can lower the bar (e.g. taopedia-articles + * min cred 0.5) or drop it entirely (oc-1 → 0), so badges and the "working + * toward earning" gate read against the repo's own threshold, not a global one. */ + minPrCred: number; + minIssueCred: number; + minMergedPrs: number; + minSolvedIssues: number; + prEligible: boolean; + issueEligible: boolean; + /** Fraction of this repo's emission allocated to issue discovery (0..1). When + * 0, issue work here earns nothing — all emission goes to PRs. */ + issueDiscoveryShare: number; + /** Repo's share of the OSS emission pool (0..1) — how big this repo's reward + * slice is. Surfaced so the card can explain why one repo pays more than + * another despite similar scores. */ + emissionShare: number; + /** This contributor's PR / issue-discovery emission from THIS repo, each as a + * fraction of the subnet TAO (server-computed via the repositories-page model: + * repo pool × the miner's score-share among all eligible contributors). The + * card multiplies by subnetTAO for the per-repo TAO/day. */ + prTaoShare: number; + issueTaoShare: number; + /** Maintainer-cut emission from this repo as a fraction of subnet TAO (0 unless + * the miner maintains it). */ + maintainerTaoShare: number; + taoPerDay: number; + usdPerDay: number; +} + +export interface MinerView { + miner: Miner; + key: string; + login: string; + githubId: string; + uid: number | null; + /** On-chain hotkey (ss58) — for chain explorer links in the detail modal. */ + hotkey: string; + avatarUrl: string; + rows: RepoSignal[]; + topRepos: RepoSignal[]; + /** Count of repos the miner actually earns from (PR pool, issue discovery, or + * maintainer cut) — may exceed the few shown in topRepos, so the card can note + * "+N more". */ + earningRepoCount: number; + /** Repos the miner is contributing to but not yet earning from, most-lucrative + * first (by emission share) — the "almost earning" growth list (capped). */ + blockedRepos: RepoSignal[]; + /** Total count of not-yet-earning repos (may exceed blockedRepos) so the card + * can note "+N more". */ + blockedRepoCount: number; + totalScore: number; + issueScore: number; + usdPerDay: number; + taoPerDay: number; + totalPrs: number; + totalIssues: number; + /** PR outcome breakdown — open / merged / closed. Cumulative (all-time) from + * the scorer feed: the per-miner data exposes only `total*` counts, with no + * 30-day window (only repo-level data carries a 30d window, and it can't be + * attributed per miner). Summed across the miner's repo rows. */ + prOpen: number; + prMerged: number; + prClosed: number; + /** Issue breakdown — open / closed (no merged solving PR) / completed (solved + * by a MERGED PR). Cumulative, same caveat as the PR breakdown. */ + issueOpen: number; + issueClosed: number; + issueCompleted: number; + /** Issues solved by a MERGED PR that also cleared the validity bar (token-score + * threshold) — the subset of completed issues that actually count toward issue + * eligibility. */ + validSolvedIssues: number; + uniqueRepos: number; + /** Scoring internals (gittensor scores the AST nodes of merged PRs): the raw + * base score, collateral (pending score from open PRs), the token score, and the + * structural / leaf node counts + scores. Surfaced in the modal's scoring panel. */ + baseScore: number; + collateralScore: number; + tokenScore: number; + nodesScored: number; + structuralCount: number; + structuralScore: number; + leafCount: number; + leafScore: number; + /** Cumulative lines added / removed across the miner's merged work. */ + additions: number; + deletions: number; + prCred: number; + issueCred: number; + prEligible: boolean; + issueEligible: boolean; + /** Whether the miner actually EARNS from each stream — eligibility AND'd with + * the repo's emission share. A miner only earns from issue discovery on repos + * whose issueDiscoveryShare > 0, and from PRs on repos whose share < 1. So a + * miner issue-eligible only on share-0 repos (e.g. bitloi) is PR-only. */ + prEarning: boolean; + issueEarning: boolean; + /** Whether the miner is a maintainer of any tracked repo — they earn the + * repo's maintainer-cut, a reward stream distinct from PRs / issue discovery + * (e.g. jjmata, who maintains we-promise/sure but has no scored PRs). */ + isMaintainer: boolean; + maintainerRepos: string[]; + /** Maintainer-cut fraction (0..1) of the repo they maintain — e.g. 0.3 for + * jjmata on we-promise/sure. Max across their maintained repos. */ + maintainerCut: number; + /** Maintainer-cut emission as a fraction of the subnet TAO — the card turns + * this into TAO (× subnetTAO) to size the maintainer segment of the split bar. */ + maintainerTaoShare: number; + /** This miner's total PR / issue-discovery emission, each as a fraction of the + * subnet TAO (summed over their repos from the repositories-page model). The + * card multiplies by subnetTAO to size the PR / issue split-bar segments. */ + prTaoShare: number; + issueTaoShare: number; + failedReason: string | null; + activity: number; +} + +// ─── Option tables (shared by toolbar + headline switcher) ───────────────────── + +export const SORT_OPTIONS: Array<{ key: SortKey; label: string }> = [ + { key: 'activity', label: 'Activity' }, + { key: 'earnings', label: 'Earnings' }, + { key: 'score', label: 'Score' }, + { key: 'repos', label: 'Repo count' }, + { key: 'name', label: 'Name' }, +]; + +export const HEADLINE_OPTIONS: Array<{ key: HeadlineMode; label: string; caption: string }> = [ + { key: 'podium', label: 'Podium', caption: 'top earners ranked' }, + { key: 'treemap', label: 'Treemap', caption: 'sized by output' }, + { key: 'market', label: 'Market', caption: 'emission + spread' }, + { key: 'metrics', label: 'Metrics', caption: 'headline numbers' }, +]; + +export const EMPTY_MINERS: Miner[] = []; +const EMPTY_REPO_SIGNALS: RepoSignal[] = []; + +// ─── Number coercion ────────────────────────────────────────────────────────── + +export function num(value: unknown): number { + const n = typeof value === 'string' ? Number.parseFloat(value) : typeof value === 'number' ? value : 0; + return Number.isFinite(n) ? n : 0; +} + +/** Coerce a 0..1 ratio. Upstream sometimes sends 0..100 percentages instead, + * so anything > 1 is treated as a percentage and scaled down. */ +export function ratio(value: unknown): number { + const n = num(value); + if (n <= 0) return 0; + return n > 1 ? Math.min(n / 100, 1) : Math.min(n, 1); +} + +export function pct(value: unknown): string { + return `${Math.round(ratio(value) * 100)}%`; +} + +export function score(value: unknown, digits = 1): string { + const n = num(value); + if (!n) return '-'; + return n >= 1000 ? n.toFixed(0) : n.toFixed(digits); +} + +/** "% of pool" share text — 2 decimals under 1%, else 1 — shared by the treemap + * inspector and the miner cards so the figure reads identically on both. */ +export function shareText(value: number, total: number): string { + if (total <= 0) return '0%'; + const p = (value / total) * 100; + return `${p.toFixed(p < 1 ? 2 : 1)}%`; +} + +function normalizedRepoName(value: unknown): string | null { + if (typeof value !== 'string') return null; + const repo = value.trim(); + return repo.includes('/') ? repo : null; +} + +// ─── Wire accessors ─────────────────────────────────────────────────────────── + +function minerWire(miner: Miner): MinerWire { + return miner as MinerWire; +} + +export function minerLogin(miner: Miner): string { + const wire = minerWire(miner); + return ( + miner.githubUsername || + wire.github_username || + miner.githubId || + String(wire.github_id ?? '') || + `uid-${miner.uid ?? 'unknown'}` + ); +} + +export function minerGithubId(miner: Miner): string { + const wire = minerWire(miner); + return String(miner.githubId ?? wire.github_id ?? ''); +} + +export function minerTrackKey(view: MinerView): string { + return view.githubId || view.login || String(view.uid ?? ''); +} + +// ─── Repo signal derivation ─────────────────────────────────────────────────── + +function repoSignalBase(row: MinerRepoEvaluation, fallbackRepo: string | null): RepoSignal | null { + const repo = normalizedRepoName(row.repositoryFullName ?? row.repository_full_name ?? fallbackRepo); + if (!repo) return null; + const mergedPrs = num(row.totalMergedPrs ?? row.total_merged_prs); + const openPrs = num(row.totalOpenPrs ?? row.total_open_prs); + const closedPrs = num(row.totalClosedPrs ?? row.total_closed_prs); + const prs = num(row.totalPrs ?? row.total_prs) || mergedPrs + openPrs + closedPrs; + const solvedIssues = num(row.totalSolvedIssues ?? row.total_solved_issues); + const openIssues = num(row.totalOpenIssues ?? row.total_open_issues); + const closedIssues = num(row.totalClosedIssues ?? row.total_closed_issues); + const validSolvedIssues = num(row.totalValidSolvedIssues ?? row.total_valid_solved_issues); + const issues = Math.max(solvedIssues + openIssues, closedIssues + openIssues, validSolvedIssues); + // Per-repo eligibility floors stamped by the activity API. null/absent → the + // subnet default; a present value (incl. a configured 0 = "no gate") is honored. + const elig = row as { minPrCred?: unknown; minIssueCred?: unknown; minMergedPrs?: unknown; minSolvedIssues?: unknown }; + const pickRatio = (v: unknown, dflt: number) => (v == null ? dflt : ratio(v)); + const pickCount = (v: unknown, dflt: number) => (v == null ? dflt : num(v)); + return { + repo, + prScore: num(row.totalScore ?? row.total_score), + issueScore: num(row.issueDiscoveryScore ?? row.issue_discovery_score), + issueTokenScore: num(row.issueTokenScore ?? row.issue_token_score), + baseScore: num(row.baseTotalScore ?? row.base_total_score), + collateralScore: num(row.totalCollateralScore ?? row.total_collateral_score), + prs, + mergedPrs, + openPrs, + closedPrs, + issues, + solvedIssues, + validSolvedIssues, + openIssues, + closedIssues, + prCred: ratio(row.credibility), + issueCred: ratio(row.issueCredibility ?? row.issue_credibility), + minPrCred: pickRatio(elig.minPrCred, MIN_ELIGIBLE_CREDIBILITY), + minIssueCred: pickRatio(elig.minIssueCred, MIN_ELIGIBLE_ISSUE_CREDIBILITY), + minMergedPrs: pickCount(elig.minMergedPrs, MIN_MERGED_PRS), + minSolvedIssues: pickCount(elig.minSolvedIssues, MIN_SOLVED_ISSUES), + prEligible: (row.isEligible ?? row.is_eligible) === true, + issueEligible: (row.isIssueEligible ?? row.is_issue_eligible) === true, + issueDiscoveryShare: num((row as { issueDiscoveryShare?: unknown }).issueDiscoveryShare), + emissionShare: num((row as { emissionShare?: unknown }).emissionShare), + prTaoShare: num((row as { prTaoShare?: unknown }).prTaoShare), + issueTaoShare: num((row as { issueTaoShare?: unknown }).issueTaoShare), + maintainerTaoShare: 0, // stamped per-repo in minerView from the maintainer map + taoPerDay: num(row.taoPerDay ?? row.tao_per_day), + usdPerDay: num(row.usdPerDay ?? row.usd_per_day), + }; +} + +function repoStrength(row: RepoSignal): number { + return ( + row.prScore * 1.2 + + row.issueScore * 1.35 + + row.baseScore * 0.25 + + row.collateralScore * 0.18 + + row.prs * 2 + + row.issues * 2.4 + + row.taoPerDay * 120 + ); +} + +function hasRepoSignal(row: RepoSignal): boolean { + return ( + row.prEligible || + row.issueEligible || + row.prScore > 0 || + row.issueScore > 0 || + row.issueTokenScore > 0 || + row.baseScore > 0 || + row.collateralScore > 0 || + row.prs > 0 || + row.issues > 0 + // usdPerDay is deliberately NOT a signal: the upstream attaches a miner's + // network-wide $/day onto EVERY repo row they're listed in, so counting it + // would inflate "repos" to all tracked repos. A repo counts only when there + // is real per-repo activity (eligibility, score, PRs, or issues). + ); +} + +function repoSignalsForMiner(miner: Miner): RepoSignal[] { + const raw = miner.repoEvaluations ?? miner.repo_evaluations; + if (!raw) return EMPTY_REPO_SIGNALS; + + const entries: Array<[string | null, MinerRepoEvaluation]> = Array.isArray(raw) + ? raw.map((row) => [null, row]) + : Object.entries(raw); + return entries + .map(([fallbackRepo, row]) => repoSignalBase(row, fallbackRepo)) + .filter((row): row is RepoSignal => Boolean(row)) + .filter(hasRepoSignal) + .sort((a, b) => repoStrength(b) - repoStrength(a) || a.repo.localeCompare(b.repo)); +} + +// ─── Miner view ─────────────────────────────────────────────────────────────── + +/** A zero-activity RepoSignal for a repo the miner maintains but hasn't + * contributed to — lets it still appear in "top repos" (they earn its cut). */ +function maintainerOnlyRepo(repo: string): RepoSignal { + return { + repo, + prScore: 0, + issueScore: 0, + issueTokenScore: 0, + baseScore: 0, + collateralScore: 0, + prs: 0, + mergedPrs: 0, + openPrs: 0, + closedPrs: 0, + issues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + openIssues: 0, + closedIssues: 0, + prCred: 0, + issueCred: 0, + minPrCred: MIN_ELIGIBLE_CREDIBILITY, + minIssueCred: MIN_ELIGIBLE_ISSUE_CREDIBILITY, + minMergedPrs: MIN_MERGED_PRS, + minSolvedIssues: MIN_SOLVED_ISSUES, + prEligible: false, + issueEligible: false, + issueDiscoveryShare: 0, + emissionShare: 0, + prTaoShare: 0, + issueTaoShare: 0, + maintainerTaoShare: 0, // stamped in minerView from the maintainer map + taoPerDay: 0, + usdPerDay: 0, + }; +} + +// ─── Reward-stream predicates ───────────────────────────────────────────────── +// The two contributor reward streams, gated on per-repo eligibility (a score +// with no eligibility pays $0). Shared by minerView AND the treemap/chip colors +// so the classification can never drift between them. + +/** PR / contributor pool — merged PRs or issue *solving* (issueTokenScore), paid + * where the miner is PR-eligible and PRs pay (issueDiscoveryShare < 1). */ +export function repoEarnsPr(row: RepoSignal): boolean { + return row.prEligible && (row.prScore > 0 || row.issueTokenScore > 0) && row.issueDiscoveryShare < 1; +} + +/** Issue discovery — a scored discovery on a repo whose issue-discovery share + * actually pays (> 0), with the miner issue-eligible. */ +export function repoEarnsIssueDiscovery(row: RepoSignal): boolean { + return row.issueEligible && row.issueScore > 0 && row.issueDiscoveryShare > 0; +} + +/** Sum of a repo's contributor stream shares (PR + issue-discovery + maintainer) + * as a fraction of subnet TAO — repoTaoOf without the subnetTAO multiplier. Lets + * callers order repos by the miner's per-repo emission without depending on the + * live subnet TAO (a constant multiplier) having loaded yet. */ +export function repoStreamShare(row: RepoSignal): number { + return row.prTaoShare + row.issueTaoShare + row.maintainerTaoShare; +} + +/** Per-repo TAO/day a miner earns from one repo — subnetTAO × the sum of the + * server-stamped PR, issue-discovery, and maintainer-cut shares. Matches the + * repositories page (e.g. MkDev11 ≈ 0.039 on gittensory). */ +export function repoTaoOf(row: RepoSignal, subnetTao: number): number { + return subnetTao * repoStreamShare(row); +} + +// SN74 eligibility thresholds — the validator's DEFAULT floors. Each repo can +// override any of these via config.eligibility (e.g. taopedia-articles drops min +// cred to 0.5, oc-1 to 0); the activity API stamps the per-repo override onto each +// row, and repoSignalBase resolves override-or-default into the RepoSignal's +// min* fields. Use those per-row fields for gating; these are only the fallback. +// credibility = merged ÷ (merged + closed). +const MIN_MERGED_PRS = 3; +const MIN_SOLVED_ISSUES = 3; +/** Default min PR credibility (PR rewards). */ +export const MIN_ELIGIBLE_CREDIBILITY = 0.8; +/** Default min issue-discovery credibility — matches gittensor MIN_ISSUE_CREDIBILITY + * (0.80, same as PRs). Only a fallback; the feed's per-repo min_issue_credibility wins. */ +const MIN_ELIGIBLE_ISSUE_CREDIBILITY = 0.8; + +/** When each repo was registered on gittensor — i.e. added to the validator's + * master_repositories.json. A repo's GitHub history long predates this (repos exist + * for years before joining SN74), so contributions made BEFORE registration are not + * SN74 work and must not inflate a miner's "working age". Dates are the config commit + * that first introduced the repo key (a faithful proxy for on-chain registration). + * Keep in sync when repos are added to master_repositories.json. */ +export const REPO_REGISTERED_AT: Record = { + 'infiniflow/ragflow': '2025-10-29', + 'entrius/gittensor': '2025-11-04', + 'we-promise/sure': '2026-01-14', + 'entrius/gittensor-ui': '2026-02-27', + 'entrius/allways': '2026-03-25', + 'entrius/das-github-mirror': '2026-05-01', + 'entrius/oc-1': '2026-05-12', + 'geniepod/genie-claw': '2026-05-15', + 'mkdev11/gittensor-hub': '2026-05-15', + 'seroperson/jvm-live-reload': '2026-05-15', + 'jsonbored/awesome-claude': '2026-05-19', + 'touchpilot/touchpilot': '2026-05-19', + 'vouchdev/vouch': '2026-05-22', + 'jsonbored/gittensory': '2026-05-28', + 'phase-rs/phase': '2026-05-28', + 'cogniax/tao-pulse-app': '2026-05-29', + 'e35ventura/taopedia': '2026-05-29', + 'e35ventura/taopedia-articles': '2026-05-29', +}; + +const REPO_REGISTERED_MS: Record = Object.fromEntries( + Object.entries(REPO_REGISTERED_AT).map(([k, v]) => [k, Date.parse(`${v}T00:00:00Z`)]), +); +/** Newest known registration — the fallback for a repo absent from the map (i.e. one + * registered after this map was last updated, hence at least this recent). Using the + * latest, not the earliest, keeps an un-mapped repo from over-stating working age. */ +const LATEST_REPO_REGISTERED_MS = Math.max(...Object.values(REPO_REGISTERED_MS)); + +/** Epoch ms at which `repo` (owner/name) became an SN74 repo. Falls back to the newest + * known registration for repos not yet in the map. */ +export function repoRegisteredMs(repo: string): number { + return REPO_REGISTERED_MS[repo.toLowerCase()] ?? LATEST_REPO_REGISTERED_MS; +} + +/** A repo the miner is actively contributing to but NOT yet earning from — work + * is happening (merged/open PRs, a PR score, or solved/scored issues) but the + * repo hasn't cleared eligibility (and it isn't a maintained / earning repo). + * These are the "almost earning" growth opportunities the card surfaces. */ +export function isBlockedContribution(row: RepoSignal, maintained: boolean): boolean { + if (maintained || repoEarnsPr(row) || repoEarnsIssueDiscovery(row)) return false; + const prWork = row.issueDiscoveryShare < 1 && (row.mergedPrs > 0 || row.openPrs > 0 || row.prScore > 0); + const issueWork = row.issueDiscoveryShare > 0 && (row.issueScore > 0 || row.solvedIssues > 0); + return prWork || issueWork; +} + +/** A miner's progress toward clearing the eligibility gate on a contributing-but- + * not-yet-earning repo: the binding gate as a short human reason PLUS how far + * along they are (0..1), so the "working toward earning" UI can show a progress + * bar, not just a static label. Derived from the same gates the validator applies + * (≥3 merged PRs / solved issues and ≥80% credibility). */ +export interface BlockGate { + /** Short human reason, e.g. "1/3 merged PRs" or "72% cred · need 80%". */ + text: string; + /** Short COUNT requirement to show beside the outcome counts, e.g. "need 3". + * '' for credibility gates — the avatar's credibility ring conveys those — and + * when nothing applies. */ + need: string; + /** Fraction of the way to clearing this specific gate (0..1). */ + progress: number; + /** Which reward stream the binding gate is on — drives the bar's color and + * which outcome counts (PR merged/closed vs issue solved/closed) it shows. */ + stream: 'pr' | 'issue'; + /** Count threshold for the stream (merged PRs / solved issues). The progress bar + * fills toward max(target, good+closed), so 1 of 3 reads as a third full rather + * than complete, while higher volumes show the merged-vs-closed ratio. */ + target: number; +} + +export function blockGate(row: RepoSignal): BlockGate { + const frac = (have: number, need: number) => Math.max(0, Math.min(1, need > 0 ? have / need : 0)); + // Gate against THIS repo's own floors (validator config, defaulted) — not a + // global 80%/3 — so the reason matches what the repo actually requires. + const minMerged = row.minMergedPrs; + const minSolved = row.minSolvedIssues; + const prWork = row.issueDiscoveryShare < 1 && (row.mergedPrs > 0 || row.openPrs > 0 || row.prScore > 0); + if (prWork) { + if (row.mergedPrs < minMerged) + return { text: `${row.mergedPrs}/${minMerged} merged PRs`, need: `need ${minMerged}`, progress: frac(row.mergedPrs, minMerged), stream: 'pr', target: minMerged }; + if (row.prCred < row.minPrCred) + return { text: `${Math.round(row.prCred * 100)}% cred · need ${Math.round(row.minPrCred * 100)}%`, need: '', progress: frac(row.prCred, row.minPrCred), stream: 'pr', target: minMerged }; + } + if (row.issueDiscoveryShare > 0 && (row.issueScore > 0 || row.solvedIssues > 0)) { + if (row.solvedIssues < minSolved) + return { text: `${row.solvedIssues}/${minSolved} solved issues`, need: `need ${minSolved}`, progress: frac(row.solvedIssues, minSolved), stream: 'issue', target: minSolved }; + if (row.issueCred < row.minIssueCred) + return { text: `${Math.round(row.issueCred * 100)}% issue cred · need ${Math.round(row.minIssueCred * 100)}%`, need: '', progress: frac(row.issueCred, row.minIssueCred), stream: 'issue', target: minSolved }; + } + return { text: 'not yet eligible', need: '', progress: 0, stream: prWork ? 'pr' : 'issue', target: prWork ? minMerged : minSolved }; +} + +/** Why a contributing repo isn't earning yet, as a short human label (see blockGate). */ +export function blockReason(row: RepoSignal): string { + return blockGate(row).text; +} + +export function minerView( + miner: Miner, + subnetTao = 0, + usdPerTao = 0, + /** This miner's ACTUAL on-chain daily TAO (alpha_per_day × price for its uid, + * from the emission feed) — the exact TaoMarketCap figure. When provided it's + * the authoritative headline emission, and the score-share model is rescaled to + * it so the per-repo / split-bar breakdown reconciles. Omitted (or undefined) + * for a uid the feed doesn't cover → fall back to the model. */ + actualTaoPerDay?: number | null, +): MinerView { + const wire = minerWire(miner); + const rows = repoSignalsForMiner(miner); + const rowPrs = rows.reduce((sum, row) => sum + row.prs, 0); + const rowIssues = rows.reduce((sum, row) => sum + row.issues, 0); + const rowUsd = rows.reduce((sum, row) => sum + row.usdPerDay, 0); + const rowTao = rows.reduce((sum, row) => sum + row.taoPerDay, 0); + const totalPrs = + rowPrs || + num(miner.totalPrs ?? wire.total_prs) || + num(miner.totalMergedPrs ?? wire.total_merged_prs) + + num(miner.totalOpenPrs ?? wire.total_open_prs) + + num(miner.totalClosedPrs ?? wire.total_closed_prs); + const totalIssues = + rowIssues || + num(miner.totalSolvedIssues ?? wire.total_solved_issues) + num(miner.totalOpenIssues ?? wire.total_open_issues); + // PR / issue outcome breakdowns. Per-repo counts are genuine per-repo figures + // (see the totalPrs comment below), so sum them when rows exist; fall back to + // the miner-level cumulative totals only for miners discovered solely via repo + // rows that carry no per-repo counts. + const sumRows = (pick: (r: RepoSignal) => number) => rows.reduce((acc, r) => acc + pick(r), 0); + const hasRows = rows.length > 0; + const prMerged = hasRows ? sumRows((r) => r.mergedPrs) : num(miner.totalMergedPrs ?? wire.total_merged_prs); + const prOpen = hasRows ? sumRows((r) => r.openPrs) : num(miner.totalOpenPrs ?? wire.total_open_prs); + const prClosed = hasRows ? sumRows((r) => r.closedPrs) : num(miner.totalClosedPrs ?? wire.total_closed_prs); + const issueOpen = hasRows ? sumRows((r) => r.openIssues) : num(miner.totalOpenIssues ?? wire.total_open_issues); + const issueClosed = hasRows ? sumRows((r) => r.closedIssues) : num(miner.totalClosedIssues ?? wire.total_closed_issues); + const issueCompleted = hasRows ? sumRows((r) => r.solvedIssues) : num(miner.totalSolvedIssues ?? wire.total_solved_issues); + const login = minerLogin(miner); + const githubId = minerGithubId(miner); + const totalScore = num(miner.totalScore ?? wire.total_score); + const issueScore = num(miner.issueDiscoveryScore ?? wire.issue_discovery_score); + // Earnings are a MINER-level property — the upstream attaches each miner's + // network-wide usd/tao-per-day onto every per-repo row, so summing the rows + // (rowUsd/rowTao) multiplies a miner's emission by their repo count. Use the + // top-level network value; fall back to the row sum only when it's absent + // (e.g. miners discovered solely via repo rows). PR/issue counts ARE genuine + // per-repo figures, so those keep summing above. + const feedUsd = num(miner.usdPerDay ?? wire.usd_per_day) || rowUsd; + // Upstream's network-wide TAO/day — kept only as a fallback for the accurate + // model below (used until the live subnet TAO has loaded). + const feedTaoPerDay = num(miner.taoPerDay ?? wire.tao_per_day) || rowTao; + const uniqueRepos = rows.length || num(miner.uniqueReposCount ?? wire.unique_repos_count); + // Scoring internals + code volume (miner-level, all-time) — for the modal's + // scoring panel. baseScore/additions/deletions/validSolvedIssues are on the Miner + // type; the rest come via the wire (camelCase from the DTO, snake_case fallback). + const hotkey = typeof miner.hotkey === 'string' ? miner.hotkey : ''; + const baseScore = num(miner.baseTotalScore ?? wire.base_total_score); + const collateralScore = num(wire.totalCollateralScore ?? wire.total_collateral_score); + const tokenScore = num(wire.totalTokenScore ?? wire.total_token_score); + const nodesScored = num(wire.totalNodesScored ?? wire.total_nodes_scored); + const structuralCount = num(wire.totalStructuralCount ?? wire.total_structural_count); + const structuralScore = num(wire.totalStructuralScore ?? wire.total_structural_score); + const leafCount = num(wire.totalLeafCount ?? wire.total_leaf_count); + const leafScore = num(wire.totalLeafScore ?? wire.total_leaf_score); + const additions = num(miner.totalAdditions ?? wire.total_additions); + const deletions = num(miner.totalDeletions ?? wire.total_deletions); + const validSolvedIssues = num(miner.totalValidSolvedIssues ?? wire.total_valid_solved_issues); + const prCred = ratio(miner.credibility); + const issueCred = ratio(miner.issueCredibility ?? wire.issue_credibility); + const prEligible = rows.some((row) => row.prEligible); + const issueEligible = rows.some((row) => row.issueEligible); + // Issue-discovery earning needs an actual discovery SCORE on a repo that pays + // for it — not mere eligibility. A miner can be issue-eligible on a paying repo + // (issueDiscoveryShare > 0) yet score zero there (earns nothing), while their + // nonzero discovery score sits on a share-0 repo that pays it $0 — hence the + // score AND share gate on the SAME row. + // Issue DISCOVERY only — finding/reporting issues (issueScore, gated by + // issueDiscoveryShare > 0). Solving issues is NOT discovery (see prEarning). + const issueEarning = rows.some(repoEarnsIssueDiscovery); + // PR / contributor pool — merged PRs OR issue *solving* (issueTokenScore). Both + // pay from the repo's contributor pool, GATED BY PR eligibility (isEligible) and + // only where PRs pay (share < 1). A pure issue-solver WITH prEligible (e.g. + // pandadev66 on infiniflow/ragflow) reads as a PR contributor; an INELIGIBLE one + // (ai-hpc — a 206 issue-token score on geniepod/genie-claw that pays $0) does + // NOT, leaving it to its maintainer-cut stream alone. + const prEarning = rows.some(repoEarnsPr); + // Maintainer-cut stream — flagged server-side from the repo maintainer rosters. + const isMaintainer = wire.isMaintainer === true; + const maintainerRepos = Array.isArray(wire.maintainerRepos) ? wire.maintainerRepos : []; + const maintainerCut = num(wire.maintainerCut); + const maintainerTaoShare = num(wire.maintainerTaoShare); + const maintainerRepoShares = wire.maintainerRepoTaoShares ?? {}; + const maintainerShareFor = (repo: string): number => { + const target = repo.toLowerCase(); + for (const [key, value] of Object.entries(maintainerRepoShares)) { + if (key.toLowerCase() === target) return num(value); + } + return 0; + }; + // Per-stream emission shares (fractions of subnet TAO) — summed from the + // server-stamped per-repo shares. Multiplying by subnetTAO sizes the PR / issue + // split-bar segments and the headline emission total. + const prTaoShare = rows.reduce((sum, row) => sum + row.prTaoShare, 0); + const issueTaoShare = rows.reduce((sum, row) => sum + row.issueTaoShare, 0); + // Daily TAO. The score-share MODEL (subnetTAO × summed PR + issue + maintainer + // shares) distributes each repo's full contributor pool by score — it ignores + // the slice that recycles unclaimed, so it runs a few % high. The ACTUAL on-chain + // per-UID emission (alpha_per_day × price, passed in from the feed) is exactly + // what TaoMarketCap shows, so it's the authoritative headline when available. We + // keep the model for the per-repo / per-stream BREAKDOWN but rescale it onto the + // actual total (taoScale) so the split bars and per-repo τ/day reconcile with the + // headline. Falls back to the model, then the upstream feed, while the emission + // (or this uid) is unavailable. + const modelTaoPerDay = subnetTao > 0 ? subnetTao * (prTaoShare + issueTaoShare + maintainerTaoShare) : feedTaoPerDay; + const hasActual = actualTaoPerDay != null && Number.isFinite(actualTaoPerDay) && actualTaoPerDay >= 0; + const taoPerDay = hasActual ? (actualTaoPerDay as number) : modelTaoPerDay; + // Per-miner factor mapping the model breakdown onto the actual total (1 = no-op). + const taoScale = hasActual && modelTaoPerDay > 0 ? (actualTaoPerDay as number) / modelTaoPerDay : 1; + // USD/day derived from the accurate TAO at the live TAO→USD rate, so $/day stays + // consistent with the emission everywhere it's shown or sorted (the upstream's own + // usd/day is the unreliable phantom value). + const usdPerDay = usdPerTao > 0 ? taoPerDay * usdPerTao : feedUsd; + // "Top repos" lists only repos the miner actually earns incentives from: a + // PR-pool contribution — merged PRs or issue solving (token score) — where the + // miner is PR-eligible and PRs pay (share < 1); a scored issue discovery where + // issue-eligible and it pays (share > 0); or a repo they maintain (maintainer- + // cut). A score without eligibility pays $0, so it's excluded — e.g. ai-hpc's + // 206 issue-token score on geniepod/genie-claw (ineligible) is dropped, leaving + // only the repo he maintains. + const maintainerRepoSet = new Set(maintainerRepos.map((repo) => repo.toLowerCase())); + const earningRepos = rows.filter( + (row) => maintainerRepoSet.has(row.repo.toLowerCase()) || repoEarnsPr(row) || repoEarnsIssueDiscovery(row), + ); + const isMaintained = (row: RepoSignal) => maintainerRepoSet.has(row.repo.toLowerCase()); + const shownRepos = new Set(earningRepos.map((row) => row.repo.toLowerCase())); + const maintainedOnly = maintainerRepos + .filter((repo) => !shownRepos.has(repo.toLowerCase())) + .map(maintainerOnlyRepo); + // Stamp each maintained repo with its own maintainer-cut share up front, so the + // card can show per-repo maintainer emission (e.g. MkDev11's gittensor-hub cut, + // which has no contributor score of its own) AND the τ/day ordering below counts + // the cut, not just the PR/issue shares. + for (const row of [...earningRepos.filter(isMaintained), ...maintainedOnly]) { + row.maintainerTaoShare = maintainerShareFor(row.repo); + } + // Rescale the model breakdown onto the actual headline (no-op when taoScale === 1) + // so every per-repo τ/day and split-bar segment sums to the authoritative total. + // Mutates the derived RepoSignals in place — earningRepos are references into + // `rows`; maintainedOnly are their own objects, so scale both. + if (taoScale !== 1) { + for (const row of rows) { + row.prTaoShare *= taoScale; + row.issueTaoShare *= taoScale; + row.maintainerTaoShare *= taoScale; + } + for (const row of maintainedOnly) { + row.prTaoShare *= taoScale; + row.issueTaoShare *= taoScale; + row.maintainerTaoShare *= taoScale; + } + } + const prTaoShareScaled = prTaoShare * taoScale; + const issueTaoShareScaled = issueTaoShare * taoScale; + const maintainerTaoShareScaled = maintainerTaoShare * taoScale; + // Order by the miner's per-repo emission (τ/day), highest first — the headline + // number on each row. repoStreamShare is τ/day without the constant subnetTAO + // multiplier, so the order is stable whether or not live subnet TAO has loaded. + const byEmission = (a: RepoSignal, b: RepoSignal) => + repoStreamShare(b) - repoStreamShare(a) || a.repo.localeCompare(b.repo); + // Maintainer-cut repos are a headline reward stream (the card badges the cut), + // so keep them pinned to the front — otherwise a maintained repo the miner + // barely contributes to (e.g. MkDev11/gittensor-hub) can fall outside the shown + // few, leaving the "maintainer cut" badge with no matching repo. Pure-cut repos + // with no activity row follow, then the rest — each group ordered by τ/day. + const topRepos = [ + ...earningRepos.filter(isMaintained).sort(byEmission), + ...maintainedOnly.sort(byEmission), + ...earningRepos.filter((row) => !isMaintained(row)).sort(byEmission), + ].slice(0, 4); + // Full earning count (may exceed the few shown) so the card can note "+N more". + const earningRepoCount = earningRepos.length + maintainedOnly.length; + // Active contributions not yet earning — the "almost earning" growth list, + // most-lucrative repos first so the best opportunities surface. + const blockedAll = rows + .filter((row) => isBlockedContribution(row, isMaintained(row))) + .sort((a, b) => b.emissionShare - a.emissionShare || repoStrength(b) - repoStrength(a)); + const blockedRepoCount = blockedAll.length; + const blockedRepos = blockedAll.slice(0, 3); + const activity = + totalScore * 1.1 + issueScore * 1.25 + usdPerDay * 100 + totalPrs * 3 + totalIssues * 2 + rows.length * 3; + + return { + miner, + key: githubId || login || String(miner.uid ?? ''), + login, + githubId, + uid: Number.isFinite(num(miner.uid)) ? num(miner.uid) : null, + hotkey, + avatarUrl: `https://github.com/${encodeURIComponent(login)}.png?size=96`, + rows, + topRepos, + earningRepoCount, + blockedRepos, + blockedRepoCount, + totalScore, + issueScore, + usdPerDay, + taoPerDay, + totalPrs, + totalIssues, + prOpen, + prMerged, + prClosed, + issueOpen, + issueClosed, + issueCompleted, + validSolvedIssues, + uniqueRepos, + baseScore, + collateralScore, + tokenScore, + nodesScored, + structuralCount, + structuralScore, + leafCount, + leafScore, + additions, + deletions, + prCred, + issueCred, + prEligible, + issueEligible, + prEarning, + issueEarning, + isMaintainer, + maintainerRepos, + maintainerCut, + maintainerTaoShare: maintainerTaoShareScaled, + prTaoShare: prTaoShareScaled, + issueTaoShare: issueTaoShareScaled, + failedReason: miner.failedReason ?? wire.failed_reason ?? null, + activity, + }; +} + +/** PR / Issue / Dual / Inactive label, by what the miner actually EARNS from + * (eligibility AND'd with each repo's emission share) — so a miner eligible for + * issue discovery only on share-0 repos reads as PR, not Dual. */ +export function eligibilityLabel(view: MinerView): string { + if (view.prEarning && view.issueEarning) return 'Dual'; + if (view.issueEarning) return 'Issue'; + if (view.prEarning) return 'PR'; + return 'Inactive'; +} + +/** A note for the one genuinely confusing card (matthewevans): a standout pile of + * registered issues that earns nothing. Issue discovery only pays when a repo + * allocates part of its emission to it (issue_discovery_share > 0) AND the miner + * clears the bar (3+ valid solved issues at 80%+ issue credibility) — so a big + * issue count on PR-only repos pays $0. Deliberately rare: only a standout issue + * count trips it, so the many modest not-yet-eligible contributors stay un-noted. */ +export function incentiveNote(view: MinerView): string | null { + // Standout issue pile AND contributions earn nothing (PR-earners with idle + // issues aren't confusing — they clearly earn). Catches matthewevans whether + // he's at 0 or earning only the maintainer cut; excludes ordinary PR earners. + if (view.totalIssues < 100 || view.prEarning || view.issueEarning) return null; + const paysDiscovery = view.rows.some((row) => row.issueDiscoveryShare > 0); + return paysDiscovery + ? `${view.totalIssues} issues registered, but none earn yet — issue discovery needs 3+ valid solved issues at 80%+ issue credibility.` + : `${view.totalIssues} issues registered, but they earn nothing — none of these repos allocate emission to issue discovery.`; +} + +// ─── Sorting + ranking ──────────────────────────────────────────────────────── + +function metricFor(view: MinerView, key: SortKey): number { + if (key === 'score') return view.totalScore + view.issueScore; + if (key === 'earnings') return view.usdPerDay; + if (key === 'repos') return view.rows.length; + if (key === 'activity') return view.activity; + return 0; +} + +export function rankMap(views: MinerView[], key: SortKey): Map { + const sorted = [...views].sort((a, b) => metricFor(b, key) - metricFor(a, key) || a.login.localeCompare(b.login)); + return new Map(sorted.map((view, index) => [view.key, index + 1])); +} + +export function compareViews(sortKey: SortKey, sortDir: SortDir) { + return (a: MinerView, b: MinerView) => { + let cmp = 0; + if (sortKey === 'activity') cmp = a.activity - b.activity; + if (sortKey === 'earnings') cmp = a.usdPerDay - b.usdPerDay; + if (sortKey === 'score') cmp = a.totalScore + a.issueScore - (b.totalScore + b.issueScore); + if (sortKey === 'repos') cmp = a.rows.length - b.rows.length; + if (sortKey === 'name') cmp = a.login.toLowerCase().localeCompare(b.login.toLowerCase()); + if (cmp === 0) cmp = a.activity - b.activity || a.usdPerDay - b.usdPerDay || a.login.localeCompare(b.login); + return sortDir === 'desc' ? -cmp : cmp; + }; +} + +// ─── Emission pool (treemap) ─────────────────────────────────────────────────── + +/** Live SN74 emission feed (proxied from TaoMarketCap via /api/sn74-emission). */ +export interface EmissionData { + totalTaoPerDay?: number | null; + minerTaoPerDay?: number | null; + validatorTaoPerDay?: number | null; + recycleTaoPerDay?: number | null; + treasuryTaoPerDay?: number | null; + /** Per-UID sum of active (non-recycle, non-treasury) miner alpha → TAO. With + * recycle + treasury it forms the per-repo TAO base (`subnetTAO`). */ + activeMinerTaoPerDay?: number | null; + ownerTaoPerDay?: number | null; + minerCount?: number | null; + validatorCount?: number | null; + /** Per-UID actual daily TAO (alpha_per_day × price) — exactly what TaoMarketCap + * shows. `minerView` uses each miner's uid entry as the authoritative headline + * emission; the score-share model only approximates it (runs a few % high). */ + perUidTaoPerDay?: Record | null; +} + +/** The per-repo TAO base — the slice of subnet emission the protocol formula + * `emissionShare × OSS_POOL` is a fraction of (active-miner UIDs + recycle UID 0 + * + treasury UID 111). Matches the repositories page's `subnetTAO` exactly so + * per-repo emission agrees across both surfaces. Falls back to half the total + * subnet emission (the ~50/50 chain split) while the breakdown is loading. */ +export function subnetTaoBase(emission: EmissionData | null | undefined): number { + const active = num(emission?.activeMinerTaoPerDay); + const recycle = num(emission?.recycleTaoPerDay); + const treasury = num(emission?.treasuryTaoPerDay); + if (active > 0 || recycle > 0 || treasury > 0) return active + recycle + treasury; + return num(emission?.totalTaoPerDay) / 2; +} + +export type PoolTileKind = 'miner' | 'others'; + +/** One tile in the miner treemap. Miners carry a `view`; the aggregate + * "others" tile (smaller earners beyond the cap) doesn't. The on-chain sinks + * (recycle UID 0, treasury UID 111) are NOT tiles — they'd dwarf the miners — + * they live in the allocation bar above the map instead. */ +export interface PoolTile { + key: string; + kind: PoolTileKind; + /** Daily TAO emission — the area weight. */ + tao: number; + /** Daily USD emission (the miner's, or the aggregate for "others"). */ + usd: number; + view: MinerView | null; + label: string; + sub: string; + /** 1-based rank among miners by TAO; 0 for the "others" tile. */ + rank: number; + /** Miner count represented (1 for a single miner, N for the "others" tile). */ + count: number; + /** Representative avatar URLs — the largest few miners folded into the "others" + * tile, used to render its face mosaic. Empty for single-miner tiles. */ + avatars: string[]; +} + +/** + * Build the miner slice of the emission pool as treemap tiles, weighted by + * daily TAO. The top `maxMinerTiles` miners get their own tile; the remaining + * earners fold into a single "others" tile so the miner slice stays whole. + */ +export function buildPoolTiles(views: MinerView[], maxMinerTiles = 56): PoolTile[] { + const earners = views + .filter((v) => v.taoPerDay > 0) + .sort((a, b) => b.taoPerDay - a.taoPerDay || a.login.localeCompare(b.login)); + + const tiles: PoolTile[] = earners.slice(0, maxMinerTiles).map((v, i) => ({ + key: v.key, + kind: 'miner', + tao: v.taoPerDay, + usd: v.usdPerDay, + view: v, + label: v.login, + sub: `uid ${v.uid ?? '-'}`, + rank: i + 1, + count: 1, + avatars: [], + })); + + const rest = earners.slice(maxMinerTiles); + const restTao = rest.reduce((sum, v) => sum + v.taoPerDay, 0); + const restUsd = rest.reduce((sum, v) => sum + v.usdPerDay, 0); + if (rest.length > 0 && restTao > 0) { + tiles.push({ + key: '__others', + kind: 'others', + tao: restTao, + usd: restUsd, + view: null, + label: 'Others', + sub: `${rest.length} more`, + rank: 0, + count: rest.length, + // The largest few tail miners (rest is already sorted by TAO desc). + avatars: rest.slice(0, 4).map((v) => v.avatarUrl), + }); + } + + return tiles; +} diff --git a/src/app/miners/_lib/squarify.ts b/src/app/miners/_lib/squarify.ts new file mode 100644 index 0000000..782fde5 --- /dev/null +++ b/src/app/miners/_lib/squarify.ts @@ -0,0 +1,130 @@ +/* Squarified treemap layout (Bruls/Huijbregts/van Wijk, 2000). + * + * Self-contained copy mirroring `repositories/_lib/squarify.ts` so the miners + * feature folder stays standalone. Packs weighted segments into a containing + * rectangle with low aspect-ratio variance — cleaner than slice-and-dice when + * the input distribution is uneven (miner earnings are heavily long-tailed). */ + +export interface SquarifyInput { + w: number; + data: T; +} + +export interface SquarifyRect { + x: number; + y: number; + w: number; + h: number; + data: T; +} + +export interface SquarifyOptions { + sort?: boolean; +} + +export function squarify( + segs: Array>, + x: number, + y: number, + w: number, + h: number, + options: SquarifyOptions = {}, +): Array> { + if (segs.length === 0 || !Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { + return []; + } + + const totalArea = w * h; + const weighted = segs.map((seg) => ({ + data: seg.data, + w: Number.isFinite(seg.w) && seg.w > 0 ? seg.w : 0, + })); + const totalWeight = weighted.reduce((a, b) => a + b.w, 0); + const items = (totalWeight > 0 + ? weighted.map((seg) => ({ data: seg.data, area: (seg.w / totalWeight) * totalArea })) + : weighted.map((seg) => ({ data: seg.data, area: totalArea / weighted.length })) + ).filter((seg) => seg.area > 0); + + if (options.sort !== false) { + items.sort((a, b) => b.area - a.area); + } + + if (items.length === 0) return []; + + const result: Array> = []; + + function layoutRow( + row: Array<{ data: T; area: number }>, + rx: number, + ry: number, + rw: number, + rh: number, + side: 'h' | 'v', + ): { x: number; y: number; w: number; h: number } { + const rowArea = row.reduce((a, b) => a + b.area, 0); + if (side === 'h') { + const rowH = rowArea / rw; + let cx = rx; + for (const it of row) { + const cw = it.area / rowH; + result.push({ x: cx, y: ry, w: cw, h: rowH, data: it.data }); + cx += cw; + } + return { x: rx, y: ry + rowH, w: rw, h: rh - rowH }; + } else { + const rowW = rowArea / rh; + let cy = ry; + for (const it of row) { + const ch = it.area / rowW; + result.push({ x: rx, y: cy, w: rowW, h: ch, data: it.data }); + cy += ch; + } + return { x: rx + rowW, y: ry, w: rw - rowW, h: rh }; + } + } + + function worstRatio(row: Array<{ data: T; area: number }>, side: 'h' | 'v', rw: number, rh: number): number { + if (row.length === 0) return Infinity; + const sum = row.reduce((a, b) => a + b.area, 0); + const length = side === 'h' ? rw : rh; + const stripThickness = sum / length; + let worst = 0; + for (const it of row) { + const otherDim = it.area / stripThickness; + const ratio = Math.max(stripThickness / otherDim, otherDim / stripThickness); + if (ratio > worst) worst = ratio; + } + return worst; + } + + function pack(): void { + let cur = items; + let rx = x; + let ry = y; + let rw = w; + let rh = h; + while (cur.length > 0) { + const side: 'h' | 'v' = rw < rh ? 'h' : 'v'; + const row: Array<{ data: T; area: number }> = []; + let i = 0; + while (i < cur.length) { + const trial = row.concat([cur[i]]); + if (row.length === 0 || worstRatio(trial, side, rw, rh) <= worstRatio(row, side, rw, rh)) { + row.push(cur[i]); + i++; + } else { + break; + } + } + const remaining = layoutRow(row, rx, ry, rw, rh, side); + rx = remaining.x; + ry = remaining.y; + rw = remaining.w; + rh = remaining.h; + cur = cur.slice(i); + } + } + + pack(); + return result; +} diff --git a/src/app/miners/_lib/streams.ts b/src/app/miners/_lib/streams.ts new file mode 100644 index 0000000..c491d18 --- /dev/null +++ b/src/app/miners/_lib/streams.ts @@ -0,0 +1,82 @@ +// Reward-stream presentation — colors and the view/repo → stream mappings shared +// by the treemap (Headline) and the command palette (Palette), so the two never +// drift. Classification itself lives in miners.ts (repoEarnsPr / +// repoEarnsIssueDiscovery); this module only turns it into colors/labels. + +import type { CSSProperties } from 'react'; +import { repoEarnsIssueDiscovery, repoEarnsPr, type MinerView, type RepoSignal } from './miners'; + +// green = PRs/contributor pool, purple = issue discovery, orange = maintainer +// cut, gray = no identified (attributable) stream. +export const PR_COLOR = 'var(--success-emphasis)'; +export const ISSUE_COLOR = 'var(--done-emphasis)'; +export const MAINTAINER_COLOR = '#e0773d'; // orange — distinct from PR/issue +export const NEUTRAL_COLOR = 'var(--fg-subtle)'; // gray — earns/active but no attributable stream + +export interface Streams { + pr: boolean; + issue: boolean; + maintainer: boolean; +} + +/** A miner's REAL earned streams. One with none — a non-earner, or the + * divergence case (e.g. a maintainer cut the live roster no longer attributes to + * them) — gets a neutral swatch downstream, not a misleading green/PR one. */ +export function streamsOf(view: MinerView): Streams { + return { pr: view.prEarning, issue: view.issueEarning, maintainer: view.isMaintainer }; +} + +/** Every reward-stream color a miner reads as, in display order: green (PR), + * purple (issue discovery), orange (maintainer cut); neutral gray if none. */ +export function streamColors(view: MinerView): string[] { + const { pr, issue, maintainer } = streamsOf(view); + const colors: string[] = []; + if (pr) colors.push(PR_COLOR); + if (issue) colors.push(ISSUE_COLOR); + if (maintainer) colors.push(MAINTAINER_COLOR); + return colors.length > 0 ? colors : [NEUTRAL_COLOR]; +} + +/** Single representative color — the miner's primary stream — for a tile tint. */ +export function streamColor(view: MinerView): string { + return streamColors(view)[0]; +} + +/** UID-pill background: a hard-stop split combining EVERY stream the miner has + * (e.g. green | purple | orange for a PR + issue + maintainer contributor). */ +export function streamBackground(view: MinerView): string { + const colors = streamColors(view); + if (colors.length === 1) return colors[0]; + const seg = 100 / colors.length; + const stops = colors.map((c, i) => `${c} ${(i * seg).toFixed(1)}% ${((i + 1) * seg).toFixed(1)}%`).join(', '); + return `linear-gradient(90deg, ${stops})`; +} + +export function streamLabel(view: MinerView): string { + const { pr, issue, maintainer } = streamsOf(view); + const parts: string[] = []; + if (pr) parts.push('Pull requests'); + if (issue) parts.push('Issue discovery'); + if (maintainer) parts.push('Maintainer cut'); + return parts.join(' + ') || 'No identified reward stream'; +} + +/** Tinted "filled" badge style in a stream color — used for the top-repo chips. */ +export function fillBadge(color: string): CSSProperties { + return { + background: `color-mix(in srgb, ${color} 16%, transparent)`, + borderColor: `color-mix(in srgb, ${color} 40%, transparent)`, + color, + }; +} + +/** How a miner earns on a specific repo → its chip color. A miner can't be both + * a maintainer AND an eligible contributor on the SAME repo (gittensor + * mechanism), so a maintained repo is unambiguously the maintainer-cut stream — + * check it first; otherwise use the eligibility-gated reward predicates. */ +export function repoStreamColor(row: RepoSignal, maintainerRepos: string[]): string { + if (maintainerRepos.some((repo) => repo.toLowerCase() === row.repo.toLowerCase())) return MAINTAINER_COLOR; + if (repoEarnsPr(row)) return PR_COLOR; + if (repoEarnsIssueDiscovery(row)) return ISSUE_COLOR; + return PR_COLOR; +} diff --git a/src/app/miners/page.module.css b/src/app/miners/page.module.css new file mode 100644 index 0000000..ab0499d --- /dev/null +++ b/src/app/miners/page.module.css @@ -0,0 +1,4929 @@ +/* Miners page. A miner-first view over the live SN74 scoring feed. The page + * is built from a switchable headline (podium / treemap / market / metrics), + * a control toolbar, activity lenses, a card or list board, a detail drawer, + * a compare tray + modal, and a ⌘K palette. All surfaces share the design + * tokens from globals.css; the locally-scoped `--soft-*` vars below just give + * the page a slightly softer border/fill language than the app default. */ + +.page{ + --app: var(--bg-canvas); + --app-surface: var(--bg-subtle); + --app-elev: var(--bg-emphasis); + --app-deep: var(--bg-inset); + --soft-border: rgba(255, 255, 255, 0.06); + --softer-border: rgba(255, 255, 255, 0.04); + --soft-fill: rgba(255, 255, 255, 0.04); + --softer-fill: rgba(255, 255, 255, 0.025); + --fg: var(--fg-default); + --fg-dim: var(--fg-muted); + --fg-mute: var(--fg-subtle); + --mono: ui-monospace, 'SF Mono', Menlo, Monaco, Consolas, monospace; + + width: 100%; + /* Full-width root (repositories model): the toolbar band runs edge-to-edge; + * content sections center themselves via .container. */ + min-height: 100%; + padding: 24px 0; + color: var(--fg-default); + overflow-x: clip; + font-feature-settings: 'cv11', 'ss01', 'ss03'; + letter-spacing: -0.005em; + accent-color: var(--accent-emphasis); +} + +/* Centers a section at the page max-width with side gutters (the padding that + * used to live on .page). Repositories layout: full-width .page + per-section + * .container so the toolbar band can run edge-to-edge. */ +/* Repositories layout model: a full-width, side-padded .section (the gutter) + * wraps a max-width:1440 .container (the cap). The .container then measures a + * clean 1440 on wide screens — the 16px side gutter lives on .section, exactly + * as repos does it (padded
+ inner .container). width: 100% is + * required because .page (the
) is a flexbox (global `main { display:flex }`): + * without it a flex item shrink-wraps to its content instead of filling. */ +.section{ + width: 100%; + padding: 0 16px; +} +.container{ + width: 100%; + max-width: 1440px; + margin: 0 auto; +} + +:global([data-theme='light']) .page{ + --soft-border: rgba(0, 0, 0, 0.08); + --softer-border: rgba(0, 0, 0, 0.05); + --soft-fill: rgba(0, 0, 0, 0.04); + --softer-fill: rgba(0, 0, 0, 0.025); +} + +.hideOnMobile{ + display: inline; +} + +@media (max-width: 767px) { + .hideOnMobile{ + display: none !important; + } +} + +/* ═══════════════ EMISSION OVERVIEW ═══════════════ */ + +.emission{ + padding: 4px 0 2px; + margin-bottom: 16px; +} + +.emissionRow{ + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Headline row: the TAO/day lead on the left, the live earnings-distribution + * panel on the right; wraps (panel drops below) on narrow screens. */ +.emissionTopRow{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px 28px; + flex-wrap: wrap; +} + +.emissionLead{ + min-width: 0; + flex: 1 1 300px; +} + +.emissionEyebrow{ + font-size: 11px; + font-weight: 500; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--fg-subtle); +} + +.emissionValue{ + margin: 4px 0 0; + font-size: clamp(20px, 2.5vw, 26px); + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.02em; + color: var(--accent-fg); +} + +.emissionValue em{ + margin-left: 8px; + color: var(--fg-subtle); + font-size: 12px; + font-style: italic; + font-weight: 400; +} + +.emissionDesc{ + margin-top: 8px; + max-width: 36rem; + color: var(--fg-subtle); + font-size: 12.5px; + line-height: 1.5; +} + +.emissionDesc a{ + color: var(--fg-muted); + text-decoration: underline; + text-decoration-color: var(--border-strong); +} + +/* Recipient cards — a responsive grid that spans the full width as one row, + * wrapping on narrow screens. Each follows the reference UI: a tinted icon chip + * top-left, a share pill top-right, value / label / source, on a solid bordered + * card. Color comes from flat tints (no gradients), keyed to the recipient's + * --seg color. */ +.emissionStats{ + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; +} + +.emissionStat{ + position: relative; + overflow: hidden; + isolation: isolate; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + padding: 13px 14px; + border-radius: 12px; + border: 1px solid var(--soft-border); + /* Slightly elevated surface (lighter than the page) so cards read as raised, + * not recessed like the darker --bg-inset did. */ + background: var(--bg-subtle); + transition: border-color 120ms ease, background 120ms ease; +} + +/* Corner decorator — nested rotated rounded squares peeking from the top-right, + * group-colored via --deco (flat tints, no gradient). Sits behind the content + * (z-index:-1, under the share pill that overlaps it, like the reference). */ +.emissionStat::before{ + content: ''; + position: absolute; + top: -18px; + right: -18px; + z-index: -1; + width: 50px; + height: 50px; + border-radius: 13px; + transform: rotate(40deg); + background: color-mix(in srgb, var(--deco) 14%, transparent); + box-shadow: + inset 0 0 0 1.5px color-mix(in srgb, var(--deco) 42%, transparent), + 0 0 0 7px color-mix(in srgb, var(--deco) 9%, transparent); + pointer-events: none; +} + +.emissionStat:hover{ + border-color: color-mix(in srgb, var(--seg) 48%, var(--soft-border)); +} + +/* Loading placeholders aren't interactive — suppress the hover border/outline. */ +.emissionStatSkeleton, +.emissionStatSkeleton:hover{ + pointer-events: none; + border-color: var(--soft-border); +} + +/* Icon chip — rounded square, tinted fill + a hairline ring, like the reference. */ +.emissionIcon{ + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin-bottom: 8px; + border-radius: 8px; + color: var(--seg); + background: color-mix(in srgb, var(--seg) 16%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seg) 32%, transparent); +} + +/* Share pill — top-right status chip (dot + % of pool), mirroring the + * reference's "● Online" pill. */ +.emissionShare{ + position: absolute; + top: 11px; + right: 11px; + display: inline-flex; + align-items: center; + padding: 2px 7px 2px 6px; + border-radius: 999px; + font-family: var(--mono); + font-size: 10px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: color-mix(in srgb, var(--seg) 72%, var(--fg-default)); + background: color-mix(in srgb, var(--seg) 13%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--seg) 28%, transparent); +} + +.emissionShare::before{ + content: ''; + width: 5px; + height: 5px; + margin-right: 5px; + border-radius: 999px; + background: var(--seg); +} + +.emissionStat strong{ + font-size: 26px; + font-weight: 500; + line-height: 1; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum' on, 'cv11'; +} + +/* Unit rides smaller + muted so the figure leads. */ +.emissionStat strong em{ + font-style: normal; + font-size: 11px; + font-weight: 500; + letter-spacing: 0; + color: var(--fg-subtle); +} + +.emissionStat span{ + font-size: 10.5px; + font-weight: 500; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--fg-subtle); +} + +.emissionStat small{ + color: var(--fg-muted); + font-size: 10px; +} + +/* ═══════════════ HEADLINE (treemap) ═══════════════ */ + +.headline{ + margin-bottom: 16px; +} + +.headlineStage{ + padding: 4px 0 10px; + min-height: 300px; +} + +.headlineFoot{ + display: flex; + align-items: center; + gap: 7px; + padding: 0; + color: var(--fg-subtle); + font-size: 11.5px; +} + +.headlineEmpty{ + display: grid; + place-items: center; + min-height: 160px; + color: var(--fg-subtle); + font-size: 13px; +} + +/* Treemap loading placeholder — shimmer tiles of descending width that fill the + map area, mirroring the real (TAO-sized) tile strip. */ +.treemapSkeleton{ + position: absolute; + inset: 0; +} + +/* Skeleton tiles are absolutely positioned from the same squarify packer as the + * real treemap (left/top/width/height set inline), so the placeholder mosaic + * matches the map's shape at any viewport instead of collapsing to bars. */ +.treemapSkeleton > span{ + position: absolute; + border-radius: 8px; +} + +/* ── Shared avatar + rank badge ── */ + +.avatarWrap{ + position: relative; + flex: 0 0 auto; +} + +/* Top-3 medal (RankMedal) at the avatar's bottom-right — same icon as the + * treemap tiles. */ +.cardRankMedal{ + position: absolute; + bottom: -7px; + right: -7px; + z-index: 2; + line-height: 0; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5)); +} + +/* ── Treemap ── */ + +.treemapWrap{ + display: flex; + flex-direction: column; + gap: 12px; +} + +.treemap{ + position: relative; + width: 100%; + border-radius: 8px; + /* No overflow:hidden — it would clip the tiles' hover drop-shadow on the + * edges. The tiles carry their own rounded corners, so nothing needs + * clipping here. */ + background: var(--bg-inset); +} + +.treeTile{ + position: absolute; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 1px; + padding: 6px 8px; + border: 0; + border-radius: 5px; + color: var(--fg-default); + cursor: pointer; + overflow: hidden; + isolation: isolate; + text-align: left; + transition: filter 130ms ease; +} + +/* Bottom-up scrim — keeps the label legible on any tile tint and adds depth. + * As the first child it paints above the fill but below the positioned label + * and avatar (which carry their own position). */ +.treeTile::before{ + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.52) 0%, rgba(0, 0, 0, 0.15) 36%, rgba(0, 0, 0, 0) 68%); + pointer-events: none; +} + +/* Brightness + shadow are both in `filter` on purpose: a CSS `filter` clips a + * sibling `box-shadow` to the border box (so it'd vanish exactly on hover), but + * a `drop-shadow()` inside the filter expands the region and renders fully. */ +.treeTile:hover{ + filter: brightness(1.14) drop-shadow(0 5px 14px rgba(0, 0, 0, 0.55)); + z-index: 5; +} + +/* Avatar-backed tiles: the miner's photo fills the tile; the wash + scrim + * (defined after the base ::before so it wins) keep it on-brand and legible. */ +.treeTileAvatarBg{ + background-repeat: no-repeat; + background-position: center; + background-size: cover; +} + +.treeTileAvatarBg::before{ + background: linear-gradient(to top, rgba(0, 0, 0, 0.72) 0%, rgba(0, 0, 0, 0.30) 50%, rgba(0, 0, 0, 0) 80%); +} + +.treeTileAvatarBg .treeName{ + color: #f7f8f8; +} + +.treeTileAvatarBg .treeValue{ + color: rgba(255, 255, 255, 0.85); +} + +/* "Others" tile face mosaic — real s in a 2x2 grid so square avatars CROP + * (object-fit: cover) instead of stretching to the tile's aspect ratio, which is + * what made them look distorted. The ::after flat scrim mutes the faces; z-index + * keeps the whole thing behind the tile's bottom scrim + "Others" label. */ +.treeTileMosaic{ + position: absolute; + inset: 0; + z-index: -1; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; +} +.treeTileMosaic img{ + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.treeTileMosaic::after{ + content: ''; + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.34); +} + +/* Rank medal — positioned wrapper for the medal SVG (gold/silver/bronze disc + + * ribbon, with the rank number inside). The SVG carries the metal colors; this + * just places it top-left and adds a drop shadow so it lifts off the avatar. */ +.treeTileRank{ + position: absolute; + top: 5px; + left: 6px; + z-index: 2; + line-height: 0; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.55)); +} + +/* Miner UID — top-right pill. Its background is tinted by reward stream (green + * PR / purple issue / green→purple split for dual) via an inline style, so the + * pill doubles as the stream marker. White text + shadows keep the number + * legible on the saturated tint and lift the pill off bright avatars. The dark + * background here is a fallback only (the inline tint always overrides it). */ +.treeTileUid{ + position: absolute; + top: 6px; + right: 7px; + padding: 1px 6px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + font-family: var(--mono); + font-size: 9.5px; + font-weight: 700; + line-height: 1.5; +} + +/* Non-miner pool tiles (recycle / treasury / others) read as muted blocks. */ +.treeSink{ + color: var(--fg-muted); +} + +.treeSink .treeName{ + color: var(--fg-default); +} + +.treeLabelRow{ + position: relative; + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 100%; +} + +/* Stream icon(s) (PR / issue discovery) — shown in the overview inspector sub-line. */ + +.treeName{ + position: relative; + font-weight: 600; + line-height: 1.15; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55); +} + +.treeValue{ + position: relative; + font-family: var(--mono); + font-size: 10.5px; + color: rgba(255, 255, 255, 0.82); + font-variant-numeric: tabular-nums; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); +} + +:global([data-theme='light']) .treeValue{ + color: var(--fg-muted); + text-shadow: none; +} + +:global([data-theme='light']) .treeName{ + text-shadow: none; +} + +/* Light tiles are tinted toward white, so flip the scrim to lift the dark + * label off the bottom instead of darkening it. */ +:global([data-theme='light']) .treeTile::before{ + background: linear-gradient(to top, rgba(255, 255, 255, 0.62) 0%, rgba(255, 255, 255, 0.18) 40%, rgba(255, 255, 255, 0) 70%); +} + +/* Avatar tiles keep a dark veil in light mode too (the photo needs it), so + * re-assert the dark scrim + light label. Defined after the light scrim above + * to win the specificity tie. */ +:global([data-theme='light']) .treeTileAvatarBg::before{ + background: linear-gradient(to top, rgba(0, 0, 0, 0.72) 0%, rgba(0, 0, 0, 0.30) 50%, rgba(0, 0, 0, 0) 80%); +} + +:global([data-theme='light']) .treeTileAvatarBg .treeName{ + color: #f7f8f8; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); +} + +:global([data-theme='light']) .treeTileAvatarBg .treeValue{ + color: rgba(255, 255, 255, 0.85); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55); +} + +.treeLegend{ + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.treeLegendNote{ + color: var(--fg-subtle); + font-size: 11px; + margin-left: auto; +} + +/* ── Treemap overview inspector — mirrors the repositories bar inspector ── */ + +.treeInspector{ + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + min-height: 64px; + padding: 12px; + border: 1px solid var(--soft-border); + border-radius: 7px; + background: var(--bg-subtle); + transition: border-color 150ms, background 150ms; +} + +.treeInspectorActive{ + border-color: var(--accent-glow); + background: var(--accent-subtle); +} + +.treeInspector img{ + width: 44px; + height: 44px; + border-radius: 8px; + border: 1px solid var(--soft-border); + object-fit: cover; + background: var(--bg-emphasis); + flex: 0 0 auto; +} + +.treeInspectorEmpty{ + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + width: 100%; + text-align: center; + color: var(--fg-muted); + font-size: 12px; +} + +.treeInspectorHint{ + color: var(--fg-subtle); + font-size: 10.5px; +} + +/* Empty-overview "Browse all miners" button — tinted secondary in brand indigo, + * mirroring the repositories BarInspector browse button. */ +.inspectorBrowseBtn{ + background: var(--accent-subtle); + color: var(--accent-fg); + border: 1px solid var(--accent-glow); + font-size: 12px; + font-weight: 500; + padding: 8px 14px; + border-radius: 5px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + transition: all 100ms; + min-height: 36px; +} +.inspectorBrowseBtn:hover{ + background: var(--menu-item-hover-bg); + border-color: var(--accent-emphasis); +} + +.treeInspectorId{ + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + /* No flex-grow (matches the repositories BarInspector): content packs to the + left and only the Open button is pushed right via margin-left:auto, instead + of this block expanding and leaving dead space before the stats. */ + flex: 0 1 auto; +} + +.treeInspectorId .identityLine strong{ + max-width: 220px; + font-size: 13px; +} + +.treeInspectorSub{ + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + font-family: var(--mono); + font-size: 11px; + color: var(--fg-muted); +} + +/* Vertical divider between the identity/score badges and the top-repos. */ +.treeDivider{ + align-self: stretch; + flex: 0 0 auto; + width: 1px; + background: var(--soft-border); +} + +/* The spotlighted miner's top earning/scoring repos — ranked chips (gold / + silver / bronze) mirroring the repositories page's "top earners" row. */ +.topRepos{ + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 0; +} + +.topReposLabel{ + font-size: 9.5px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--fg-subtle); + flex-shrink: 0; +} + +.topReposList{ + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + min-width: 0; +} + +/* Pill badge per repo (avatar + name + score) — mirrors the repositories + earner chips. */ +.topRepoChip{ + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 8px 3px 4px; + border-radius: 999px; + background: var(--bg-emphasis); + border: 1px solid var(--soft-border); + color: var(--fg-default); + text-decoration: none; + font-size: 11px; + line-height: 1; + transition: border-color 100ms, background 100ms; +} + +.topRepoChip:hover{ + border-color: var(--accent-glow); +} + +/* Owner avatar inside each top-repo item — matches the repositories avatarSm + (20px, 4px radius). Scoped under .topRepoChip so it beats the generic + `.treeInspector img` rule (which otherwise forces 44px). */ +.topRepoChip .topRepoChipAvatar{ + width: 20px; + height: 20px; + border-radius: 4px; + border: 1px solid var(--soft-border); + object-fit: cover; + background: var(--bg-subtle); + flex-shrink: 0; +} + +.topRepoName{ + font-size: 11px; + color: var(--fg-muted); + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topRepoScore{ + font-family: var(--mono); + font-size: 10.5px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +/* Muted secondary value inside a stat (e.g. "14 · 2 issues"). */ +.treeStatSub{ + font-style: normal; + font-weight: 400; + color: var(--fg-subtle); +} + +/* Stream legend entries — a pill swatch matching each tile's tinted UID pill + * (solid PR / issue, or the green→purple split for dual miners). */ +.legendBar{ + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--fg-muted); + font-size: 11px; +} + +.legendBar > span{ + width: 18px; + height: 11px; + border-radius: 999px; + flex: 0 0 auto; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14); +} + +.treeInspectorBrowse{ + align-self: flex-start; + padding: 2px 0; + border: 0; + background: transparent; + color: var(--fg-muted); + font: inherit; + font-size: 10.5px; + text-align: left; + text-decoration: underline; + text-decoration-color: var(--soft-border); + text-underline-offset: 3px; + cursor: pointer; + transition: color 100ms; +} + +.treeInspectorBrowse:hover{ + color: var(--accent-fg); + text-decoration-color: var(--accent-glow); +} + +.treeInspectorStats{ + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + border-left: 1px solid var(--soft-border); + border-right: 1px solid var(--soft-border); + padding: 0 16px; + margin: 0 8px; +} + +.treeInspectorSkeletonStats{ + display: flex; + align-items: flex-end; + gap: 18px; + flex: 0 0 auto; + margin: 0 8px; +} + +.treeInspectorSkeletonStat{ + display: flex; + flex-direction: column; + gap: 6px; +} + +@media (max-width: 760px) { + .treeInspectorSkeletonStats{ + flex-wrap: wrap; + margin: 0; + } +} + +.treeInspectorStat{ + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.treeInspectorStat span{ + font-size: 9.5px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-subtle); + line-height: 1.2; +} + +.treeInspectorStat strong{ + font-family: var(--mono); + font-size: 12.5px; + font-weight: 500; + color: var(--fg-muted); + font-variant-numeric: tabular-nums; + line-height: 1.2; + white-space: nowrap; +} + +.treeStatGreen{ + color: var(--success-fg) !important; +} + +.treeStatPurple{ + color: var(--done-fg) !important; +} + +.treeInspectorOpen{ + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 36px; + padding: 8px 12px; + margin-left: auto; + border: 0; + border-radius: 5px; + background: var(--btn-primary-bg); + color: var(--btn-primary-fg); + font: inherit; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + flex-shrink: 0; + transition: background 100ms; +} + +.treeInspectorOpen:hover{ + background: var(--btn-primary-hover-bg); +} + +/* Sink (recycle / treasury / others) inspector bits. */ +.sinkSwatch{ + width: 46px; + height: 46px; + border-radius: 9px; + border: 1px solid var(--soft-border); + flex: 0 0 auto; + opacity: 0.85; +} + +/* Overview panel swatch for the "Others" tile — a 2x2 face mosaic of its + * largest miners, mirroring the tile's mosaic. */ +.sinkMosaic{ + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + width: 46px; + height: 46px; + border-radius: 9px; + overflow: hidden; + border: 1px solid var(--soft-border); + flex: 0 0 auto; +} + +.sinkMosaic img{ + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.uidPill{ + flex: 0 0 auto; + height: 17px; + padding: 0 6px; + border-radius: 3px; + background: var(--bg-emphasis); + color: var(--fg-muted); + font-family: var(--mono); + font-size: 10px; + font-weight: 600; + line-height: 17px; +} + +/* ═══════════════ TOOLBAR ═══════════════ */ + +.toolbar{ + /* Full-width band (repositories model): spans the full-width .page edge to + * edge; content centers via .toolbarInner, aligned with the .container sections. + * width:100% is required for the same reason as .container — .page (the
) + * is a flexbox, so without it this flex item shrink-wraps to its content instead + * of filling. No max-width here: the band itself goes edge-to-edge. */ + width: 100%; + margin: 0 0 16px; + padding: 14px 16px; + border-top: 1px solid var(--soft-border); + border-bottom: 1px solid var(--soft-border); + background: var(--bg-subtle); +} + +.toolbarInner{ + width: 100%; + max-width: 1440px; + margin: 0 auto; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 14px; +} + +/* Filter group (label + chips) wraps as one unit, so on narrow widths the + * controls drop to their own row instead of interleaving with the chips. */ +.toolbarFilters{ + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 14px; + min-width: 0; +} + +/* Filter-by stream pills — mirror the repositories toolbar chip style. */ +.filterBy{ + font-size: 11px; + font-weight: 500; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--fg-subtle); + flex-shrink: 0; +} + +.filterChips{ + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.chip{ + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + font-size: 12px; + border-radius: 5px; + background: var(--soft-fill); + border: 1px solid var(--soft-border); + color: var(--fg-dim); + line-height: 1; + cursor: pointer; + transition: background 100ms, color 100ms, border-color 100ms; + user-select: none; +} + +.chip:hover{ + color: var(--fg-default); + border-color: var(--border-default); +} + +.chipDot{ + width: 6px; + height: 6px; + border-radius: 2px; + flex: 0 0 auto; +} + +.toolbarSort{ + display: inline-flex; + align-items: center; + gap: 6px; +} + +.sortLabel{ + font-size: 11px; + color: var(--fg-subtle); +} + +.toolbarRight{ + display: inline-flex; + align-items: center; + gap: 12px 14px; + flex-wrap: wrap; +} + +.trackedButton, +.trackedButtonActive{ + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + height: 32px; + padding: 0 10px; + border: 1px solid var(--soft-border); + border-radius: 6px; + background: var(--bg-canvas); + color: var(--fg-muted); + font: inherit; + font-size: 12.5px; + font-weight: 500; + white-space: nowrap; + cursor: pointer; +} + +.trackedButton:hover, +.trackedButtonActive{ + color: var(--fg-default); + background: var(--bg-emphasis); +} + +.countPill{ + min-width: 20px; + height: 18px; + padding: 0 6px; + border-radius: 999px; + background: var(--bg-emphasis); + color: var(--fg-default); + font-family: var(--mono); + font-size: 10.5px; + line-height: 18px; + text-align: center; +} + +.viewToggleGroup{ + display: inline-flex; + gap: 1px; + padding: 2px; + border: 1px solid var(--soft-border); + border-radius: 6px; + background: var(--soft-fill); + line-height: 1; +} + +.viewToggle{ + appearance: none; + border: 0; + margin: 0; + padding: 5px 9px; + border-radius: 4px; + background: transparent; + color: var(--fg-subtle); + font: inherit; + font-size: 11.5px; + display: inline-flex; + align-items: center; + gap: 5px; + cursor: pointer; + transition: background 100ms, color 100ms; +} + +.viewToggle:hover{ + color: var(--fg-muted); +} + +.viewToggleActive{ + color: var(--fg-default) !important; + background: var(--bg-canvas) !important; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); +} + +.viewToggleLabel{ + display: none; +} + +.searchTrigger{ + display: inline-flex; + align-items: center; + gap: 8px; + height: 32px; + padding: 0 10px; + border-radius: 6px; + border: 1px solid var(--soft-border); + background: var(--bg-canvas); + color: var(--fg-subtle); + font: inherit; + font-size: 12.5px; + cursor: pointer; + transition: background 100ms, color 100ms, border-color 100ms; +} + +.searchTrigger:hover{ + background: var(--bg-emphasis); + color: var(--fg-default); + border-color: var(--border-default); +} + +.searchTriggerLabel{ + display: none; +} + +.searchTriggerKbd{ + display: none; + align-items: center; + gap: 4px; +} + +.kbd{ + font-family: inherit; + font-size: 10.5px; + padding: 1px 5px; + border-radius: 4px; + background: var(--soft-fill); + border: 1px solid var(--soft-border); + color: var(--fg-dim); + line-height: 1.4; +} + +@media (min-width: 860px) { + .searchTriggerLabel, +.searchTriggerKbd{ + display: inline-flex; + } +} + +/* Phone layout: the filter group and the controls each take a full row, and + * Sort grows to fill so the controls row uses the width instead of leaving a + * ragged gap. Below 768px the Tracked/Sort text labels are already hidden + * (.hideOnMobile), leaving compact icon + dropdown controls. */ +@media (max-width: 640px) { + .toolbarFilters, +.toolbarRight{ + flex: 1 1 100%; + } + .toolbarSort{ + flex: 1 1 auto; + min-width: 0; + } + .toolbarSort > button{ + width: 100% !important; + } +} + +/* ═══════════════ BOARD ═══════════════ */ + +.boardShell{ + min-width: 0; +} + +.boardHead{ + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; +} + +.boardEyebrow{ + font-size: 11px; + font-weight: 500; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--fg-subtle); +} + +.boardHeading{ + margin-top: 4px; + font-size: 14.5px; + font-weight: 500; + line-height: 1.2; +} + +.boardSync{ + flex-shrink: 0; + font-family: var(--mono); + font-size: 11.5px; + color: var(--fg-muted); + white-space: nowrap; +} + +/* ── Card grid ── */ + +.minerGrid{ + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + align-items: stretch; +} + +/* Pagination footer under the card grid / list. */ +.pagerRow{ + display: flex; + justify-content: flex-end; + padding-top: 14px; + margin-top: 14px; + border-top: 1px solid var(--soft-border); +} + +.minerCard{ + position: relative; + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; + padding: 16px; + border: 1px solid var(--soft-border); + border-radius: 10px; + background: var(--bg-subtle); + color: var(--fg-default); + cursor: pointer; + text-align: left; + transition: background 120ms, border-color 120ms, box-shadow 120ms; +} + +.minerCard:hover{ + border-color: rgba(255, 255, 255, 0.12); +} + +:global([data-theme='light']) .minerCard:hover{ + border-color: var(--border-default); + background: var(--bg-emphasis); +} + +.minerCard:focus, +.minerCard:focus-visible{ + outline: none; +} + +/* Selected (drawer open) / in-compare — subtle background tints only; no + * left-border bar or inset outline (the compare checkmark also marks compare). */ +.selectedCard{ + background: var(--accent-subtle); +} + +/* ── Repositories-style card layout ── */ + +.cardCorner{ + position: absolute; + top: 12px; + right: 12px; + z-index: 1; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.cardHead{ + display: flex; + align-items: flex-start; + gap: 11px; + padding-right: 56px; +} + +.cardHead .avatarWrap img{ + width: 40px; + height: 40px; + border-radius: 7px; + border: 1px solid var(--soft-border); + object-fit: cover; + background: var(--bg-emphasis); +} + +.cardHeadText{ + min-width: 0; + flex: 1; +} + +.cardNameLine{ + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.cardNameLine strong{ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13.5px; + font-weight: 600; +} + +.cardSub{ + margin-top: 3px; + color: var(--fg-subtle); + font-family: var(--mono); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +.cardHeadline{ + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + margin-top: 14px; +} + +.cardHeadlineMain{ + min-width: 0; +} + +.cardBig{ + font-family: var(--mono); + font-size: 36px; + line-height: 0.95; + font-weight: 600; + letter-spacing: -0.025em; + color: var(--accent-fg); + font-variant-numeric: tabular-nums; +} + +.cardBigUnit{ + margin-left: 4px; + font-size: 13px; + font-weight: 500; + letter-spacing: 0; + color: var(--fg-muted); +} + +.cardEyebrow{ + display: flex; + align-items: center; + gap: 7px; + margin-top: 7px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-subtle); +} + +.cardEyebrowSep{ + color: var(--border-strong); +} + +.cardEyebrowMono{ + font-family: var(--mono); + text-transform: none; + letter-spacing: 0; + color: var(--fg-muted); + font-variant-numeric: tabular-nums; +} + +.cardHeadlineSide{ + flex: 0 0 auto; + text-align: right; +} + +.cardSideNum{ + font-family: var(--mono); + font-size: 20px; + line-height: 1; + font-weight: 600; + color: var(--fg-default); + font-variant-numeric: tabular-nums; +} + +.cardSideLabel{ + margin-top: 5px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-subtle); +} + +.cardActivityRow{ + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 14px; + align-items: start; + margin-top: 14px; + padding-top: 13px; + border-top: 1px solid var(--soft-border); +} + +.cardActLabel{ + margin-bottom: 4px; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-subtle); +} + +/* Header activity triplet — open / merged / closed (or open / closed / completed), + * each an icon + count tinted by outcome. Wraps to a second line on narrow cards. */ +.actStats{ + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 3px 5px; +} + +.actStat{ + display: inline-flex; + align-items: center; + gap: 3px; + font-family: var(--mono); + font-size: 13px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.actSep{ + color: var(--fg-subtle); + font-size: 12px; +} + +.cardActSide{ + text-align: right; +} + +.cardActSub{ + margin-top: 2px; + font-size: 10px; + color: var(--fg-subtle); + font-variant-numeric: tabular-nums; +} + +/* Per-repo contributions chart in the activity row (repo-card sparkline analog). */ +.contribSpark{ + display: flex; + align-items: flex-end; + justify-content: flex-end; + gap: 2px; + height: 26px; +} + +.contribSpark > span{ + width: 5px; + min-height: 2px; + border-radius: 1px; +} + +.contribEmpty{ + display: flex; + align-items: flex-end; + justify-content: flex-end; + height: 26px; + color: var(--fg-subtle); +} + +.cardMaintBadge{ + display: inline-flex; + align-items: center; + flex: 0 0 auto; + padding: 1px 6px; + border: 1px solid color-mix(in srgb, #e0773d 34%, transparent); + border-radius: 3px; + background: color-mix(in srgb, #e0773d 14%, transparent); + color: #e0773d; + font-size: 10.5px; + font-weight: 500; + line-height: 1.5; + white-space: nowrap; +} + +.identityLine{ + display: flex; + align-items: center; + gap: 7px; + min-width: 0; +} + +.identityLine strong{ + display: block; + max-width: 170px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 600; +} + +.youPill{ + flex: 0 0 auto; + height: 17px; + padding: 0 6px; + border-radius: 3px; + background: var(--attention-subtle); + color: var(--attention-fg); + font-size: 10px; + font-weight: 600; + line-height: 17px; +} + +.starButton, +.starActive{ + display: grid; + place-items: center; + width: 22px; + height: 22px; + flex: 0 0 auto; + border: 1px solid var(--soft-border); + border-radius: 5px; + background: var(--soft-fill); + color: var(--fg-muted); + cursor: pointer; + transition: background 100ms, color 100ms, border-color 100ms; +} + +.starButton:hover, +.starActive:hover{ + color: var(--accent-fg); + background: var(--accent-subtle); + border-color: var(--accent-glow); +} + +/* Active (tracked / in-compare) — saturated indigo chip with a white glyph, + * matching the repositories RepoCard corner buttons. */ +.starActive{ + color: #ffffff; + background: var(--accent-emphasis); + border-color: var(--accent-emphasis); +} + +.miniStat{ + min-width: 0; +} + +.miniStat span{ + display: block; + font-size: 10.5px; +} + +.miniStat strong{ + display: block; + margin-top: 3px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-default); + font-family: var(--mono); + font-size: 15px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.greenText strong{ + color: var(--success-fg); +} + +.purpleText strong{ + color: var(--done-fg); +} + +/* ── Reward-stream badges: named, color-tinted chips under the name ── */ +.streamBadges{ + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px; + margin-top: 11px; +} + +.streamBadge{ + display: inline-flex; + align-items: center; + height: 20px; + padding: 0 8px; + border: 1px solid; + border-radius: 999px; + font-size: 10.5px; + font-weight: 600; + white-space: nowrap; +} + +/* ── Activity + aggregate credibility strip ── */ + +.cardReposLabel{ + margin: 13px 0 7px; + color: var(--fg-subtle); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.repoSignals, +.repoSignalsEmpty{ + margin: 0 0 13px; +} + +.repoSignals{ + display: grid; + gap: 6px; +} + +.repoSignalsEmpty{ + display: flex; + align-items: center; + min-height: 86px; + padding: 10px; + border: 1px dashed var(--soft-border); + border-radius: 6px; + color: var(--fg-subtle); + font-size: 12px; + line-height: 1.4; +} + +.repoRow{ + display: flex; + align-items: center; + gap: 9px; + min-width: 0; + padding: 8px 9px; + border: 1px solid var(--soft-border); + border-radius: 7px; + background: var(--soft-fill); +} + +.repoRowAvatar{ + flex: 0 0 auto; + width: 34px; + height: 34px; + border-radius: 7px; + border: 1px solid var(--soft-border); + object-fit: cover; + background: var(--bg-emphasis); +} + +.repoRowBody{ + flex: 1 1 auto; + min-width: 0; +} + +.repoRowName{ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-default); + font-size: 12px; + font-weight: 500; +} + +.repoRowMeta{ + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + margin-top: 6px; + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.repoRowMeta span{ + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1.5px 6px; + border: 1px solid var(--soft-border); + border-radius: 999px; + background: var(--bg-canvas); + color: var(--fg-muted); +} + +/* Emission badge — accent-tinted so the per-repo TAO reads as the headline metric. */ +.repoRowMeta .repoRowTao{ + color: var(--accent-fg); + border-color: color-mix(in srgb, var(--accent-fg) 32%, transparent); + background: color-mix(in srgb, var(--accent-fg) 12%, transparent); +} + +/* Repo emission weight ("% pool") — muted, borderless, so it reads as context + * for the τ/d rather than another metric competing for attention. */ +.repoRowMeta .repoRowPool{ + padding: 1.5px 4px; + border-color: transparent; + background: transparent; + color: var(--fg-subtle); +} + +/* "+N more earning repos" reconciliation line under the shown top repos. */ +.cardReposMore{ + margin: -7px 0 13px; + padding: 5px 2px 0; + color: var(--fg-muted); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +/* Tighter label for the secondary "Working toward earning" section so it doesn't + * add as much vertical weight as the primary "Top repos" heading. */ +.cardReposLabelTight{ + margin-top: 4px; +} + +/* "Working toward earning" — a compact, avatar-less list. Single-line rows keep + * this secondary section from inflating the card the way full repo rows would. */ +.blockedList{ + list-style: none; + margin: 0 0 4px; + padding: 0; + display: grid; + gap: 0; +} + +/* Each "almost earning" repo is two lines — repo + pool weight on top, the gate + * progress bar + reason below — split by a hairline (no boxes, matching the + * top-repos lines). */ +.blockedRow{ + display: flex; + align-items: center; + gap: 9px; + padding: 8px 0; + min-width: 0; + font-variant-numeric: tabular-nums; +} + +.blockedRow:first-child{ + padding-top: 1px; +} + +.blockedBody{ + flex: 1 1 auto; + min-width: 0; + display: grid; + gap: 5px; +} + +.blockedRow + .blockedRow{ + border-top: 1px solid var(--softer-border); +} + +.blockedTop{ + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.blockedRepo{ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11.5px; + color: var(--fg-default); +} + +.blockedPool{ + flex: 0 0 auto; + color: var(--fg-subtle); + font-size: 9.5px; +} + +.blockedGate{ + display: flex; + align-items: center; + gap: 8px; +} + +.blockedBarTrack{ + flex: 1 1 auto; + display: flex; + height: 4px; + border-radius: 999px; + background: var(--bg-emphasis); + overflow: hidden; +} + +.blockedBarSeg{ + height: 100%; +} + +.blockedReason{ + flex: 0 0 auto; + color: var(--fg-subtle); + font-size: 9.5px; + font-weight: 500; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.blockedNum{ + font-weight: 700; +} + +.blockedMore{ + margin-top: 6px; + color: var(--fg-subtle); + font-size: 10px; +} + +/* Contextual incentive note — explains a penalty, or why an active miner is + * earning nothing yet (and how earning would happen). */ +.cardNote{ + display: flex; + gap: 6px; + margin-top: 11px; + padding: 8px 10px; + border: 1px solid var(--soft-border); + border-radius: 8px; + background: var(--soft-fill); + color: var(--fg-muted); + font-size: 10.5px; + line-height: 1.45; +} + +.cardNote svg{ + flex: 0 0 auto; + margin-top: 1px; + color: var(--attention-fg); +} + +/* ─── Top-repos switchers (live A/B: grouping + emission style) ──────────── */ +/* ─── "Top repos" per-row list (ring avatar = credibility, emission = the row's + * own background filled left-to-right to the miner's share of the repo pool) ── */ +.repoList{ + display: grid; + gap: 0; + margin: 0 0 13px; +} + +.repoListRow{ + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +/* Round just the group's outer corners (top of the first row, bottom of the last) + * and clip the per-row fills to them — the inner hairline dividers stay straight, + * so the stack reads as one contained panel nested in the 10px card. */ +.repoListLines{ + border-radius: 8px; + overflow: hidden; +} + +/* Edge-to-edge rows split by a hairline divider, each row's background carrying + * its emission fill — so the column reads as one continuous per-repo meter. */ +.repoListLines .repoListRow{ + padding: 10px 4px; +} + +.repoListLines .repoListRow + .repoListRow{ + border-top: 1px solid var(--softer-border); +} + +.repoListBody{ + flex: 1 1 auto; + min-width: 0; +} + +.repoListTop{ + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; + margin-bottom: 5px; +} + +.repoListName{ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 500; + color: var(--fg-default); +} + +.repoListTao{ + flex: 0 0 auto; + font-size: 11px; + color: var(--accent-fg); + font-variant-numeric: tabular-nums; +} + +/* The repo's TOTAL emission, shown before the miner's share in a muted tone so + * it reads as context, distinct from the accent-colored miner share. */ +.repoListTaoRepo{ + color: var(--fg-muted); +} + +/* Under-bar row: activity badges (left) + score badge (right). */ +.repoListFoot{ + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 8px; + margin-top: 8px; +} + +.repoListFoot:empty{ + display: none; +} + +.scoreBadge{ + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border: 1px solid var(--soft-border); + border-radius: 999px; + background: var(--soft-fill); + font-size: 9.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.scoreBadgeNums{ + display: inline-flex; + align-items: baseline; +} + +.scoreBadgeVal{ + color: var(--fg-default); + font-weight: 700; +} + +.scoreBadgeLabel{ + color: var(--fg-subtle); +} + +.scoreBadgeColl{ + color: var(--attention-fg); + font-weight: 600; +} + +/* Per-repo activity — one badge per type (PRs / issues), each holding its colored + * states (open/merged/closed PRs, open/closed/completed issues). Sits in the foot + * row alongside the score badge, so spacing is handled by .repoListFoot. */ +.repoStats{ + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.statBadge{ + display: inline-flex; + align-items: center; + gap: 8px; + padding: 2px 9px; + border: 1px solid var(--soft-border); + border-radius: 999px; + background: var(--soft-fill); +} + +.statItem{ + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 9.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* Small circular avatar wrapped by credibility "spinner" ring(s). */ +.repoRing{ + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; +} + +.repoRingSvg{ + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.repoRingTrack{ + stroke: var(--bg-emphasis); +} + +.repoRingImg{ + border-radius: 50%; + object-fit: cover; + background: var(--bg-emphasis); +} + +.repoRingBadges{ + position: absolute; + right: -3px; + bottom: -4px; + display: flex; + gap: 2px; +} + +.credChip{ + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 15px; + height: 14px; + padding: 0 3px; + border: 1px solid; + border-radius: 7px; + font-size: 8.5px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +/* Dual-stream avatars carry TWO cred chips — two 3-digit pills overflow a small + * avatar when right-anchored, so center the pair and compact them to fit. */ +/* Dual-stream avatars carry both credibilities in ONE centered pill (two separate + * 3-digit chips overflow a small avatar) — PR cred and issue-discovery cred, + * color-coded, on a neutral pill. */ +.repoRingBadgesDual{ + right: auto; + left: 50%; + transform: translateX(-50%); +} + +.credChipDual{ + display: inline-flex; + align-items: center; + gap: 2px; + height: 14px; + padding: 0 2px; + border: 1px solid var(--soft-border); + border-radius: 7px; + background: var(--bg-subtle); + font-size: 7.5px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.credBadges{ + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.credBadge{ + display: inline-flex; + align-items: center; + gap: 3px; + padding: 1px 6px 1px 5px; + border: 1px solid; + border-radius: 999px; + font-size: 9.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Circular credibility ring — gray track + stream-colored arc + centered %. */ +.credRing{ + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex: 0 0 auto; +} + +.credRing svg{ + display: block; + width: 100%; + height: 100%; +} + +.credRingTrack{ + stroke: var(--bg-emphasis); +} + +/* The 0.80 eligibility marker on the ring — a small contrasting dot so you can + * see at a glance whether credibility clears the earning threshold. */ +.credRingThreshold{ + fill: var(--fg-muted); + stroke: var(--bg-subtle); + stroke-width: 0.6; +} + +.credRingPct{ + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--mono); + font-size: 8.5px; + font-weight: 600; + color: var(--fg-default); + font-variant-numeric: tabular-nums; +} + +.activityPills{ + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex-wrap: wrap; +} + +.activityPills span, +.eligibilityPill{ + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + height: 22px; + padding: 0 7px; + border: 1px solid var(--soft-border); + border-radius: 999px; + background: var(--soft-fill); + color: var(--fg-muted); + font-size: 10.5px; + font-weight: 600; + white-space: nowrap; +} + +.activityPills span:first-child{ + color: var(--success-fg); +} + +.activityPills span:nth-child(2){ + color: var(--done-fg); +} + +.eligible{ + color: var(--success-fg); + border-color: color-mix(in srgb, var(--success-fg) 38%, var(--soft-border)); + background: var(--success-subtle); +} + +.inactive{ + color: var(--fg-muted); + background: var(--bg-emphasis); +} + +/* ── List view ── */ + +.listWrap{ + display: flex; + flex-direction: column; + /* Same bordered, rounded card as the repositories table: surface fill, hairline + * border, clipped corners — the header strip + rows sit inside it. */ + background: var(--app-surface); + border: 1px solid var(--soft-border); + border-radius: 8px; + overflow: hidden; +} + +.listHeader, +.listRow{ + display: grid; + grid-template-columns: 52px minmax(0, 1.2fr) 100px 64px minmax(100px, 0.85fr) minmax(100px, 0.85fr) minmax(96px, 0.7fr) minmax(200px, 1.8fr); + align-items: center; + gap: 12px; +} + +/* Header strip — matches the repositories table header exactly. */ +.listHeader{ + padding: 10px 14px; + border-bottom: 1px solid var(--soft-border); + background: var(--app-deep); + color: var(--fg-mute); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.07em; + text-transform: uppercase; + user-select: none; +} + +.listHeadRight{ + text-align: right; +} + +/* Row chrome mirrors the repositories list — left-accent on select, hairline + * dividers, hover tint. The 3px transparent left border + 11px left padding sum + * to the header's 14px inset so columns stay aligned. */ +.listRow{ + padding: 11px 14px; + padding-left: 11px; + border-bottom: 1px solid var(--softer-border); + border-left: 3px solid transparent; + color: var(--fg-default); + cursor: pointer; + text-align: left; + transition: background 100ms, border-color 100ms; +} + +.listRow:last-child{ + border-bottom: none; +} + +.listRow:hover{ + background: var(--softer-fill); +} + +/* Keep selected / compare rows tinted even on hover (repositories-table behavior). */ +.listRowSelected:hover{ + background: var(--menu-item-hover-bg); +} + +.listRow:focus, +.listRow:focus-visible{ + outline: none; +} + +.listRowSelected{ + border-left-color: var(--accent-emphasis); + background: var(--accent-subtle); +} + +.listIdentity{ + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} + +.listIdentity img{ + width: 28px; + height: 28px; + border-radius: 999px; + border: 1px solid var(--soft-border); + object-fit: cover; + background: var(--bg-emphasis); + flex: 0 0 auto; +} + +.listIdentityText{ + min-width: 0; +} + +.listIdentityText .identityLine strong{ + max-width: 100%; + font-size: 12.5px; +} + +.listMeta{ + display: flex; + align-items: center; + gap: 6px; + margin-top: 2px; + min-width: 0; + color: var(--fg-subtle); + font-family: var(--mono); + font-size: 10px; +} + +/* Right-aligned stacked numeric cell — big value + small caption (repos style). */ +.listCell{ + min-width: 0; + text-align: right; +} + +.listCellNum{ + font-family: var(--mono); + font-size: 13px; + font-weight: 500; + color: var(--fg-default); + font-variant-numeric: tabular-nums; +} + +.listTaoNum{ + color: var(--accent-fg); +} + +.listCellSub{ + margin-top: 2px; + color: var(--fg-subtle); + font-size: 9.5px; + font-variant-numeric: tabular-nums; +} + +/* PR / issue activity columns — each holds the card's outcome triplet. */ +.listActCell{ + min-width: 0; +} + +/* Contributions column — the card's per-repo sparkline, left-aligned for the table + * (the card right-aligns it) and trimmed a touch to sit on the row. */ +.listContribCell{ + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 3px; +} + +.listContribCell .contribSpark{ + justify-content: flex-start; + height: 22px; +} + +.listContribCell .contribEmpty{ + justify-content: flex-start; + height: 22px; +} + +.listContribSub{ + color: var(--fg-subtle); + font-size: 9.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* Top-repos column — the card's RepoEmissionBar (compact) reused verbatim, so the + * ring avatars, names, τ/d and emission fill match the card exactly. */ +.listReposCol{ + min-width: 0; +} + +/* Compact top-repos (list view) — a single-line strip: a row of ring avatars, + * the #1 repo's name + τ/d, and a "+N" for the rest. Keeps the row short. */ +.repoStrip{ + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.repoStripAvatars{ + display: flex; + align-items: center; + gap: 7px; + flex: 0 0 auto; +} + +.repoStripAvatar{ + display: inline-flex; + line-height: 0; +} + +/* Smaller cred badge for the strip's compact avatars (PR/issue cred, or "-" for a + * maintained repo), pinned bottom-right by the base .repoRingBadges rule. */ +.repoStripAvatar .credChip{ + min-width: 13px; + height: 13px; + padding: 0 2px; + border-radius: 6px; + font-size: 7.5px; +} + +.repoStripName{ + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-default); + font-size: 11.5px; + font-weight: 500; +} + +.repoStripTao{ + flex: 0 0 auto; + color: var(--accent-fg); + font-family: var(--mono); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +.repoStripMore{ + flex: 0 0 auto; + color: var(--fg-subtle); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.repoMiniEmpty{ + color: var(--fg-subtle); + font-size: 11px; +} + +.listActions{ + display: inline-flex; + align-items: center; + gap: 4px; +} + +.listActions .starButton, +.listActions .starActive{ + width: 24px; + height: 24px; +} + +/* ═══════════════ DRAWER ═══════════════ */ + +@keyframes drawerIn { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +/* ── Repo network ── */ + +.trackRow{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 30px; + border-top: 1px solid var(--softer-border); + font-size: 12.5px; +} + +.trackRow strong{ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-default); + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} + +.goodValue{ + color: var(--success-fg) !important; +} + +.mutedValue{ + color: var(--fg-muted) !important; +} + +.progressBlock{ + margin: 12px 0; +} + +.progressBlock > div{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 6px; + font-size: 12px; +} + +.progressBlock strong{ + font-family: var(--mono); + font-weight: 500; +} + +.progressTrack{ + display: block; + height: 6px; + overflow: hidden; + border-radius: 999px; + background: var(--bg-emphasis); +} + +.progressTrack span{ + display: block; + height: 100%; + border-radius: inherit; +} + +.progressGreen{ + background: var(--success-emphasis); +} + +.progressPurple{ + background: var(--done-emphasis); +} + +/* ═══════════════ STATES ═══════════════ */ + +.emptyState, +.errorState{ + padding: 32px; + color: var(--fg-muted); + text-align: center; + font-size: 13px; +} + +.errorState{ + color: var(--danger-fg); + background: var(--danger-subtle); + border-bottom: 1px solid var(--danger-emphasis); +} + +.skeletonWrap{ + padding: 12px; +} + +/* ═══════════════ COMPARE TRAY + MODAL ═══════════════ */ + +.modalOuter{ + position: fixed; + inset: 0; + z-index: 200; + display: grid; + place-items: start center; + padding: 8vh 16px 16px; +} + +.modalBg{ + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); +} + +.modalBox{ + position: relative; + width: 100%; + max-width: 920px; + max-height: 84vh; + display: flex; + flex-direction: column; + background: var(--bg-emphasis); + border: 1px solid var(--soft-border); + border-radius: 12px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); + overflow: hidden; +} + +/* ═══════════════ MINER DETAIL MODAL ═══════════════ */ + +/* Wider box for the modal's 2-column body. */ +.modalBoxWide{ + max-width: 1020px; +} + +/* Header: rich identity block on the left, action cluster on the right. + * Surface dropped one step (--bg-inset) for a console title-bar feel; scoped to + * .modalBoxWide so the shared Compare modal header is untouched. */ + +/* 56px frame holding a 40px circular avatar inside dual credibility rings + * (outer = PR cred green, inner = issue cred purple). */ + +.mmMono{ + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} + +.mmNav{ + display: flex; + gap: 4px; + margin-right: 2px; +} + +/* Anchors share the square-button look the scaffold already gives buttons. */ + +/* Hero — headline emission + rank chips. A faint wash + a 2px left tick are the + * one place tinted by the miner's DOMINANT reward stream (--mm-stream, set inline); + * everything else stays grayscale / rationed indigo. */ + +/* Precision instrument readout — integer bold, fraction muted, unit as a tag. */ + +/* Rank strip as a hairline-divided instrument quad (one register, tabular). */ + +.mmFailed{ + margin: 12px 18px 0; + padding: 8px 12px; + border: 1px solid color-mix(in srgb, var(--danger-emphasis) 40%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--danger-emphasis) 12%, var(--bg-canvas)); + color: var(--danger-fg); + font-size: 12px; +} + +/* Two-column body. */ + +.mmSectionCount{ + margin-left: auto; + color: var(--fg-subtle); + font-size: 10.5px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +/* Emission breakdown — a donut centerpiece (stream-colored arcs) beside the + * τ/d + % legend kept as the data table, framed in a console field. */ + +.mmDonut{ + width: 108px; + height: 108px; + flex: 0 0 auto; +} + +.mmDonutHole{ + font-family: var(--mono); + fill: var(--fg-default); + font-variant-numeric: tabular-nums; + font-size: 15px; + font-weight: 600; +} + +.mmDonutUnit{ + fill: var(--fg-subtle); + font-size: 8px; + letter-spacing: 0.04em; +} + +.mmLegend{ + display: flex; + flex-direction: column; + gap: 5px; + min-width: 0; +} + +.mmLegendRow{ + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; +} + +.mmSwatch{ + width: 10px; + height: 10px; + border-radius: 2px; + flex: 0 0 auto; +} + +.mmLegendLabel{ + color: var(--fg-muted); +} + +.mmLegendTao{ + margin-left: auto; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + color: var(--fg-default); +} + +.mmLegendPct{ + width: 38px; + text-align: right; + font-family: var(--mono); + font-variant-numeric: tabular-nums; + color: var(--fg-subtle); +} + +.mmMuted{ + color: var(--fg-subtle); + font-size: 12px; +} + +/* Reward-stream cards (PR / issue / maintainer). */ + +/* Contributions — code volume line. */ + +.mmAdd{ + color: var(--success-fg); +} + +.mmDel{ + color: var(--danger-fg); +} + +/* Collapsible scoring internals. */ + +/* Identity. */ + +/* ── Tabs — precise underline rail (animated ::after) ─────────────── */ + +/* ── Works lists (PR / issue rows) ────────────────────────────── */ +/* One framed register; rows light a left spine in their own role/state color + * (--row-accent, set inline) on hover — semantic, not flat indigo. */ +.mmWorkList{ + display: flex; + flex-direction: column; + border: 1px solid var(--soft-border); + border-radius: 8px; + overflow: hidden; +} + +.mmWorkRow, +.mmRepoRow{ + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border-radius: 0; + color: inherit; + text-decoration: none; + border-bottom: 1px solid var(--softer-border); + box-shadow: inset 0 0 0 transparent; + transition: background 100ms, box-shadow 100ms; +} + +.mmWorkRow:last-child, +.mmRepoRow:last-child{ + border-bottom: none; +} + +.mmWorkRow:hover, +.mmRepoRow:hover{ + background: var(--softer-fill); + box-shadow: inset 2px 0 0 var(--row-accent, var(--accent-emphasis)); +} + +.mmWorkIcon{ + flex: 0 0 auto; + display: inline-flex; +} + +.mmWorkBody{ + min-width: 0; + flex: 1 1 auto; +} + +.mmWorkTitle{ + font-size: 12.5px; + color: var(--fg-default); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mmWorkMeta{ + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; + color: var(--fg-subtle); + font-size: 10.5px; +} + +.mmWorkRepo{ + font-family: var(--mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; +} + +.mmWorkNum{ + font-family: var(--mono); +} + +.mmDiff{ + display: inline-flex; + gap: 5px; + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} + +.mmWorkRight{ + display: flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; +} + +.mmWorkDate{ + width: 40px; + text-align: right; + color: var(--fg-subtle); + font-family: var(--mono); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +.mmWorkExt{ + color: var(--fg-subtle); + opacity: 0; + transition: opacity 100ms; +} + +.mmWorkRow:hover .mmWorkExt{ + opacity: 1; +} + +.mmScoreBadge{ + padding: 1px 6px; + border-radius: 5px; + background: var(--accent-subtle); + color: var(--accent-fg); + font-family: var(--mono); + font-size: 10.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +/* ── Repository rows ──────────────────────────────────────────── */ +.mmRepoRow{ + gap: 11px; +} + +.mmRepoAvatar{ + width: 28px; + height: 28px; + border-radius: 7px; + border: 1px solid var(--soft-border); + object-fit: cover; + background: var(--bg-emphasis); + flex: 0 0 auto; +} + +.mmRepoBody{ + min-width: 0; + flex: 1 1 auto; +} + +.mmRepoTop{ + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.mmRepoName{ + font-family: var(--mono); + font-size: 12.5px; + color: var(--fg-default); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mmRoleChip{ + flex: 0 0 auto; + padding: 0 6px; + border: 1px solid; + border-radius: 5px; + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.mmRepoMeta{ + display: flex; + align-items: center; + gap: 10px; + margin-top: 3px; + color: var(--fg-subtle); + font-size: 10.5px; +} + +.mmRepoMeta span{ + display: inline-flex; + align-items: center; + gap: 3px; +} + +.mmRepoTao{ + flex: 0 0 auto; + width: 84px; + text-align: right; + font-family: var(--mono); + font-size: 11.5px; + color: var(--fg-default); + font-variant-numeric: tabular-nums; +} + +/* ── Filter chips + empty state ───────────────────────────────── */ +.mmChips{ + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.mmChip{ + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + border: 1px solid var(--soft-border); + border-radius: 5px; + background: var(--bg-canvas); + color: var(--fg-muted); + font-size: 11px; + cursor: pointer; + transition: color 100ms, border-color 100ms, background 100ms, transform 120ms; +} + +.mmChip:hover{ + color: var(--fg-default); +} + +.mmChipOn{ + border-color: color-mix(in srgb, var(--accent-emphasis) 55%, transparent); + background: var(--accent-subtle); + color: var(--accent-fg); +} + +.mmChipN{ + font-family: var(--mono); + font-size: 9.5px; + color: var(--fg-subtle); + font-variant-numeric: tabular-nums; +} + +.mmChipOn .mmChipN{ + color: var(--accent-fg); +} + +.mmEmpty{ + padding: 24px 8px; + text-align: center; + color: var(--fg-subtle); + font-size: 12px; +} + +/* ── Motion + focus (surgical, reduced-motion gated). The box entrance is scoped + * to .modalBoxWide so the shared Compare modal (bare .modalBox) is untouched; the + * scrim is left un-animated for the same reason. ─────────────────── */ +@media (prefers-reduced-motion: no-preference) { + .modalBoxWide{ + animation: mmIn 160ms cubic-bezier(0.16, 1, 0.3, 1); + } + @keyframes mmIn { + from { + opacity: 0; + transform: translateY(6px) scale(0.99); + } + to { + opacity: 1; + transform: none; + } + } + .mmDonut{ + animation: mmPop 240ms ease-out both; + transform-origin: center; + } + @keyframes mmPop { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: none; + } + } + .mmChip:active{ + transform: scale(0.94); + } +} + +.mmChip:focus-visible{ + outline: 1px solid var(--accent-emphasis); + outline-offset: 2px; + border-radius: inherit; +} + +@media (max-width: 760px) { + .modalBoxWide{ + max-height: 92vh; + } + .mmWorkRepo{ + max-width: 130px; + } + .mmRepoTao{ + width: 64px; + } + .mmWorkDate{ + width: 36px; + } +} + +/* ═══════════════ MINER DASHBOARD MODAL (2-pane: sidebar + cards) ═══════════════ */ + +/* Shell — near-fullscreen; .mmShell rides alongside .modalBox/.modalBoxWide so no + * bare .modalBox rule is touched (Compare modal stays safe). */ +.mmShell{ + max-width: 1180px; + width: min(94vw, 1180px); + height: 88vh; + max-height: 88vh; + padding: 0; + border: none; +} +.mmShellMax{ + width: 98vw; + max-width: 98vw; + height: 96vh; + max-height: 96vh; +} + +/* Center the near-fullscreen dashboard with minimal gutter (only when this modal + * is open; the shared Compare modal keeps the default top-aligned padding). */ +.modalOuter:has(.mmShell){ + padding: 2vh 12px; + place-items: center; +} + +.mmTopBar{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--soft-border); + /* A subtle surface one step up from the page — --bg-subtle is #101113 in dark, #f9fafb + * in light. The old --bg-inset sank to the page's own value in dark mode and the bar + * vanished; this sits lighter than the page yet darker than the card body, so it reads + * as a distinct toolbar band, with the border below dividing it from the content. */ + background: var(--bg-subtle); + flex: 0 0 auto; +} +.mmTopActions{ + display: flex; + align-items: center; + gap: 6px; +} +.mmTopBar button{ + display: grid; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--soft-border); + border-radius: 6px; + background: var(--bg-canvas); + color: var(--fg-muted); + cursor: pointer; + transition: color 100ms, background 100ms, border-color 100ms, transform 120ms; +} +.mmTopBar button:hover{ + color: var(--fg-default); + /* A step lighter than the --bg-subtle bar, so the button lifts on hover and never + * matches the bar's surface. */ + background: var(--bg-emphasis); +} +.mmTopBar button:disabled{ + opacity: 0.4; + cursor: default; +} +.mmTopBar button:focus-visible{ + outline: 1px solid var(--accent-emphasis); + outline-offset: 2px; +} +/* Tracked state — gold star, so the toggle reads as "favorited". */ +.mmTopBar button.mmTopStarOn, +.mmTopBar button.mmTopStarOn:hover{ + color: var(--attention-fg); + border-color: color-mix(in srgb, var(--attention-emphasis) 45%, transparent); +} + +.mmGrid{ + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + flex: 1 1 auto; + min-height: 0; +} + +/* ── Sidebar ──────────────────────────────────────────────────── */ +.mmSide{ + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--soft-border); + background: var(--bg-subtle); +} +.mmSideScroll{ + overflow-y: auto; + padding: 22px 18px 18px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} +.mmSideAvatarWrap{ + position: relative; + width: 92px; + height: 92px; + display: grid; + place-items: center; + margin-bottom: 12px; +} +.mmSideAvatarRing{ + position: absolute; + inset: 0; + border-radius: 50%; + padding: 3px; + background: conic-gradient( + from 200deg, + var(--accent-emphasis), + color-mix(in srgb, var(--accent-emphasis) 35%, transparent), + var(--accent-emphasis) + ); + box-shadow: 0 0 0 1px var(--soft-border), 0 0 18px var(--accent-glow); + -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px)); + mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px)); +} +.mmSideAvatar{ + width: 78px; + height: 78px; + border-radius: 50%; + object-fit: cover; + background: var(--bg-emphasis); +} +.mmSideStatus{ + position: absolute; + top: 6px; + right: 6px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--dot, var(--fg-subtle)); + border: 2px solid var(--bg-subtle); + box-shadow: 0 0 8px color-mix(in srgb, var(--dot, var(--fg-subtle)) 70%, transparent); +} +.mmSideNameRow{ + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + max-width: 100%; +} +.mmSideName{ + font-size: 17px; + font-weight: 650; + color: var(--fg-default); + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mmSideNameGh{ + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--fg-muted); + transition: color 0.15s ease; +} +.mmSideNameGh:hover{ + color: var(--fg-default); +} +.mmSidePoints{ + display: inline-flex; + align-items: baseline; + gap: 5px; + margin-top: 12px; + padding: 6px 12px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--accent-emphasis) 45%, transparent); + background: var(--accent-subtle); + color: var(--accent-fg); + font-family: var(--mono); + font-variant-numeric: tabular-nums; +} +.mmSidePoints svg{ + align-self: center; +} +.mmSidePoints strong{ + font-size: 16px; + font-weight: 700; + letter-spacing: -0.01em; +} +.mmSidePointsUnit{ + font-size: 10px; + letter-spacing: 0.02em; + opacity: 0.8; +} +.mmSideDivider{ + width: 100%; + height: 1px; + background: var(--soft-border); + margin: 16px 0; +} +/* Volume at a glance — PRs / issues / repos as three compact cells. */ +.mmSideGrid{ + width: 100%; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 7px; + margin-bottom: 14px; +} +.mmSideCell{ + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + padding: 9px 4px; + border: 1px solid var(--soft-border); + border-radius: 10px; + background: var(--bg-canvas); +} +.mmSideCell strong{ + font-size: 16px; + font-weight: 650; + line-height: 1.15; + color: var(--fg-default); + font-variant-numeric: tabular-nums; +} +.mmSideCell span{ + font-size: 10px; + letter-spacing: 0.02em; + color: var(--fg-muted); +} +/* The remaining stats — a tidy hairline-divided register. */ +.mmSideStats{ + width: 100%; + display: flex; + flex-direction: column; + margin: 0; +} +.mmSideStat{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 7px 0; + font-size: 12px; + border-bottom: 1px solid var(--softer-border); +} +.mmSideStat:last-child{ + border-bottom: none; +} +.mmSideStat dt{ + color: var(--fg-subtle); + margin: 0; +} +.mmSideStat dd{ + margin: 0; + color: var(--fg-default); + font-weight: 600; + text-align: right; +} +.mmSidePos{ + color: var(--success-fg); +} +.mmSideBio{ + width: 100%; + margin: 8px 0 0; + color: var(--fg-muted); + font-size: 12px; + line-height: 1.5; + text-align: center; +} +/* Tenure ribbon — a diagonal corner banner in the modal's top-left, coloured by SN74 + * working-age tier (grey → blue → green → purple → gold). The modal box's overflow:hidden + * + rounded corner clip it cleanly into the corner; pointer-events:none keeps the nav + * buttons beneath it clickable. The star + uppercase tracking give it a "rank" feel. */ +.mmRibbon{ + position: absolute; + bottom: 22px; + left: -36px; + z-index: 6; + width: 142px; + padding: 3px 0; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + transform: rotate(45deg); + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #fff; + pointer-events: none; + box-shadow: 0 2px 9px rgba(0, 0, 0, 0.45); +} +.mmRibbon svg{ + flex-shrink: 0; + margin-top: -1px; +} +.mmTenureNew{ + background: var(--neutral-emphasis); +} +.mmTenureRookie{ + background: var(--accent-emphasis); +} +.mmTenureRegular{ + background: var(--success-emphasis); +} +.mmTenureVeteran{ + background: var(--done-emphasis); +} +.mmTenurePioneer{ + background: var(--attention-emphasis); + color: #1c1500; +} +.mmSideFollow{ + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 8px; + color: var(--fg-muted); + font-size: 12px; +} +.mmSideFollow svg{ + color: var(--fg-subtle); + flex: 0 0 auto; +} +.mmSideFollow strong{ + color: var(--fg-default); + font-weight: 600; +} +.mmSideFollowSep{ + color: var(--fg-subtle); +} + +/* ── Main column + cards ──────────────────────────────────────── */ +.mmMain{ + min-width: 0; + min-height: 0; + overflow-y: auto; + padding: 18px 18px 24px; + background: var(--bg-canvas); + display: flex; + flex-direction: column; + gap: 16px; +} +.mmBottomRow{ + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + min-width: 0; +} + +/* Top-level dashboard tabs (Overview / Contributions). */ +.mmMainTabs{ + display: flex; + gap: 20px; + border-bottom: 1px solid var(--soft-border); +} +.mmMainTab{ + position: relative; + border: none; + background: none; + padding: 8px 2px; + margin-bottom: -1px; + color: var(--fg-muted); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: color 100ms; +} +.mmMainTab::after{ + content: ''; + position: absolute; + left: 0; + bottom: -1px; + height: 2px; + width: 0; + background: var(--accent-emphasis); + transition: width 140ms ease; +} +.mmMainTab:hover{ + color: var(--fg-default); +} +.mmMainTabOn{ + color: var(--fg-default); +} +.mmMainTabOn::after{ + width: 100%; +} +.mmMainTab:focus-visible{ + outline: 1px solid var(--accent-emphasis); + outline-offset: 2px; + border-radius: 4px; +} + +.mmCard{ + min-width: 0; + padding: 16px; + border: 1px solid var(--soft-border); + border-radius: 16px; + background: var(--bg-inset); +} +.mmCardHead{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} +.mmCardTitle{ + display: flex; + align-items: center; + gap: 7px; + margin: 0; + color: var(--fg-default); + font-size: 13px; + font-weight: 600; +} +.mmCardTitle svg{ + color: var(--fg-subtle); +} +/* Insights & next actions — Linear-style cards: a subtle elevated surface with a + * clean 1px border; the semantic colour lives only in a soft icon badge, keeping the + * row calm and scannable. Derived from the miner's per-repo signals. */ +.mmInsights{ + display: flex; + flex-direction: column; + gap: 8px; +} +.mmInsight{ + display: flex; + align-items: flex-start; + gap: 11px; + padding: 12px 13px; + border: 1px solid var(--border-default); + border-radius: 10px; + background: var(--bg-subtle); + transition: border-color 120ms ease; +} +.mmInsight:hover{ + border-color: var(--border-strong); +} +.mmInsightIcon{ + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: 7px; +} +.mmInsightText{ + min-width: 0; +} +.mmInsightTitle{ + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--fg-default); +} +.mmInsightBody{ + margin-top: 2px; + font-size: 12px; + line-height: 1.5; + color: var(--fg-muted); +} +.mmInsightStrong .mmInsightIcon{ + color: var(--success-fg); + background: color-mix(in srgb, var(--success-emphasis) 15%, transparent); +} +.mmInsightWarn .mmInsightIcon{ + color: var(--danger-fg); + background: color-mix(in srgb, var(--danger-emphasis) 16%, transparent); +} +.mmInsightAction .mmInsightIcon{ + color: var(--attention-fg); + background: color-mix(in srgb, var(--attention-emphasis) 16%, transparent); +} +.mmInsightInfo .mmInsightIcon{ + color: var(--accent-fg); + background: color-mix(in srgb, var(--accent-emphasis) 15%, transparent); +} +.mmInfo{ + display: inline-flex; + color: var(--fg-subtle); + cursor: help; +} + +/* Range tabs (timeline) */ + +.mmTlEmpty{ + display: grid; + place-items: center; + height: 216px; + border: 1px dashed var(--soft-border); + border-radius: 12px; + color: var(--fg-subtle); + font-size: 12px; +} + +/* Right-side card controls (repo dropdown + filter tabs). */ +.mmCardControls{ + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +/* Custom repository dropdown (avatar + per-repo τ/day + score), matching the + * app's Dropdown chrome. */ +.mmRepoDd{ + position: relative; + display: inline-flex; +} +.mmRepoDdTrigger{ + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 260px; + height: 30px; + padding: 0 8px 0 7px; + border: 1px solid var(--soft-border); + border-radius: 7px; + background: var(--bg-canvas); + color: var(--fg-default); + font-size: 11.5px; + cursor: pointer; + transition: border-color 100ms, box-shadow 100ms; +} +.mmRepoDdTrigger:hover{ + border-color: var(--border-strong); +} +.mmRepoDdTrigger[aria-expanded='true'], +.mmRepoDdTrigger:focus-visible{ + outline: none; + border-color: var(--accent-emphasis); + box-shadow: 0 0 0 3px var(--accent-glow); +} +.mmRepoDdAvatar{ + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid var(--soft-border); + object-fit: cover; + background: var(--bg-emphasis); + flex: 0 0 auto; +} +.mmRepoDdName{ + font-family: var(--mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mmRepoDdChevron{ + color: var(--fg-subtle); + flex: 0 0 auto; +} +.mmRepoDdMenu{ + /* position/top/left/width/max-height are set inline (body portal, fixed). */ + z-index: 9600; + overflow-y: auto; + padding: 4px; + background: var(--bg-subtle); + border: 1px solid var(--border-default); + border-radius: 8px; + box-shadow: var(--shadow-overlay); +} +.mmRepoDdOpt{ + display: flex; + align-items: center; + gap: 11px; + width: 100%; + padding: 8px 10px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--fg-default); + text-align: left; + cursor: pointer; + transition: background 80ms; +} +.mmRepoDdOpt:hover{ + background: var(--menu-item-hover-bg); +} +.mmRepoDdOptOn{ + background: var(--accent-subtle); +} +.mmRepoDdInfo{ + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} +.mmRepoDdRepoText{ + font-family: var(--mono); + font-size: 12.5px; + font-weight: 600; + color: var(--fg-default); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mmRepoDdOwner{ + color: var(--fg-muted); + font-weight: 400; +} +.mmRepoDdMeta{ + display: flex; + align-items: center; + gap: 6px; + color: var(--fg-subtle); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} +.mmRepoDdScore{ + color: var(--fg-muted); +} +.mmRepoDdMetaSep{ + opacity: 0.45; +} +.mmRepoDdValue{ + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1px; + padding-left: 8px; +} +.mmRepoDdValue strong{ + font-family: var(--mono); + font-size: 13px; + font-weight: 650; + line-height: 1.1; + color: var(--accent-fg); + font-variant-numeric: tabular-nums; +} +.mmRepoDdValueZero strong{ + color: var(--fg-subtle); + font-weight: 500; +} +.mmRepoDdValueUnit{ + font-size: 9px; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--fg-subtle); +} + +/* Filter tabs (table) */ +.mmCardTabs{ + display: flex; + gap: 14px; +} +.mmCardTab{ + position: relative; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 1px; + border: none; + background: none; + color: var(--fg-muted); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: color 100ms; +} +.mmCardTab::after{ + content: ''; + position: absolute; + left: 0; + bottom: -1px; + height: 2px; + width: 0; + background: var(--accent-emphasis); + transition: width 140ms ease; +} +.mmCardTab:hover{ + color: var(--fg-default); +} +.mmCardTabOn{ + color: var(--fg-default); +} +.mmCardTabOn::after{ + width: 100%; +} +.mmCardTabN{ + min-width: 16px; + padding: 0 5px; + border-radius: 5px; + text-align: center; + background: var(--soft-fill); + color: var(--fg-subtle); + font-family: var(--mono); + font-size: 9.5px; + font-variant-numeric: tabular-nums; +} +.mmCardTabOn .mmCardTabN{ + background: var(--accent-subtle); + color: var(--accent-fg); +} + +/* Works table */ +.mmTableWrap{ + overflow-x: auto; + overflow-y: auto; + max-height: 420px; + scrollbar-width: thin; +} +/* Contributions list — the card fills the main pane (no floating void): head + foot + * stay put and the table scrolls in the space between (single scroll, not two). */ +.mmCardFill{ + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} +.mmCardFill .mmTableWrap{ + flex: 1 1 auto; + min-height: 0; + max-height: none; +} +.mmCardFill .mmEmpty{ + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; +} +.mmCardFill .mmCardFoot{ + flex: 0 0 auto; +} +.mmTable{ + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} +.mmTable thead th{ + position: sticky; + top: 0; + z-index: 1; + padding: 7px 12px; + text-align: left; + background: var(--bg-inset); + border-bottom: 1px solid var(--soft-border); + color: var(--fg-subtle); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} +/* Fluid columns (percentages) so the table fills the panel at any width — no dead + * zone when maximized, nothing clipped — while keeping the normal-size proportions. */ +.mmThState{ width: 12%; } +.mmThTitle{ width: 46%; } +.mmThScore{ + width: 9%; + text-align: right; +} +.mmThCreated, +.mmThUpdated{ width: 12%; } +.mmTr{ + cursor: pointer; + transition: background 100ms; +} +.mmTr:hover{ + background: var(--softer-fill); +} +.mmTr:hover .mmTitleText{ + color: var(--accent-fg); +} +.mmTr:focus-visible{ + outline: 1px solid var(--accent-emphasis); + outline-offset: -1px; +} +.mmTable tbody td{ + padding: 9px 12px; + border-bottom: 1px solid var(--softer-border); + font-size: 12.5px; + color: var(--fg-default); + vertical-align: middle; +} +.mmTable tbody tr:last-child td{ + border-bottom: none; +} +.mmTdTitle{ + min-width: 0; +} +.mmTitleLink{ + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + color: inherit; + text-decoration: none; +} +.mmTitleText{ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mmTitleLink:hover .mmTitleText{ + color: var(--accent-fg); + text-decoration: underline; + text-underline-offset: 2px; +} +.mmTitleNum{ + flex: 0 0 auto; + color: var(--fg-subtle); + font-family: var(--mono); + font-size: 11px; + font-variant-numeric: tabular-nums; +} +.mmPill{ + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 999px; + color: #fff; + font-size: 11.5px; + font-weight: 600; + white-space: nowrap; + line-height: 1.5; +} +.mmTdCreated, +.mmTdUpdated{ + color: var(--fg-subtle); + font-family: var(--mono); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +/* Base (at merge) and Live (time-decayed) per-PR score columns. */ +.mmTdScore{ + text-align: right; + font-family: var(--mono); + font-size: 11.5px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +/* Base = muted baseline number (the score at merge). */ +.mmScoreBase{ + color: var(--fg-subtle); +} +/* Current = a freshness-tinted chip, so the score columns read as badges (distinct + * from the plain date text) and the color shows how much value has decayed. */ +.mmScoreChip{ + display: inline-flex; + align-items: center; + justify-content: flex-end; + min-width: 48px; + padding: 1px 7px; + border-radius: 6px; + font-weight: 600; + line-height: 1.55; +} +.mmScoreFresh{ + background: var(--success-subtle); + color: var(--success-fg); +} +.mmScoreFading{ + background: var(--attention-subtle); + color: var(--attention-fg); +} +.mmScoreStale{ + background: var(--neutral-subtle); + color: var(--fg-muted); +} +/* Recent "Updated" highlight — mirrors the explorer's RecentTime. */ +.mmRecent{ + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--success-fg); + font-weight: 700; +} +.mmRecentDot{ + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success-emphasis); + display: inline-block; + animation: gtPulse 1.6s ease-in-out infinite; +} +.mmCardFoot{ + display: flex; + justify-content: center; + margin-top: 8px; + border-top: 1px solid var(--softer-border); +} +.mmViewAll{ + display: inline-flex; + align-items: center; + gap: 5px; + border: none; + background: none; + color: var(--accent-fg); + font-size: 11.5px; + font-weight: 500; + cursor: pointer; + transition: opacity 100ms; + padding-top: 10px; +} +.mmViewAll:hover{ + opacity: 0.78; + text-decoration: underline; + text-underline-offset: 2px; +} +.mmCardTab:focus-visible, +.mmViewAll:focus-visible, +.mmTitleLink:focus-visible{ + outline: 1px solid var(--accent-emphasis); + outline-offset: 2px; + border-radius: 6px; +} + +/* Row-detail (master-detail) view inside the works card. */ +.mmDetail{ + display: flex; + flex-direction: column; + gap: 12px; +} +.mmDetailTitle{ + margin: 0; + font-size: 15px; + font-weight: 600; + line-height: 1.4; + color: var(--fg-default); +} +/* The title is the link to GitHub: underline the text on hover, with a trailing + * external-link icon at the end of the title. */ +.mmDetailTitleLink{ + color: inherit; + text-decoration: none; +} +.mmDetailTitleLink:hover .mmDetailTitleText{ + text-decoration: underline; + text-underline-offset: 2px; +} +.mmDetailTitleIcon{ + margin-left: 5px; + color: var(--fg-subtle); + vertical-align: middle; +} +.mmDetailTitleLink:hover .mmDetailTitleIcon{ + color: var(--accent-fg); +} +.mmDetailBody{ + margin: 0; + max-height: 320px; + overflow: auto; + padding: 12px; + border: 1px solid var(--soft-border); + border-radius: 10px; + background: var(--bg-canvas); + color: var(--fg-muted); + font-family: inherit; + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} +/* "View on GitHub" link under an issue's description. */ +.mmDetailGh{ + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + border: 1px solid var(--soft-border); + border-radius: 8px; + background: var(--bg-canvas); + color: var(--fg-default); + font-size: 12px; + font-weight: 600; + transition: color 100ms, border-color 100ms, background 100ms; +} +.mmDetailGh:hover{ + color: var(--accent-fg); + border-color: var(--accent-emphasis); + background: var(--soft-fill); +} +.mmDetailGh:focus-visible{ + outline: 1px solid var(--accent-emphasis); + outline-offset: 2px; +} + +/* ── Rich PR detail (scoring) ──────────────────────────────────── */ +.mmDetailTop{ + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.mmDetailBackBtn{ + display: grid; + place-items: center; + width: 28px; + height: 28px; + flex: 0 0 auto; + border: 1px solid var(--soft-border); + border-radius: 7px; + background: var(--bg-canvas); + color: var(--fg-muted); + cursor: pointer; + transition: color 100ms, background 100ms, border-color 100ms; +} +.mmDetailBackBtn:hover{ + color: var(--fg-default); + background: var(--bg-emphasis); +} +.mmDetailBackBtn:focus-visible{ + outline: 1px solid var(--accent-emphasis); + outline-offset: 2px; +} +.mmDetailAvatar{ + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--soft-border); + object-fit: cover; + background: var(--bg-emphasis); + flex: 0 0 auto; +} +.mmDetailNum{ + font-family: var(--mono); + font-size: 14px; + font-weight: 700; + color: var(--fg-default); +} +.mmDetailScore{ + margin-left: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + line-height: 1.1; +} +.mmDetailScore > span{ + color: var(--fg-subtle); + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.mmDetailScore strong{ + font-family: var(--mono); + font-size: 22px; + font-weight: 700; + color: var(--fg-default); + font-variant-numeric: tabular-nums; +} +.mmDetailRepo{ + font-family: var(--mono); + font-size: 12px; + color: var(--fg-subtle); +} +.mmDetailRepo:hover{ + color: var(--accent-fg); + text-decoration: underline; + text-underline-offset: 2px; +} +.mmDetailChips{ + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.mmDetailChip{ + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + border: 1px solid var(--soft-border); + border-radius: 7px; + background: var(--bg-canvas); + color: var(--fg-muted); + font-size: 11px; +} +a.mmDetailChip:hover{ + color: var(--fg-default); + border-color: var(--border-strong); +} +.mmDetailChip svg{ + color: var(--fg-subtle); +} + +/* Detail sub-tabs (Overview / Files / Conversation) */ + +.mmOverview{ + display: flex; + flex-direction: column; + gap: 16px; +} +.mmDetailLabel{ + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fg-subtle); +} + +/* Time-decay card */ +.mmDecayCard{ + padding: 14px; + border: 1px solid var(--soft-border); + border-radius: 12px; + background: var(--bg-canvas); +} +.mmDecayHead{ + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} +.mmDecayMult{ + font-family: var(--mono); + font-size: 13px; + font-weight: 700; + color: var(--fg-default); + font-variant-numeric: tabular-nums; +} +.mmDecayMult em{ + color: var(--fg-subtle); + font-style: normal; + font-weight: 500; + font-size: 11px; +} +/* Bordered chart container — matches the Overview EarningForecastChart. */ +.mmDecayPlot{ + width: 100%; + overflow: hidden; + border: 1px solid var(--border-muted); + border-radius: 6px; +} +.mmDecaySvg{ + display: block; + width: 100%; + height: 220px; +} +/* Hover tooltip — mirrors the Overview forecast chart's readout. */ +.mmDecayTip{ + background: color-mix(in srgb, var(--bg-overlay) 92%, transparent); + backdrop-filter: blur(8px); + border: 1px solid var(--border-default); + border-radius: 8px; + padding: 7px 9px; +} +.mmDecayTipHead{ + margin-bottom: 6px; + color: var(--fg-default); + font-size: 11px; + font-weight: 700; +} +.mmDecayTipMuted{ + color: var(--fg-muted); + font-weight: 400; + font-style: italic; +} +.mmDecayTipRow{ + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; +} +.mmDecayTipRow + .mmDecayTipRow{ + margin-top: 4px; +} +.mmDecayTipDot{ + width: 8px; + height: 8px; + border-radius: 99px; + flex-shrink: 0; +} +.mmDecayTipKey{ + flex: 1; + color: var(--fg-muted); +} +.mmDecayTipVal{ + color: var(--fg-default); + font-family: var(--mono); + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +/* Scoring breakdown + token donut */ +.mmScoreGrid{ + display: grid; + grid-template-columns: minmax(0, 1fr) 200px; + gap: 16px; + align-items: start; +} +.mmBreak{ + display: flex; + flex-direction: column; + border: 1px solid var(--soft-border); + border-radius: 12px; + overflow: hidden; +} +.mmBreakRow{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 13px; + border-bottom: 1px solid var(--softer-border); + font-size: 12.5px; +} +.mmBreakRow:last-child{ + border-bottom: none; +} +.mmBreakRow > span{ + color: var(--fg-subtle); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.mmBreakRow strong{ + color: var(--fg-default); + font-weight: 600; + font-variant-numeric: tabular-nums; + text-align: right; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mmTokenCard{ + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 14px; + border: 1px solid var(--soft-border); + border-radius: 12px; + background: var(--bg-canvas); +} +.mmTokenCard .mmDetailLabel{ + align-self: flex-start; +} +.mmTokenLegend{ + display: flex; + gap: 14px; + color: var(--fg-muted); + font-size: 11px; +} +.mmTokenLegend > span{ + display: inline-flex; + align-items: center; + gap: 5px; +} +.mmConvPanel{ + display: flex; + flex-direction: column; + gap: 12px; +} + +@media (max-width: 760px) { + .mmScoreGrid{ + grid-template-columns: 1fr; + } +} + +/* Heatmap internals (frame = .mmCard) */ +.mmHeatScroll{ + overflow-x: auto; + overflow-y: hidden; + margin-top: 2px; + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} +.mmHeatScroll::-webkit-scrollbar{ + height: 6px; +} +.mmHeatScroll::-webkit-scrollbar-thumb{ + background: var(--border-strong); + border-radius: 999px; +} +.mmHeatSvg{ + display: block; + width: 100%; + height: auto; +} +.mmHeatLegend{ + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + margin-top: 10px; + color: var(--fg-subtle); + font-size: 10.5px; +} +.mmHeatSwatch{ + width: 11px; + height: 11px; + border-radius: 2.5px; + border: 1px solid var(--soft-border); + flex: 0 0 auto; +} + +/* Emission card internals (frame = .mmCard) */ +.mmStreamsCard{ + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} +.mmStreamsTag{ + margin-left: auto; + flex: 0 0 auto; + padding: 1px 8px; + border: 1px solid var(--soft-border); + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.mmStreamsGrid{ + display: grid; + grid-template-columns: 108px 1fr; + gap: 16px; + align-items: center; +} +/* RESPONSIVE — collapse to single column under 860px; mobile full sheet */ +@media (max-width: 860px) { + .modalOuter:has(.mmShell){ + padding: 0; + place-items: stretch; + } + .mmShell, +.mmShellMax{ + width: 100vw; + max-width: 100vw; + height: 100vh; + max-height: 100vh; + border-radius: 0; + } + .mmGrid{ + grid-template-columns: 1fr; + overflow-y: auto; + } + .mmSide{ + border-right: none; + border-bottom: 1px solid var(--soft-border); + } + .mmSideScroll{ + padding: 16px 16px 14px; + } + .mmMain{ + overflow-y: visible; + } + .mmBottomRow{ + grid-template-columns: 1fr; + } + /* No definite main height on mobile (the grid scrolls) — let the table card flow + * naturally and the inner table keep a bounded scroll. */ + .mmCardFill{ + flex: 0 0 auto; + display: block; + } + .mmCardFill .mmTableWrap{ + flex: initial; + max-height: 62vh; + } +} +@media (max-width: 760px) { + .mmCard{ + padding: 13px; + border-radius: 12px; + } + .mmStreamsGrid{ + grid-template-columns: 1fr; + justify-items: center; + } + .mmStreamsGrid .mmLegend{ + width: 100%; + } +} +@media (max-width: 640px) { + /* Narrow viewports: don't squish or drop columns — keep every column at a readable, + * fixed width and let the table overflow so the wrap (overflow-x: auto) scrolls + * horizontally to reveal the full row. Title takes whatever width is left. */ + .mmTable{ + min-width: 620px; + } + .mmThState{ + width: 92px; + } + .mmThTitle{ + width: auto; + } + .mmThScore{ + width: 60px; + } + .mmThCreated, +.mmThUpdated{ + width: 78px; + } +} +@media (prefers-reduced-motion: no-preference) { + .mmTopBar button:active{ + transform: scale(0.94); + } +} + +/* ═══════════════ PALETTE ═══════════════ */ + +.paletteOuter{ + position: fixed; + inset: 0; + z-index: 210; + display: none; +} + +.paletteOpen{ + display: block; +} + +.paletteBg{ + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); +} + +.paletteBox{ + position: relative; + max-width: 38rem; + margin: 13vh auto 0; + background: var(--bg-emphasis); + border: 1px solid var(--soft-border); + border-radius: 12px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); + overflow: hidden; +} + +.paletteHeader{ + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--soft-border); +} + +.paletteInput{ + flex: 1; + background: transparent; + border: 0; + outline: none; + font: inherit; + font-size: 14px; + color: var(--fg); + box-shadow: none !important; +} + +.paletteInput::placeholder{ + color: var(--fg-mute); +} + +.paletteResults{ + max-height: 52vh; + overflow-y: auto; + padding: 6px; +} + +.paletteEmpty{ + padding: 32px 12px; + text-align: center; + font-size: 12.5px; + color: var(--fg-subtle); +} + +.paletteItem{ + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-radius: 8px; + text-align: left; + background: transparent; + border: 0; + cursor: pointer; + color: inherit; +} + +.paletteItem img{ + width: 30px; + height: 30px; + border-radius: 6px; + border: 1px solid var(--soft-border); + object-fit: cover; + flex: 0 0 auto; +} + +.paletteItemActive{ + background: var(--soft-fill); +} + +.paletteItemText{ + min-width: 0; + flex: 1; +} + +/* Name row: login (truncates) + the reward-stream swatch(es) on its right. */ +.paletteItemName{ + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + font-size: 13px; + font-weight: 500; +} + +.paletteItemLogin{ + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Value row: each value (uid · score · top repo) is a subtle chip badge. */ +.paletteItemSub{ + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + min-width: 0; + overflow: hidden; +} + +.badge{ + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + padding: 1px 7px; + border-radius: 5px; + background: color-mix(in srgb, var(--fg-default) 7%, transparent); + border: 1px solid color-mix(in srgb, var(--fg-default) 8%, transparent); + color: var(--fg-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* The top-repo badge can shrink + ellipsis; uid/score stay their natural size. */ +.badgeRepo{ + flex: 0 1 auto; + min-width: 0; +} + +.badgeRepoName{ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Fire icon marks the miner's hottest (top) repo. */ +.badgeFire{ + display: inline-flex; + align-items: center; + color: var(--attention-fg); +} + +/* Reward-stream swatches beside the name: a small rounded rectangle per stream + (green = PRs, indigo = issue discovery). */ +.streamTags{ + display: inline-flex; + align-items: center; + gap: 3px; + flex: 0 0 auto; +} + +.streamTag{ + display: block; + width: 14px; + height: 9px; + border-radius: 2px; +} + +/* Earnings stacked at the right: USD/day (primary) over TAO/day (native). */ +.paletteItemMetrics{ + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 1px; +} + +.paletteItemUsd{ + font-family: var(--mono); + font-size: 12px; + color: var(--success-fg); + font-variant-numeric: tabular-nums; +} + +.paletteItemTao{ + font-family: var(--mono); + font-size: 10.5px; + color: var(--fg-subtle); + font-variant-numeric: tabular-nums; +} + +/* ═══════════════ RESPONSIVE ═══════════════ */ + +@media (max-width: 1280px) { + .minerGrid{ + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .listColHideLg{ + display: none; + } + .listHeader, +.listRow{ + grid-template-columns: 52px minmax(0, 1.2fr) 100px 64px minmax(104px, 0.9fr) minmax(104px, 0.9fr); + } +} + +@media (max-width: 980px) { + .minerGrid{ + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .listColHideMd{ + display: none; + } + /* Visible after hiding md+lg cols: actions · miner · emission · score. */ + .listHeader, +.listRow{ + grid-template-columns: 52px minmax(0, 1fr) 100px 64px; + } +} + +@media (max-width: 760px) { + .page{ + padding: 18px 12px; + padding-bottom: calc(var(--bottom-nav-height) + 18px); + } + + .emissionStats{ + width: 100%; + justify-content: flex-start; + } + + .emissionStat{ + text-align: left; + } + + .headlineStage{ + padding: 4px 0 10px; + } + + .treeInspector{ + align-items: flex-start; + } + + /* Banner wraps on mobile — the vertical divider would dangle, so drop it. */ + .treeDivider{ + display: none; + } + + .treeInspectorStats{ + border: 0; + padding: 0; + margin: 0; + } + + .treeInspectorOpen{ + margin-left: 0; + } + + .toolbar{ + grid-template-columns: 1fr 1fr; + } + + .toolbarRight{ + grid-column: 1 / -1; + justify-self: stretch; + justify-content: space-between; + } + + + + .minerGrid{ + grid-template-columns: 1fr; + } + + .listColHideSm{ + display: none; + } + + .listHeader, +.listRow{ + grid-template-columns: 48px minmax(0, 1fr) 92px; + gap: 8px; + } + + .listHeader{ + padding: 0 10px; + } + + .listRow{ + padding: 10px 10px; + padding-left: 7px; + } + + + } + +@media (max-width: 520px) { + .emissionStats{ + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + } + + + + + + } + +/* ═══════════════ Miner earnings distribution (header right panel) ═══════════════ */ +.distPanel{ + flex: 1 1 360px; + min-width: 0; + display: flex; + flex-direction: column; + padding: 12px 14px 11px; + border: 1px solid var(--soft-border); + border-radius: 12px; + background: var(--bg-subtle); +} + +.distHead{ + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--fg-subtle); +} + +.distCount{ + font-weight: 500; + letter-spacing: 0; + text-transform: none; + color: var(--fg-muted); +} + +/* Histogram — bars sized by miner count per $/day bucket; bars animate as the + * feed refreshes. */ +.histo{ + flex: 1 1 auto; + display: flex; + align-items: flex-end; + gap: 6px; + height: 104px; + margin-top: 12px; +} + +.histoCol{ + position: relative; + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + height: 100%; + justify-content: flex-end; + outline: none; +} + +.histoCol[tabindex='0']{ + cursor: pointer; +} + +/* Hover/focus highlight band — a subtle full-height column behind the bar + * (stops above the x-axis label), like a chart's column hover state. */ +.histoCol::before{ + content: ''; + position: absolute; + left: -2px; + right: -2px; + top: -6px; + bottom: 16px; + border-radius: 6px; + background: color-mix(in srgb, var(--fg-default) 6%, transparent); + opacity: 0; + transition: opacity 120ms ease; + pointer-events: none; +} +.histoCol:hover::before, +.histoCol:focus::before{ + opacity: 1; +} + +.histoBar{ + position: relative; + z-index: 1; + width: 100%; + min-height: 2px; + border-radius: 4px 4px 0 0; + background: var(--accent-emphasis); + transition: height 420ms cubic-bezier(0.22, 1, 0.36, 1), background 120ms ease; +} +.histoBar[data-active]{ + background: color-mix(in srgb, var(--accent-emphasis) 82%, var(--fg-default)); +} + +.histoBar em{ + position: absolute; + top: -14px; + left: 0; + right: 0; + text-align: center; + font-style: normal; + font-family: var(--mono); + font-size: 9.5px; + color: var(--fg-muted); + font-variant-numeric: tabular-nums; +} + +.histoLabel{ + font-size: 9px; + color: var(--fg-subtle); + white-space: nowrap; +} + +/* ── Bucket popover: the miners in a hovered $/day bar ── */ +.distPop{ + position: absolute; + /* Open DOWNWARD — the panel sits near the top of the page, so opening upward + * clips the popover against the viewport. Flush to the column bottom (no gap) + * so moving into the popover doesn't trip the hover-out. */ + top: 100%; + z-index: 60; + width: 234px; + max-width: 78vw; + padding: 9px 10px 7px; + border-radius: 10px; + border: 1px solid var(--border-strong); + background: var(--bg-overlay); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5); + cursor: default; +} +/* Anchor to the hovered column's edge so the popover never overflows the panel: + * left-half bars grow rightward, right-half bars grow leftward. */ +.distPopLeft{ + left: 0; +} +.distPopRight{ + right: 0; +} +/* upward arrow bridging the popover to the bar above it */ +.distPop::after{ + content: ''; + position: absolute; + bottom: 100%; + width: 9px; + height: 9px; + background: var(--bg-overlay); + border-left: 1px solid var(--border-strong); + border-top: 1px solid var(--border-strong); + transform: translateY(5px) rotate(45deg); +} +.distPopLeft::after{ + left: 14px; +} +.distPopRight::after{ + right: 14px; +} + +.distPopHead{ + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px solid var(--soft-border); +} +.distPopHead strong{ + font-size: 12px; + font-weight: 600; + color: var(--fg-default); +} +.distPopHead strong span{ + color: var(--fg-subtle); + font-weight: 500; +} +.distPopHead > span{ + flex: 0 0 auto; + font-size: 10.5px; + color: var(--fg-muted); +} + +.distPopList{ + display: flex; + flex-direction: column; + gap: 1px; + max-height: 196px; + overflow-y: auto; + margin: 0 -4px; +} + +.distPopRow{ + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 4px 6px; + border: 0; + border-radius: 6px; + background: transparent; + text-align: left; + cursor: pointer; + transition: background 100ms ease; +} +.distPopRow:hover{ + background: var(--soft-fill); +} + +.distPopAvatar{ + width: 22px; + height: 22px; + border-radius: 6px; + flex: 0 0 auto; + object-fit: cover; +} + +.distPopName{ + display: flex; + flex-direction: column; + min-width: 0; + line-height: 1.25; + font-size: 12px; + color: var(--fg-default); + overflow: hidden; +} +.distPopName{ + white-space: nowrap; + text-overflow: ellipsis; +} +.distPopName em{ + font-style: normal; + font-size: 9.5px; + color: var(--fg-subtle); + font-family: var(--mono); +} + +.distPopVal{ + margin-left: auto; + flex: 0 0 auto; + font-family: var(--mono); + font-size: 11px; + color: var(--success-fg); + font-variant-numeric: tabular-nums; +} diff --git a/src/app/miners/page.tsx b/src/app/miners/page.tsx index dc37224..e7d0322 100644 --- a/src/app/miners/page.tsx +++ b/src/app/miners/page.tsx @@ -2,951 +2,457 @@ export const dynamic = 'force-dynamic'; -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { PageLayout, Heading, Text, Box, TextInput, Label } from '@primer/react'; -import { - SearchIcon, - StarIcon, - StarFillIcon, - TableIcon, - ListUnorderedIcon, - TriangleDownIcon, - TriangleUpIcon, -} from '@primer/octicons-react'; -import { TableRowsSkeleton, CardGridSkeleton } from '@/components/Skeleton'; +import { ListUnorderedIcon, SearchIcon, SquareFillIcon, StarIcon } from '@primer/octicons-react'; +import { TableRowsSkeleton } from '@/components/Skeleton'; import { useMinerLogin } from '@/lib/use-miner'; import { useTrackedMiners } from '@/lib/tracked-miners'; -import { formatUsd, formatUsdMonthly, formatPercent } from '@/lib/format'; -import type { Miner, MinersResponse } from '@/types/entities'; - -type SortKey = 'score' | 'earnings' | 'issues' | 'credibility'; -type EligibilityFilter = 'all' | 'eligible' | 'ineligible'; -type ViewMode = 'grid' | 'list'; - -const SORT_LABEL: Record = { - score: 'Score', - earnings: 'Earnings', - issues: 'Issues', - credibility: 'Credibility', -}; - -const SORT_KEYS: SortKey[] = ['score', 'earnings', 'issues', 'credibility']; - -function num(v: unknown): number { - const n = typeof v === 'string' ? parseFloat(v) : typeof v === 'number' ? v : 0; - return Number.isFinite(n) ? n : 0; -} +import { formatCount } from '@/lib/format'; +import type { MinersResponse } from '@/types/entities'; +import styles from './page.module.css'; +import { + EMPTY_MINERS, + SORT_OPTIONS, + compareViews, + minerTrackKey, + minerView, + num, + rankMap, + repoStreamShare, + subnetTaoBase, + type EmissionData, + type MinerView, + type SortDir, + type SortKey, + type ViewMode, +} from './_lib/miners'; +import Headline from './_components/Headline'; +import EmissionHeader from './_components/EmissionHeader'; +import MinerCard from './_components/MinerCard'; +import { MinerCardGridSkeleton } from './_components/shared'; +import MinerListRow from './_components/MinerListRow'; +import MinerModal from './_components/MinerModal'; +import Palette from './_components/Palette'; +import Dropdown from '@/components/Dropdown'; +import { InlinePagination } from '@/components/repo-explorer/Pagination'; +import { ISSUE_COLOR, MAINTAINER_COLOR, PR_COLOR, fillBadge } from './_lib/streams'; + +/** Reward-stream filter pills — mirror the treemap/palette stream colors. A miner + * matches a stream if they EARN it (a multi-stream miner matches more than one). */ +type StreamFilter = 'all' | 'pr' | 'issue' | 'maintainer'; +const STREAM_FILTERS: Array<{ key: StreamFilter; label: string; color: string }> = [ + { key: 'all', label: 'Show all', color: '' }, + { key: 'pr', label: 'Pull requests', color: PR_COLOR }, + { key: 'issue', label: 'Issue discovery', color: ISSUE_COLOR }, + { key: 'maintainer', label: 'Maintainer cut', color: MAINTAINER_COLOR }, +]; export default function MinersPage() { const me = useMinerLogin(); const { tracked, toggle } = useTrackedMiners(); - const [query, setQuery] = useState(''); - const [sortKey, setSortKey] = useState('score'); - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc'); - const [eligibility, setEligibility] = useState('all'); - const [view, setView] = useState('grid'); - const [leaderboardMode, setLeaderboardMode] = useState<'usd' | 'issues'>('usd'); - - const onSortChange = (k: SortKey) => { - if (k === sortKey) setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); - else { - setSortKey(k); - setSortDir('desc'); - } - }; - const { data, isLoading, isError } = useQuery({ - queryKey: ['miners'], - queryFn: async () => { - const r = await fetch('/api/miners'); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - return r.json(); + const [stream, setStream] = useState('all'); + const [trackedOnly, setTrackedOnly] = useState(false); + const [sortKey, setSortKey] = useState('activity'); + const [sortDir, setSortDir] = useState('desc'); + const [viewMode, setViewMode] = useState('card'); + const [page, setPage] = useState(1); + const [selectedId, setSelectedId] = useState(null); + const [paletteOpen, setPaletteOpen] = useState(false); + + // Hold real rows empty until after mount so the first client render matches + // the SSR'd HTML (TanStack Query has no data on the server but may have a + // warm client cache). + const [hydrated, setHydrated] = useState(false); + useEffect(() => setHydrated(true), []); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['miners', 'activity'], + queryFn: async ({ signal }) => { + const response = await fetch('/api/miners/activity', { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; }, - refetchInterval: 10_000, + refetchInterval: 30_000, refetchIntervalInBackground: true, }); - // Stable rank — gittensor.io discoveries ranks by issueDiscoveryScore - // (the issue-context score), not the global totalScore. - const rankByScore = useMemo(() => { - const map = new Map(); - if (!data?.miners) return map; - const sorted = [...data.miners].sort((a, b) => num(b.issueDiscoveryScore) - num(a.issueDiscoveryScore)); - sorted.forEach((m, i) => map.set(m.id, i + 1)); - return map; - }, [data]); + const { data: emission } = useQuery({ + queryKey: ['sn74-emission'], + queryFn: async ({ signal }) => { + const r = await fetch('/api/sn74-emission', { signal }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }, + refetchInterval: 60_000, + staleTime: 30_000, + refetchOnWindowFocus: false, + }); - const filtered = useMemo(() => { - if (!data?.miners) return [] as Miner[]; - const q = query.trim().toLowerCase(); - let list = data.miners.filter((m) => { - if (q && !`${m.githubUsername} ${m.uid} ${m.hotkey ?? ''}`.toLowerCase().includes(q)) return false; - if (eligibility === 'eligible' && !m.isIssueEligible) return false; - if (eligibility === 'ineligible' && m.isIssueEligible) return false; - return true; - }); - list = [...list].sort((a, b) => { - // Match gittensor.io: ELIGIBLE miners always come before INELIGIBLE ones, - // regardless of which sort metric is active. The selected metric only - // orders within each eligibility group. - if (a.isIssueEligible !== b.isIssueEligible) { - return a.isIssueEligible ? -1 : 1; - } - let cmp = 0; - // All ranks use the issue-context fields — this page mirrors - // gittensor.io's /discoveries which is purely about issue rewards. - if (sortKey === 'score') cmp = num(a.issueDiscoveryScore) - num(b.issueDiscoveryScore); - else if (sortKey === 'earnings') cmp = num(a.usdPerDay) - num(b.usdPerDay); - else if (sortKey === 'issues') cmp = (a.totalOpenIssues ?? 0) - (b.totalOpenIssues ?? 0); - else if (sortKey === 'credibility') cmp = num(a.issueCredibility) - num(b.issueCredibility); - // Tie-breaker: discovery score (so credibility ties don't shuffle randomly) - if (cmp === 0) cmp = num(a.issueDiscoveryScore) - num(b.issueDiscoveryScore); - return sortDir === 'desc' ? -cmp : cmp; - }); - return list; - }, [data, query, eligibility, sortKey, sortDir]); - - // Aggregate sidebar stats — all values recomputed against the same scope as - // the visible miners list (`filtered`), so toggling All / Eligible / - // Ineligible reshapes every card. - const stats = useMemo(() => { - const empty = { - counts: { all: 0, eligible: 0, ineligible: 0 }, - issueCounts: { all: 0, eligible: 0, ineligible: 0 }, - pr: { merged: 0, open: 0, closed: 0, mergeRate: 0, totalDay: 0 }, - issue: { solved: 0, open: 0, closed: 0, solveRate: 0, totalDay: 0 }, - code: { added: 0, deleted: 0, repos: 0, avgCred: 0 }, - topEarners: [] as Miner[], - mostActive: [] as Miner[], - }; - if (!data?.miners) return empty; - const scope = filtered; - - let merged = 0, openPr = 0, closedPr = 0, prDay = 0; - let solved = 0, openIs = 0, closedIs = 0, isDay = 0; - let added = 0, deleted = 0, repos = 0, credSum = 0, credN = 0; - let prAll = 0, prElig = 0, prInelig = 0; - let isAll = 0, isElig = 0, isInelig = 0; - for (const m of scope) { - merged += m.totalMergedPrs ?? 0; - openPr += m.totalOpenPrs ?? 0; - closedPr += m.totalClosedPrs ?? 0; - solved += m.totalSolvedIssues ?? 0; - openIs += m.totalOpenIssues ?? 0; - closedIs += m.totalClosedIssues ?? 0; - added += m.totalAdditions ?? 0; - deleted += m.totalDeletions ?? 0; - repos += m.uniqueReposCount ?? 0; - const c = num(m.issueCredibility ?? m.credibility); - if (c > 0) { credSum += c; credN += 1; } - // Total $/day per track is sum of usdPerDay for miners eligible in that - // track (matches gittensor.io). A miner can be eligible for both, in - // which case their full usdPerDay counts toward each track's total. - const usd = m.usdPerDay ?? 0; - if (m.isEligible) prDay += usd; - if (m.isIssueEligible) isDay += usd; - // Miners Activity: PR column tracks isEligible, ISSUE column tracks isIssueEligible. - prAll += 1; - if (m.isEligible) prElig += 1; else prInelig += 1; - isAll += 1; - if (m.isIssueEligible) isElig += 1; else isInelig += 1; + // Per-repo TAO base (active miners + recycle + treasury) — the value the + // protocol's `emissionShare × OSS_POOL` formula is a fraction of. Drives every + // emission figure on the page (headline, treemap, podium, per-repo) so they all + // agree, matching the repositories page exactly. + const subnetTao = subnetTaoBase(emission); + + const views = useMemo(() => { + if (!hydrated) return []; + const raw = data?.miners ?? EMPTY_MINERS; + // Live TAO→USD rate from the feed (median usd/tao over earning miners) — used + // to derive each miner's $/day from their ACCURATE TAO, so USD stays + // consistent with the accurate emission everywhere (display + sort). + const rates: number[] = []; + for (const m of raw) { + const t = num((m as { taoPerDay?: unknown }).taoPerDay); + const u = num((m as { usdPerDay?: unknown }).usdPerDay); + if (t > 0 && u > 0) rates.push(u / t); } - const totalPr = merged + closedPr; - const totalIs = solved + closedIs; - - const topEarners = [...scope] - .sort((a, b) => num(b.usdPerDay) - num(a.usdPerDay)) - .slice(0, 5); - const mostActive = [...scope] - .sort((a, b) => (b.totalOpenIssues ?? 0) - (a.totalOpenIssues ?? 0)) - .slice(0, 5); - - return { - counts: { all: prAll, eligible: prElig, ineligible: prInelig }, - issueCounts: { all: isAll, eligible: isElig, ineligible: isInelig }, - pr: { merged, open: openPr, closed: closedPr, mergeRate: totalPr ? Math.round((merged / totalPr) * 100) : 0, totalDay: prDay }, - issue: { solved, open: openIs, closed: closedIs, solveRate: totalIs ? Math.round((solved / totalIs) * 100) : 0, totalDay: isDay }, - code: { added, deleted, repos, avgCred: credN ? credSum / credN : 0 }, - topEarners, - mostActive, + rates.sort((a, b) => a - b); + const usdPerTao = rates.length > 0 ? rates[Math.floor(rates.length / 2)] : 0; + // Each miner's ACTUAL on-chain τ/day, keyed by uid (alpha_per_day × price) — + // the exact TaoMarketCap figure, used as the authoritative headline emission. + const perUid = emission?.perUidTaoPerDay ?? null; + return raw.map((miner) => { + // uid > 0 guards a missing uid (coerces to 0) from grabbing UID 0's recycle + // emission; 0/111 are the recycle/treasury sinks, never real miners. + const uid = num((miner as { uid?: unknown }).uid); + const actual = perUid && uid > 0 ? perUid[uid] : undefined; + return minerView(miner, subnetTao, usdPerTao, actual); + }); + }, [data?.miners, emission, hydrated, subnetTao]); + + // Each repo's ACTUAL distributed emission (τ/day) — the sum of every contributor's + // on-chain per-repo share, so the card's "repo total / your share" reconciles with + // the now-accurate per-miner emission. (The old `emissionShare × subnetTAO` was the + // NOTIONAL pool — it counted the ~60% that recycles unclaimed, so it overstated.) + // Maintainer-only earning repos live in topRepos (pinned), not rows, so include both. + const repoEmissionTotals = useMemo(() => { + const totals = new Map(); + const add = (repo: string, tao: number) => { + if (tao > 0) totals.set(repo, (totals.get(repo) ?? 0) + tao); }; - }, [data, filtered]); - - return ( - - - Miners - - SN74 miners — earnings, scoring, eligibility. Discovery rewards filed via quality issues are scored separately - from PR rewards. - - - - - {/* main column */} - - {/* Toolbar */} - - - - Miners ({data?.count ?? 0}) - - - setQuery(e.target.value)} - sx={{ width: '100%' }} - /> - - - - - - {SORT_KEYS.map((k) => { - const active = sortKey === k; - return ( - onSortChange(k)} - sx={{ - display: 'inline-flex', - alignItems: 'center', - gap: 1, - px: 2, - py: '4px', - border: '1px solid', - borderColor: active ? 'var(--border-default)' : 'transparent', - borderRadius: 1, - bg: active ? 'var(--bg-emphasis)' : 'transparent', - color: active ? 'var(--fg-default)' : 'var(--fg-muted)', - fontSize: 1, - fontWeight: active ? 600 : 500, - cursor: 'pointer', - fontFamily: 'inherit', - '&:hover': { color: 'var(--fg-default)' }, - }} - > - {SORT_LABEL[k]} - {active && (sortDir === 'desc' ? : )} - - ); - })} - - - - {(['all', 'eligible', 'ineligible'] as EligibilityFilter[]).map((e) => ( - setEligibility(e)} - sx={{ - px: 2, - py: '4px', - border: '1px solid', - borderColor: eligibility === e ? 'var(--border-default)' : 'transparent', - borderRadius: 1, - bg: eligibility === e ? 'var(--bg-emphasis)' : 'transparent', - color: eligibility === e ? 'var(--fg-default)' : 'var(--fg-muted)', - fontSize: 1, - fontWeight: 500, - cursor: 'pointer', - fontFamily: 'inherit', - textTransform: 'capitalize', - '&:hover': { color: 'var(--fg-default)' }, - }} - > - {e} - - ))} - - setView('grid')} aria="Grid view"> - - - setView('list')} aria="List view"> - - - - - - - {isError && ( - - Failed to load miners. - - )} - {isLoading && !data && ( - view === 'grid' ? ( - - ) : ( - - ) - )} - - {data && view === 'grid' && ( - - {filtered.map((m) => ( - toggle(m.id)} - /> - ))} - - )} - - {data && view === 'list' && ( - - )} - - - {/* Sidebar */} - - - - - PR - ISSUE - - All - {stats.counts.all} - {stats.issueCounts.all} - - Eligible - {stats.counts.eligible} - {stats.issueCounts.eligible} - - Ineligible - {stats.counts.ineligible} - {stats.issueCounts.ineligible} - - - - - - MERGED - OPEN - CLOSED - {stats.pr.merged} - {stats.pr.open} - {stats.pr.closed} - - = 75 ? 'var(--success-fg)' : 'var(--attention-emphasis)'} /> - - Total $/day - ${stats.pr.totalDay.toLocaleString(undefined, { maximumFractionDigits: 0 })} - - - - - - SOLVED - OPEN - CLOSED - {stats.issue.solved} - {stats.issue.open} - {stats.issue.closed} - - = 75 ? 'var(--success-fg)' : 'var(--attention-emphasis)'} /> - - Total $/day - ${stats.issue.totalDay.toLocaleString(undefined, { maximumFractionDigits: 0 })} - - - - - - - - - - = 0.5 ? 'var(--success-fg)' : stats.code.avgCred >= 0.2 ? 'var(--attention-emphasis)' : 'var(--danger-fg)'} - /> - - - - - - - - - - ); -} - -function KvRow({ label, value, color }: { label: string; value: string | number; color: string }) { - return ( - - {label} - {value} - - ); -} - -function LeaderboardCard({ - mode, - onModeChange, - earners, - active, -}: { - mode: 'usd' | 'issues'; - onModeChange: (m: 'usd' | 'issues') => void; - earners: Miner[]; - active: Miner[]; -}) { - const rows = mode === 'usd' ? earners : active; - const colHeader = mode === 'usd' ? '$/DAY' : 'ISSUES'; - const cardTitle = mode === 'usd' ? 'Top Earners' : 'Most Active'; - return ( - - onModeChange('usd')}>$ - onModeChange('issues')}>Issues - + for (const view of views) { + const seen = new Set(); + for (const row of view.rows) { + add(row.repo.toLowerCase(), subnetTao * repoStreamShare(row)); + seen.add(row.repo.toLowerCase()); } - > - - - - # - MINER - {colHeader} - - - - {rows.map((m, i) => ( - - {i + 1} - - - {/* eslint-disable-next-line @next/next/no-img-element */} - {m.githubUsername} - {m.githubUsername} - - - - {mode === 'usd' ? formatUsd(num(m.usdPerDay)) : (m.totalOpenIssues ?? 0).toLocaleString()} - - - ))} - {rows.length === 0 && ( - - - No miners in scope. - - - )} - - - - ); -} + for (const row of view.topRepos) { + if (!seen.has(row.repo.toLowerCase())) add(row.repo.toLowerCase(), subnetTao * repoStreamShare(row)); + } + } + return totals; + }, [views, subnetTao]); -function ToggleBtn({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { - return ( - - {children} - + const filtered = useMemo(() => { + const list = views.filter((view) => { + if (trackedOnly && !tracked.has(minerTrackKey(view))) return false; + if (stream === 'pr' && !view.prEarning) return false; + if (stream === 'issue' && !view.issueEarning) return false; + if (stream === 'maintainer' && !view.isMaintainer) return false; + return true; + }); + return [...list].sort(compareViews(sortKey, sortDir)); + }, [sortDir, sortKey, stream, tracked, trackedOnly, views]); + + // Pagination — the card grid fits 4 rows (12); the list's compact rows fit more. + const pageSize = viewMode === 'card' ? 12 : 20; + const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); + const currentPage = Math.min(Math.max(1, page), totalPages); + const paged = useMemo( + () => filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize), + [filtered, currentPage, pageSize], ); -} - -function ViewToggleBtn({ active, onClick, aria, children }: { active: boolean; onClick: () => void; aria: string; children: React.ReactNode }) { - return ( - - {children} - + // Jump back to the first page whenever the filter / sort / view changes. + useEffect(() => { + setPage(1); + }, [stream, sortKey, sortDir, trackedOnly, viewMode]); + + // Paging from the footer scrolls back to the top of the board so the new page + // starts in view rather than leaving the viewport at the bottom. + const boardRef = useRef(null); + const goToPage = useCallback((p: number) => { + setPage(p); + boardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, []); + + const selectedMiner = useMemo( + () => (selectedId ? views.find((view) => view.key === selectedId) ?? null : null), + [views, selectedId], ); -} - -function SidebarCard({ title, right, children }: { title: string; right?: React.ReactNode; children: React.ReactNode }) { - return ( - - - {title} - {right} - - {children} - + // Position of the open miner within the filtered list — powers the modal's + // prev/next navigation (← / →), stepping across pages. + const selectedIndex = useMemo( + () => (selectedMiner ? filtered.findIndex((v) => v.key === selectedMiner.key) : -1), + [filtered, selectedMiner], ); -} -function Bar({ label, pct, color }: { label: string; pct: number; color: string }) { - return ( - - - {label} - {pct}% - - - - - + const ranks = useMemo( + () => ({ + activity: rankMap(views, 'activity'), + score: rankMap(views, 'score'), + earnings: rankMap(views, 'earnings'), + repos: rankMap(views, 'repos'), + }), + [views], ); -} -function CredibilityRing({ value, size = 56, dim = false }: { value: number; size?: number; dim?: boolean }) { - const r = (size - 6) / 2; - const c = 2 * Math.PI * r; - const pct = Math.min(1, Math.max(0, value)); - const offset = c * (1 - pct); - const stroke = pct >= 0.5 ? 'var(--success-emphasis)' : pct >= 0.2 ? 'var(--attention-emphasis)' : 'var(--fg-muted)'; - return ( - - - - - {pct > 0 && ( - - )} - - - {Math.round(pct * 100)}% - - - ); -} + const myKey = useMemo(() => { + if (!me) return null; + const found = views.find((view) => view.login.toLowerCase() === me.toLowerCase()); + return found?.key ?? null; + }, [me, views]); + + // The signed-in user's own miner row, if they are one — lets us surface their + // UID directly even when they're too small to appear as a treemap tile. + const myView = useMemo(() => (myKey ? views.find((view) => view.key === myKey) ?? null : null), [myKey, views]); + + const lastSync = data?.fetched_at + ? new Date(data.fetched_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : 'pending'; + + // ⌘K opens the palette. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setPaletteOpen(true); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + const selectMiner = useCallback((view: MinerView) => { + setSelectedId(view.key); + }, []); + + const errorMessage = isError ? (error instanceof Error ? error.message : 'unknown error') : null; + const showSkeleton = (!hydrated || isLoading) && !data; + // Miner emission pool (TAO/day) — the denominator for each card/row share bar. + // Matches the treemap's fullPool (active miners + recycle + treasury). + const poolTao = emission?.minerTaoPerDay ?? 0; + // Whole-subnet daily emission — denominator for each card's "% of total". + const totalTao = emission?.totalTaoPerDay ?? 0; -function MinerCard({ - miner, - rank, - isMe, - isTracked, - onToggleTrack, -}: { - miner: Miner; - rank: number; - isMe: boolean; - isTracked: boolean; - onToggleTrack: () => void; -}) { - const dim = !miner.isIssueEligible; - const usd = num(miner.usdPerDay); - // Donut + score in cards reflect issue-context (matches gittensor.io discoveries) - const cred = num(miner.issueCredibility ?? miner.credibility); - const score = num(miner.issueDiscoveryScore); return ( - - - {/* eslint-disable-next-line @next/next/no-img-element */} - {miner.githubUsername} +
+
+ + setPaletteOpen(true)} /> - - - - {miner.githubUsername} - - #{rank} - {isMe && ( - - )} - - {!miner.isIssueEligible && ( - - )} - - - {isTracked ? : } - - - - - - - - {formatUsd(usd)} - - /day - - {formatUsdMonthly(usd)} - - - - - - - - - - - - ); -} - -function StatCol({ label, value, color, align = 'left' }: { label: string; value: string | number; color: string; align?: 'left' | 'right' }) { - return ( - - {label} - {value} - - ); -} - -function MinerListView({ - miners, - rankByScore, - me, - tracked, - onToggleTrack, - sortKey, - sortDir, - onSortChange, -}: { - miners: Miner[]; - rankByScore: Map; - me: string; - tracked: Set; - onToggleTrack: (id: string) => void; - sortKey: SortKey; - sortDir: 'asc' | 'desc'; - onSortChange: (k: SortKey) => void; -}) { - return ( - - - - - RANK - MINER - EARNINGS/DAY - ISSUES - CREDIBILITY - SCORE - - - - - {miners.map((m) => { - const dim = !m.isIssueEligible; - const isMe = m.githubUsername.toLowerCase() === me.toLowerCase(); - const rank = rankByScore.get(m.id) ?? 0; +
+
+ +
+
+
+ Filter by +
+ {STREAM_FILTERS.map((f) => { + const active = stream === f.key; return ( - setStream(f.key)} > - - - {rank} - - - - - {/* eslint-disable-next-line @next/next/no-img-element */} - {m.githubUsername} - {m.githubUsername} - {isMe && } - - - - - {formatUsd(num(m.usdPerDay))} - - - - - - - {m.totalSolvedIssues ?? 0} - - - - {m.totalOpenIssues ?? 0} - - - - {m.totalClosedIssues ?? 0} - - - - - - {formatPercent(m.issueCredibility ?? m.credibility, { scale: 100 })} - - - - - {num(m.issueDiscoveryScore).toFixed(2)} - - - - onToggleTrack(m.id)} - aria-label={tracked.has(m.id) ? 'Untrack miner' : 'Track miner'} - sx={{ - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: 24, - height: 24, - bg: 'transparent', - border: 'none', - borderRadius: 1, - color: tracked.has(m.id) ? 'attention.fg' : 'fg.muted', - cursor: 'pointer', - '&:hover': { bg: 'canvas.inset', color: 'attention.fg' }, - }} - > - {tracked.has(m.id) ? : } - - - + {f.color ? : null} + {f.label} + ); })} - - - - ); -} +
+
+ +
+ + + +
+ + +
+ + +
+
+
+ +
+
+
+
+
+
{formatCount(filtered.length, { fallback: '0' })} miners
+

All miners

+
+ repo-scoped feed · synced {lastSync} +
+ + {errorMessage &&
Failed to load repo-scoped miners: {errorMessage}
} + + {showSkeleton ? ( + viewMode === 'card' ? ( + + ) : ( +
+ +
+ ) + ) : filtered.length === 0 ? ( +
No miners match this filter.
+ ) : viewMode === 'card' ? ( +
+ {paged.map((view) => { + const trackKey = minerTrackKey(view); + return ( + selectMiner(view)} + onToggleTrack={() => toggle(trackKey)} + /> + ); + })} +
+ ) : ( +
+
+ + Miner + Emission + Score + PR activity + Issues activity + Contributions + Top repos +
+ {paged.map((view) => { + const trackKey = minerTrackKey(view); + return ( + selectMiner(view)} + onToggleTrack={() => toggle(trackKey)} + /> + ); + })} +
+ )} -function Th({ - children, - align = 'left', - width, - sortKey, - current, - dir, - onSort, -}: { - children?: React.ReactNode; - align?: 'left' | 'right' | 'center'; - width?: number; - sortKey?: SortKey; - current?: SortKey; - dir?: 'asc' | 'desc'; - onSort?: (k: SortKey) => void; -}) { - const isSortable = !!sortKey && !!onSort; - const active = isSortable && current === sortKey; - return ( - onSort!(sortKey) : undefined} - sx={{ - p: 2, - textAlign: align, - width, - fontWeight: 600, - fontSize: '11px', - color: active ? 'fg.default' : 'fg.muted', - textTransform: 'uppercase', - letterSpacing: '0.5px', - whiteSpace: 'nowrap', - cursor: isSortable ? 'pointer' : 'default', - userSelect: 'none', - '&:hover': isSortable ? { color: 'fg.default' } : undefined, - }} - > - - {active && (dir === 'desc' ? : )} - {children} - - + {!showSkeleton && totalPages > 1 ? ( +
+ +
+ ) : null} +
+
+
+ + {selectedMiner && ( + setSelectedId(null)} + onToggleTrack={() => toggle(minerTrackKey(selectedMiner))} + onPrev={selectedIndex > 0 ? () => setSelectedId(filtered[selectedIndex - 1].key) : undefined} + onNext={ + selectedIndex >= 0 && selectedIndex < filtered.length - 1 + ? () => setSelectedId(filtered[selectedIndex + 1].key) + : undefined + } + /> + )} + + setPaletteOpen(false)} + onSelect={(key) => setSelectedId(key)} + /> +
); } diff --git a/src/components/ActivityLineChart.tsx b/src/components/ActivityLineChart.tsx new file mode 100644 index 0000000..a7ee47c --- /dev/null +++ b/src/components/ActivityLineChart.tsx @@ -0,0 +1,528 @@ +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; +import { Box, Text } from '@primer/react'; + +function fmtCount(value: number): string { + return Math.round(value).toLocaleString(); +} + +// Decay-weighted scores are small fractional values — show 2 decimals (e.g. 3.02). +function fmtScore(value: number): string { + return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +export type ActivityKey = 'mergedPrs' | 'closedPrs' | 'resolvedIssues' | 'openedPrs' | 'openedIssues'; + +export interface DayPoint { + label: string; + mergedPrs: number; + closedPrs: number; + resolvedIssues: number; + openedPrs: number; + openedIssues: number; +} + +// Chart palette — curated dashboard colors (Tailwind-500 family) that read +// distinctly against both light and dark canvases when used at 18-70% opacity +// in stacked bands. Order = legend display order (lifecycle-grouped: opens +// then completions then closes). +const ACTIVITY_GROUPS = ['PRs', 'Issues'] as const; +type ActivityGroup = (typeof ACTIVITY_GROUPS)[number]; +// `label` is the full name (used in the tooltip); `short` drops the redundant group +// prefix so the grouped legend reads "PRs · Opened / Merged / Closed". +const ACTIVITY_SERIES: Array<{ key: ActivityKey; label: string; short: string; group: ActivityGroup; color: string }> = [ + { key: 'openedPrs', label: 'PRs Opened', short: 'Opened', group: 'PRs', color: '#3b82f6' }, // blue-500 + { key: 'mergedPrs', label: 'PRs Merged', short: 'Merged', group: 'PRs', color: '#10b981' }, // emerald-500 + { key: 'closedPrs', label: 'PRs Closed', short: 'Closed', group: 'PRs', color: '#ef4444' }, // red-500 + { key: 'openedIssues', label: 'Issues Opened', short: 'Opened', group: 'Issues', color: '#8b5cf6' }, // violet-500 + { key: 'resolvedIssues', label: 'Issues Resolved', short: 'Resolved', group: 'Issues', color: '#f59e0b' }, // amber-500 +]; + +export function smoothPath(points: Array<{ x: number; y: number }>): string { + if (points.length === 0) return ''; + if (points.length === 1) return 'M ' + points[0].x + ' ' + points[0].y; + return points + .map((point, index) => { + if (index === 0) return 'M ' + point.x + ' ' + point.y; + const prev = points[index - 1]; + const cpX = prev.x + (point.x - prev.x) / 2; + return 'C ' + cpX + ' ' + prev.y + ' ' + cpX + ' ' + point.y + ' ' + point.x + ' ' + point.y; + }) + .join(' '); +} + +function niceCeil(value: number): number { + if (value <= 4) return 4; + const exp = Math.floor(Math.log10(value)); + const mag = Math.pow(10, exp); + const norm = value / mag; + const nice = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10; + return Math.ceil(nice * mag); +} + +export function ActivityLineChart({ points }: { points: DayPoint[] }) { + const [hoveredIndex, setHoveredIndex] = useState(null); + // Track the real rendered width so the viewBox matches the box 1:1 — a fixed + // viewBox with `width="100%"` letterboxes (shrinks + centers) on narrow + // screens, leaving big empty bands above/below the plot. + const containerRef = useRef(null); + const [measuredWidth, setMeasuredWidth] = useState(900); + useEffect(() => { + const el = containerRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w && w > 0) setMeasuredWidth(w); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + const width = Math.max(280, measuredWidth); + const height = 300; + const pad = { left: 40, right: 16, top: 14, bottom: 32 }; + const plotWidth = width - pad.left - pad.right; + const plotHeight = height - pad.top - pad.bottom; + const maxValue = Math.max(4, ...points.flatMap((point) => ACTIVITY_SERIES.map((series) => point[series.key]))); + // Round yMax up to a "nice" step (10/25/50/100/250…) so y-axis labels are + // readable round numbers. + const yMax = niceCeil(maxValue); + const active = hoveredIndex === null ? null : { index: hoveredIndex, point: points[hoveredIndex] }; + // Keep the last hovered index so the indicator can smoothly stay in place + // while it fades out after the cursor leaves the chart. + const lastHoveredRef = useRef(0); + useEffect(() => { + if (hoveredIndex !== null) lastHoveredRef.current = hoveredIndex; + }, [hoveredIndex]); + // Clamp to current data length — when the user switches duration the data + // shrinks but the ref still holds an index from the previous (longer) range. + const rawDisplayIndex = hoveredIndex ?? lastHoveredRef.current; + const displayIndex = points.length > 0 + ? Math.min(Math.max(0, rawDisplayIndex), points.length - 1) + : 0; + const displayPoint = points[displayIndex] ?? points[0]; + const x = (idx: number) => pad.left + (idx * plotWidth) / Math.max(1, points.length - 1); + const y = (value: number) => pad.top + (1 - value / yMax) * plotHeight; + const tickStep = Math.max(1, Math.ceil(points.length / 7)); + const totals = ACTIVITY_SERIES.map((series) => ({ ...series, total: points.reduce((sum, point) => sum + point[series.key], 0) })); + const tooltipWidth = 196; + // Height grows with series count — 5 rows × ~18px + header + total row + + // padding. Recomputed so new series don't get clipped. + const tooltipHeight = 56 + ACTIVITY_SERIES.length * 19 + 36; + // Always compute a tooltip position (using displayIndex) so the tooltip can + // slide smoothly even between hover transitions. + const tooltipX = Math.min(width - tooltipWidth - 10, Math.max(10, x(displayIndex) - tooltipWidth / 2)); + const tooltipY = pad.top + 8; + + return ( + + + + {ACTIVITY_GROUPS.map((group) => ( + + {group} + {totals + .filter((series) => series.group === group) + .map((series) => ( + + ))} + + ))} + + + setHoveredIndex(null)}> + {/* Stacked bands intentionally use a flat fill (no gradient) — the + previous top→bottom gradient faded each band's lower edge to ~18% + opacity, which over a dark canvas became near-black and made the + colors look muddy. The 1.5px top-edge stroke alone gives enough + separation between layers. */} + {[0, 0.25, 0.5, 0.75, 1].map((tick) => { + const value = Math.round(yMax * (1 - tick)); + const lineY = pad.top + tick * plotHeight; + return ( + + + {value} + + ); + })} + {/* Lines — one per series, drawn with a stroke-dashoffset animation + so each one "draws itself in" left-to-right on first render. */} + {ACTIVITY_SERIES.map((series, seriesIndex) => { + const seriesPoints = points.map((point, idx) => ({ x: x(idx), y: y(point[series.key]) })); + return ( + + ); + })} + {points.map((point, idx) => { + const showLabel = idx === 0 || idx === points.length - 1 || idx % tickStep === 0; + return ( + + {showLabel && {point.label}} + setHoveredIndex(idx)} + onFocus={() => setHoveredIndex(idx)} + onBlur={() => setHoveredIndex(null)} + /> + + ); + })} + {/* Hover indicator — always mounted (so CSS transitions can interpolate + between hover positions instead of snapping). Group opacity controls + show/hide, child elements transition their positional attributes. + Guarded by points.length so it doesn't try to read stackedRows[0] + when there's no data yet. */} + {points.length > 0 && ( + + + {/* Dots at each line's value for the hovered x. CSS transitions on + cx/cy give a glide effect when moving between days. */} + {ACTIVITY_SERIES.map((series) => ( + + ))} + {/* Tooltip — HTML inside foreignObject for themed CSS vars. The `x` + attribute on foreignObject is transitionable in modern browsers. */} + + + + {displayPoint.label} + + + {ACTIVITY_SERIES.map((series) => ( + + + {series.label} + + {fmtCount(displayPoint[series.key])} + + + ))} + + + TOTAL + + {fmtCount(ACTIVITY_SERIES.reduce((sum, series) => sum + displayPoint[series.key], 0))} + + + + + + )} + + + + ); +} + +function ActivityLegend({ color, label, total }: { color: string; label: string; total: number }) { + return ( + + + {label} + {fmtCount(total)} + + ); +} + +export interface ForecastRepo { + repo: string; + /** This repo's decay-weighted earning-power contribution on the bucket day. */ + score: number; +} +export interface ForecastPoint { + label: string; + earned: number; + projected: boolean; + /** Per-repo breakdown (top contributors) for the day, for the hover tooltip. */ + repos: ForecastRepo[]; +} + +function forecastRepoAvatar(repo: string): string { + const owner = repo.split('/')[0] ?? ''; + return `https://github.com/${encodeURIComponent(owner)}.png?size=40`; +} +export interface ForecastSeries { + points: ForecastPoint[]; + /** Last historical bucket index (today) — boundary with the dashed projection. */ + nowIdx: number; + /** Projected % erosion of earning power over the forward horizon (no new merges). */ + dropPct: number | null; + projDays: number; + liveNow: number; +} + +const FORECAST_COLOR = '#6366f1'; // indigo-500 — distinct from the activity palette + +/** Earning-power decay forecast in the SAME visual language as ActivityLineChart: a + * smooth line of the miner's decay-weighted earning power — solid over history, dashed + * past a "now" divider for the forward projection (current portfolio aging with no new + * merges). The legend headlines the projected erosion. */ +export function EarningForecastChart({ series }: { series: ForecastSeries }) { + const [hoveredIndex, setHoveredIndex] = useState(null); + const containerRef = useRef(null); + const [measuredWidth, setMeasuredWidth] = useState(900); + useEffect(() => { + const el = containerRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + const ro = new ResizeObserver((entries) => { + const w = entries[0]?.contentRect.width; + if (w && w > 0) setMeasuredWidth(w); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + const lastHoveredRef = useRef(0); + useEffect(() => { + if (hoveredIndex !== null) lastHoveredRef.current = hoveredIndex; + }, [hoveredIndex]); + + const width = Math.max(280, measuredWidth); + const height = 300; + const pad = { left: 40, right: 16, top: 14, bottom: 32 }; + const plotWidth = width - pad.left - pad.right; + const plotHeight = height - pad.top - pad.bottom; + const baseline = pad.top + plotHeight; + const pts = series.points; + const n = pts.length; + const yMax = niceCeil(Math.max(4, ...pts.map((p) => p.earned))); + const x = (idx: number) => pad.left + (idx * plotWidth) / Math.max(1, n - 1); + const y = (value: number) => pad.top + (1 - value / yMax) * plotHeight; + const tickStep = Math.max(1, Math.ceil(n / 7)); + const nowIdx = series.nowIdx; + const hasProj = series.projDays > 0 && n > nowIdx + 1; + const nowX = x(nowIdx); + const histPts = pts.slice(0, nowIdx + 1).map((p, i) => ({ x: x(i), y: y(p.earned) })); + const histLine = smoothPath(histPts); + const areaPath = histPts.length ? `${histLine} L ${nowX} ${baseline} L ${x(0)} ${baseline} Z` : ''; + const projLine = hasProj ? smoothPath(pts.slice(nowIdx).map((p, k) => ({ x: x(nowIdx + k), y: y(p.earned) }))) : ''; + + const active = hoveredIndex !== null; + const displayIndex = n > 0 ? Math.min(Math.max(0, hoveredIndex ?? lastHoveredRef.current), n - 1) : 0; + const displayPoint = pts[displayIndex] ?? pts[0]; + const repoRows = displayPoint?.repos?.length ?? 0; + const tooltipWidth = repoRows > 0 ? 220 : 190; + const tooltipHeight = 70 + repoRows * 21; + const tooltipX = Math.min(width - tooltipWidth - 10, Math.max(10, x(displayIndex) - tooltipWidth / 2)); + const tooltipY = pad.top + 8; + + return ( + + + + + Total Score + {fmtScore(series.liveNow)} + + {hasProj ? ( + + + Forecast + + ) : null} + {series.dropPct != null && series.dropPct > 0 ? ( + + ▼ {series.dropPct}% in {series.projDays}d + + ) : null} + + + setHoveredIndex(null)} + > + + + + + + + {[0, 0.25, 0.5, 0.75, 1].map((tick) => { + const value = Math.round(yMax * (1 - tick)); + const lineY = pad.top + tick * plotHeight; + return ( + + + {value} + + ); + })} + {hasProj ? ( + <> + + + now + + ) : null} + + + {hasProj ? ( + + ) : null} + {pts.map((point, idx) => ( + + {(idx === 0 || idx === n - 1 || idx % tickStep === 0) && ( + {point.label} + )} + setHoveredIndex(idx)} + /> + + ))} + {n > 0 && ( + + + + + + + {displayPoint.label} + {displayPoint.projected ? ( + · forecast + ) : null} + + + + Total score + {fmtScore(displayPoint.earned)} + + {repoRows > 0 ? ( + + {displayPoint.repos.map((r) => ( + + + + {r.repo} + + {fmtScore(r.score)} + + ))} + + ) : null} + + + + )} + + + + ); +} + diff --git a/src/components/IssueLabels.tsx b/src/components/IssueLabels.tsx index ca5e91f..f216999 100644 --- a/src/components/IssueLabels.tsx +++ b/src/components/IssueLabels.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Box } from '@primer/react'; +import { useTheme, type ThemeMode } from '@/lib/theme'; type IssueLabel = { name: string; color?: string | null }; @@ -37,28 +38,72 @@ function fallbackColorFor(name: string): string { return match?.[1] ?? '6e7781'; } -// GitHub stores label colors as hex without `#`. Pick readable text with a -// YIQ-style luminance check so custom label colors stay legible. -function readableFgFor(hex: string): string { - const h = normalizeHexColor(hex); - if (!h) return '#ffffff'; - const r = parseInt(h.slice(0, 2), 16); - const g = parseInt(h.slice(2, 4), 16); - const b = parseInt(h.slice(4, 6), 16); - const yiq = (r * 299 + g * 587 + b * 114) / 1000; - return yiq >= 160 ? '#1f2328' : '#ffffff'; +function hexToRgb(hex: string): [number, number, number] { + return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)]; +} + +function rgbToHsl(r: number, g: number, b: number): [number, number, number] { + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + const d = max - min; + let h = 0; + let s = 0; + if (d !== 0) { + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0); + else if (max === gn) h = (bn - rn) / d + 2; + else h = (rn - gn) / d + 4; + h *= 60; + } + return [Math.round(h), Math.round(s * 100), Math.round(l * 100)]; +} + +type ChipColors = { background: string; color: string; borderColor: string }; + +// Reproduce github.com's own label theming (computed here in JS so it never depends on +// CSS calc/custom-property plumbing): dark = translucent tint + lightened text + border; +// light = solid color bg + black/white text + subtle border. Matches the colors shown +// on a PR/issue page exactly. +function chipColors(hex: string, theme: ThemeMode): ChipColors { + const [r, g, b] = hexToRgb(hex); + const [h, s, l] = rgbToHsl(r, g, b); + const perceived = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 255; + + if (theme === 'light') { + const lightnessSwitch = Math.max(0, Math.min((perceived - 0.6) * -1000, 1)); + const borderAlpha = Math.max(0, Math.min((perceived - 0.96) * 100, 1)); + return { + background: `rgb(${r}, ${g}, ${b})`, + color: `hsl(0, 0%, ${lightnessSwitch * 100}%)`, + borderColor: `hsla(${h}, ${s}%, ${l - 25}%, ${borderAlpha})`, + }; + } + + const threshold = 0.453; + const lightnessSwitch = Math.max(0, Math.min((perceived - threshold) * -1000, 1)); + const lighten = (threshold - perceived) * 100 * lightnessSwitch; + return { + background: `rgba(${r}, ${g}, ${b}, 0.18)`, + color: `hsl(${h}, ${s}%, ${l + lighten}%)`, + borderColor: `hsla(${h}, ${s}%, ${l + lighten}%, 0.3)`, + }; } export const IssueLabelChip = React.memo(function IssueLabelChip({ label, maxWidth = 120, + theme = 'dark', }: { label: IssueLabel; maxWidth?: number; + theme?: ThemeMode; }) { const hex = normalizeHexColor(label.color) ?? fallbackColorFor(label.name); - const bg = `#${hex}`; - const fg = readableFgFor(hex); + const c = chipColors(hex, theme); return ( {visible.map((label) => ( - + ))} {hidden > 0 && ( = 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(4)}`; } - if (abs >= 100) return `$${n.toFixed(0)}`; + if (abs >= 100) return `$${n.toLocaleString(undefined, { maximumFractionDigits: 0 })}`; if (abs >= 1) return `$${n.toFixed(2)}`; return `$${n.toFixed(4)}`; } diff --git a/src/types/entities.ts b/src/types/entities.ts index dec5ec6..4ff3384 100644 --- a/src/types/entities.ts +++ b/src/types/entities.ts @@ -172,6 +172,91 @@ export interface GtRepoPrsResponse { fetched_at: number; } +/** One of a miner's scored pull requests (from the gittensor `/prs` feed), + * carrying its repo so the miner modal can list works across all repos. */ +export interface MinerPr { + repo: string; + number: number; + title: string; + state: 'OPEN' | 'MERGED' | 'CLOSED'; + score: number; + createdAt: string; + mergedAt: string | null; + /** Closed-without-merge timestamp (from the pulls mirror) — needed to recompute the + * trailing-window credibility live, since closed PRs count against it. */ + closedAt: string | null; + additions: number; + deletions: number; + linkedIssueNumber: number | null; + /** Author GitHub login (for the detail header). */ + author: string; + /** On-chain hotkey credited for this PR. */ + hotkey: string; + commitCount: number; + /** Per-PR scoring breakdown (gittensor AST scoring). */ + baseScore: number; + collateralScore: number; + tokenScore: number; + /** Total AST nodes scored. */ + totalNodesScored: number; + structuralCount: number; + structuralScore: number; + leafCount: number; + leafScore: number; + /** Scoring label (e.g. "bug", "feature") + its multiplier, and the review-quality + * multiplier — the two score-story factors carried per-PR. */ + label: string | null; + labelMultiplier: number; + reviewQualityMultiplier: number; + /** GitHub labels on the PR (name + hex color), same as the explorer shows. */ + labels: Array<{ name: string; color?: string }>; +} + +/** One of a miner's issues (from the local issues mirror). */ +export interface MinerIssue { + repo: string; + number: number; + title: string; + state: string; + /** GitHub close reason — COMPLETED / NOT_PLANNED (null when open/unknown). */ + stateReason: string | null; + htmlUrl: string | null; + createdAt: string | null; + updatedAt: string | null; + /** Close timestamp — for the activity "issues resolved" series. */ + closedAt: string | null; + /** GitHub labels on the issue (name + hex color), same as the explorer shows. */ + labels: Array<{ name: string; color?: string }>; +} + +/** One day's PR/issue lifecycle counts for the activity chart (computed server-side + * over the FULL works set, so prolific miners' closed PRs aren't truncated). */ +export interface MinerActivityPoint { + label: string; + openedPrs: number; + mergedPrs: number; + closedPrs: number; + openedIssues: number; + resolvedIssues: number; +} + +/** A miner's complete works across all repos — for the detail modal. */ +export interface MinerWorksResponse { + prs: MinerPr[]; + issues: MinerIssue[]; + counts: { + prs: number; + prMerged: number; + prOpen: number; + prClosed: number; + issues: number; + issuesOpen: number; + issuesCompleted: number; + }; + /** PR/issue activity over the last 30 days (daily buckets). */ + activity: MinerActivityPoint[]; +} + /** Admin-managed extra repo. Returned by `/api/user-repos`. */ export interface UserRepo { full_name: string; @@ -218,6 +303,60 @@ export interface Miner { alphaPerDay?: number; taoPerDay?: number; usdPerDay?: number; + /** Per-repository scoring rows keyed by repository full name. */ + repoEvaluations?: Record | MinerRepoEvaluation[]; + /** Snake-case variant returned by the upstream scorer payload. */ + repo_evaluations?: Record | MinerRepoEvaluation[]; +} + +export interface MinerRepoEvaluation { + id?: string | number; + uid?: string | number; + githubUsername?: string; + github_username?: string; + githubId?: string | number; + github_id?: string | number; + repositoryFullName?: string; + repository_full_name?: string; + isEligible?: boolean; + is_eligible?: boolean; + credibility?: string | number; + baseTotalScore?: string | number; + base_total_score?: string | number; + totalScore?: string | number; + total_score?: string | number; + totalCollateralScore?: string | number; + total_collateral_score?: string | number; + totalMergedPrs?: string | number; + total_merged_prs?: string | number; + totalOpenPrs?: string | number; + total_open_prs?: string | number; + totalClosedPrs?: string | number; + total_closed_prs?: string | number; + totalPrs?: string | number; + total_prs?: string | number; + isIssueEligible?: boolean; + is_issue_eligible?: boolean; + issueCredibility?: string | number; + issue_credibility?: string | number; + issueDiscoveryScore?: string | number; + issue_discovery_score?: string | number; + issueTokenScore?: string | number; + issue_token_score?: string | number; + totalSolvedIssues?: string | number; + total_solved_issues?: string | number; + totalValidSolvedIssues?: string | number; + total_valid_solved_issues?: string | number; + totalClosedIssues?: string | number; + total_closed_issues?: string | number; + totalOpenIssues?: string | number; + total_open_issues?: string | number; + alphaPerDay?: string | number; + alpha_per_day?: string | number; + taoPerDay?: string | number; + tao_per_day?: string | number; + usdPerDay?: string | number; + usd_per_day?: string | number; } export interface AuthorCredibility { @@ -249,6 +388,14 @@ export interface RepoMiner { closedPrCount?: number; totalPrCount?: number; credibility?: number; + issueDiscoveryScore?: number; + issueTokenScore?: number; + issueCredibility?: number; + isIssueEligible?: boolean; + totalSolvedIssues?: number; + totalValidSolvedIssues?: number; + totalClosedIssues?: number; + totalOpenIssues?: number; ossRank: number | null; globalScore?: number | null; /** On-chain miner UID — surfaced for the drawer treemap's tile label. */ @@ -273,6 +420,8 @@ export interface RepoMiner { export interface RepoMinersResponse { fullName: string; issueDiscoveryEnabled?: boolean; + /** Full per-repo miner evaluations, including both PR and issue-discovery fields. */ + repoEvaluations?: RepoMiner[]; ossContributions: RepoMiner[]; issueDiscoveries: RepoMiner[]; fetched_at: number;