From d9b28cac171ed3c5ed796e0b09980e9b98caa4e5 Mon Sep 17 00:00:00 2001 From: bitloi Date: Mon, 25 May 2026 12:07:44 +0200 Subject: [PATCH 01/18] feat: refactor repositories page into modular layout Replaces the monolithic page with an extracted component/util layout (_components/, _lib/) and adds /api/repos/metadata and /api/sn74-emission to back the new card / drawer / market surfaces. Adds the optional 30d stats fields on GtRepo and the closeOnScroll prop on Dropdown that the new page depends on. --- src/app/api/gt/repositories/route.ts | 95 +- src/app/api/repos/metadata/route.ts | 431 ++++ src/app/api/sn74-emission/route.ts | 244 +++ src/app/repositories/_components/Avatar.tsx | 46 + .../repositories/_components/CompareModal.tsx | 948 +++++++++ .../repositories/_components/CompareTray.tsx | 100 + src/app/repositories/_components/Drawer.tsx | 454 ++++ src/app/repositories/_components/LangIcon.tsx | 50 + .../_components/MarketSection.tsx | 936 +++++++++ src/app/repositories/_components/Palette.tsx | 129 ++ .../repositories/_components/RefPanels.tsx | 764 +++++++ src/app/repositories/_components/RepoCard.tsx | 680 ++++++ .../repositories/_components/RepoListRow.tsx | 293 +++ src/app/repositories/_lib/colors.ts | 231 ++ src/app/repositories/_lib/incentives.ts | 284 +++ src/app/repositories/_lib/rows.ts | 231 ++ src/app/repositories/_lib/squarify.ts | 107 + src/app/repositories/page.module.css | 1853 +++++++++++++++++ src/app/repositories/page.tsx | 1711 ++++++--------- src/components/Dropdown.tsx | 32 +- src/types/entities.ts | 15 + 21 files changed, 8524 insertions(+), 1110 deletions(-) create mode 100644 src/app/api/repos/metadata/route.ts create mode 100644 src/app/api/sn74-emission/route.ts create mode 100644 src/app/repositories/_components/Avatar.tsx create mode 100644 src/app/repositories/_components/CompareModal.tsx create mode 100644 src/app/repositories/_components/CompareTray.tsx create mode 100644 src/app/repositories/_components/Drawer.tsx create mode 100644 src/app/repositories/_components/LangIcon.tsx create mode 100644 src/app/repositories/_components/MarketSection.tsx create mode 100644 src/app/repositories/_components/Palette.tsx create mode 100644 src/app/repositories/_components/RefPanels.tsx create mode 100644 src/app/repositories/_components/RepoCard.tsx create mode 100644 src/app/repositories/_components/RepoListRow.tsx create mode 100644 src/app/repositories/_lib/colors.ts create mode 100644 src/app/repositories/_lib/incentives.ts create mode 100644 src/app/repositories/_lib/rows.ts create mode 100644 src/app/repositories/_lib/squarify.ts create mode 100644 src/app/repositories/page.module.css diff --git a/src/app/api/gt/repositories/route.ts b/src/app/api/gt/repositories/route.ts index d5357b5..d2c4841 100644 --- a/src/app/api/gt/repositories/route.ts +++ b/src/app/api/gt/repositories/route.ts @@ -4,6 +4,7 @@ export const dynamic = 'force-dynamic'; const REPOS_URL = 'https://api.gittensor.io/dash/repos'; const PRS_URL = 'https://api.gittensor.io/prs'; +const ISSUES_URL = 'https://api.gittensor.io/issues'; const TTL_MS = 30_000; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; @@ -48,6 +49,14 @@ interface UpstreamPr { commitCount?: number | null; } +/** Issue-discovery bounty record from gittensor.io/issues. Used to bin + * per-day issue activity for the repo-card Contributions chart. */ +interface UpstreamIssue { + repositoryFullName: string; + createdAt: string; + status?: string; +} + export interface GtRepo { fullName: string; owner: string; @@ -117,15 +126,35 @@ async function fetchJson(url: string): Promise { } async function refresh(): Promise { - const [reposRaw, prsRaw] = await Promise.all([ + // Issues endpoint is optional — if it 5xxs or times out we still + // return repos+PRs; daily-issues binning just stays at zeros and the + // Contributions chart renders the PR portion only. + const [reposRaw, prsRaw, issuesResult] = await Promise.all([ fetchJson(REPOS_URL), fetchJson(PRS_URL), + fetchJson(ISSUES_URL).catch((err): UpstreamIssue[] => { + console.warn(`[gt/repositories] issues feed failed: ${String(err)}`); + return []; + }), ]); + const issuesRaw = issuesResult; const now = Date.now(); const weekAgo = now - WEEK_MS; const twoWeeksAgo = now - 2 * WEEK_MS; + // Day-bucket for the per-repo daily sparklines. `dayStartMs` is the UTC + // midnight epoch of "today"; bin index = floor((dayStart - prDayStart) / 86_400_000), + // valid when in [0, N-1] → most recent N days. Output arrays are + // oldest-first (index 0 = oldest, index N-1 = today) so the UI doesn't + // have to reverse. We compute both a 14-day spark (kept for back-compat) + // and a 30-day spark + 30-day filtered counts (merged, closed, total, + // active contributors) for the new 30-day activity panel. + const DAY_MS = 24 * 60 * 60 * 1000; + const todayStart = Math.floor(now / DAY_MS) * DAY_MS; + const THIRTY_DAYS_MS = 30 * DAY_MS; + const thirtyDaysAgo = now - THIRTY_DAYS_MS; + interface Agg { totalScore: number; totalPrCount: number; @@ -135,6 +164,17 @@ async function refresh(): Promise { prsLastWeek: number; contributors: Set; lastPrAt: number; + dailyPrs14d: number[]; + // 30-day window — all use `prCreatedAt` to define the window + prsLast30d: number; + mergedLast30d: number; + closedLast30d: number; + contributors30d: Set; + dailyPrs30d: number[]; + /** Issue-discovery bounties created per day over the last 30 days + * (oldest first). Sibling of dailyPrs30d. Populated from + * /api/gittensor.io/issues. */ + dailyIssues30d: number[]; } const aggMap = new Map(); const ensure = (k: string): Agg => { @@ -150,6 +190,13 @@ async function refresh(): Promise { prsLastWeek: 0, contributors: new Set(), lastPrAt: 0, + dailyPrs14d: new Array(14).fill(0), + prsLast30d: 0, + mergedLast30d: 0, + closedLast30d: 0, + contributors30d: new Set(), + dailyPrs30d: new Array(30).fill(0), + dailyIssues30d: new Array(30).fill(0), }; aggMap.set(key, a); } @@ -170,6 +217,45 @@ async function refresh(): Promise { if (t > a.lastPrAt) a.lastPrAt = t; if (t >= weekAgo) a.prsThisWeek += 1; else if (t >= twoWeeksAgo) a.prsLastWeek += 1; + // 30-day window — filters by `prCreatedAt`, then classifies by current state. + // PRs created in the last 30d whose state is currently OPEN don't count + // toward merged or closed — they're still pending and will show up in + // the live open-PR count from /api/repos/metadata. + if (t > 0 && t >= thirtyDaysAgo) { + a.prsLast30d += 1; + if (p.mergedAt) { + a.mergedLast30d += 1; + const author = p.author || p.githubId; + if (author) a.contributors30d.add(author); + } else if (p.prState && p.prState !== 'OPEN' && p.prState !== 'open') { + a.closedLast30d += 1; + } + } + // Per-day binning for the 14-day + 30-day sparklines + if (t > 0) { + const prDayStart = Math.floor(t / DAY_MS) * DAY_MS; + const daysAgo = Math.floor((todayStart - prDayStart) / DAY_MS); + if (daysAgo >= 0 && daysAgo < 14) { + a.dailyPrs14d[13 - daysAgo] += 1; + } + if (daysAgo >= 0 && daysAgo < 30) { + a.dailyPrs30d[29 - daysAgo] += 1; + } + } + } + + // Bin issue-discovery bounties by day. Each issue counts once on its + // `createdAt` date — status (completed / cancelled / open) is ignored + // since the chart shows submission volume, not outcomes. + for (const iss of issuesRaw) { + if (!iss.repositoryFullName || !iss.createdAt) continue; + const t = Date.parse(iss.createdAt); + if (!Number.isFinite(t) || t <= 0) continue; + const dayStart = Math.floor(t / DAY_MS) * DAY_MS; + const daysAgo = Math.floor((todayStart - dayStart) / DAY_MS); + if (daysAgo < 0 || daysAgo >= 30) continue; + const a = ensure(iss.repositoryFullName); + a.dailyIssues30d[29 - daysAgo] += 1; } const repos: GtRepo[] = reposRaw.map((r) => { @@ -199,6 +285,13 @@ async function refresh(): Promise { prsLastWeek, trendingPct, lastPrAt: a?.lastPrAt ? new Date(a.lastPrAt).toISOString() : null, + dailyPrs14d: a?.dailyPrs14d ?? new Array(14).fill(0), + prsLast30d: a?.prsLast30d ?? 0, + mergedLast30d: a?.mergedLast30d ?? 0, + closedLast30d: a?.closedLast30d ?? 0, + contributorsLast30d: a?.contributors30d.size ?? 0, + dailyPrs30d: a?.dailyPrs30d ?? new Array(30).fill(0), + dailyIssues30d: a?.dailyIssues30d ?? new Array(30).fill(0), }; }); diff --git a/src/app/api/repos/metadata/route.ts b/src/app/api/repos/metadata/route.ts new file mode 100644 index 0000000..b37e1b8 --- /dev/null +++ b/src/app/api/repos/metadata/route.ts @@ -0,0 +1,431 @@ +/* Bulk repo metadata fetched from GitHub. + * + * The `/api/sn74-repos` mirror only carries the SN74 *policy* (emission + * share, label multipliers, eligibility, etc.) — it has no description, + * topics, or language breakdown. The `/repositories` page needs both for + * its card / list / drawer surfaces, so this route fans out to the GitHub + * REST API once for every SN74 repo and caches the result in-memory for an + * hour (descriptions and language ratios change rarely). + * + * Output shape: `{ [fullName]: { description, langs: [[name, pct], …] } }`. */ + +import { NextResponse } from 'next/server'; +import { withRotation } from '@/lib/github'; +import { getLiveReposAsyncServer } from '@/lib/repos-server'; + +export const dynamic = 'force-dynamic'; + +const CACHE_TTL_MS = 60 * 60 * 1000; // 1h — metadata churn is slow +const PER_REPO_TIMEOUT_MS = 12_000; // per-repo cap so one slow call can't stall the whole batch +const CONCURRENCY = 4; // GitHub secondary rate limits punish bursts; throttle ourselves +/** How long to wait before re-trying a repo that came back without langs. + * Independent of CACHE_TTL_MS — a transient 5xx shouldn't sentence a repo + * to an hour of `—` in the UI. */ +const EMPTY_LANGS_RETRY_MS = 60_000; +/** Per-call retry budget for transient errors (5xx / network) inside a + * single refresh. Rate-limit handling already lives in withRotation. */ +const SUBCALL_RETRIES = 2; +const SUBCALL_BACKOFF_MS = 600; + +export interface RepoMeta { + description: string; + /** Languages sorted descending by byte share, expressed as percentages. */ + langs: Array<[string, number]>; + /** Live open pull request count from GitHub. -1 when the fetch failed. */ + openPrCount: number; + /** Daily count of GitHub issues opened on the repo over the last 30 + * days (oldest first, length 30). Index 0 = 29 days ago, 29 = today. + * Powers the Contributions chart's lower (issue) half. Empty array + * when the issues fetch failed. */ + dailyIssues30d: number[]; +} + +interface CacheEntry { + fetchedAt: number; + data: Record; +} + +let cache: CacheEntry | null = null; +let inflight: Promise | null = null; + +/** Run an async task list with bounded concurrency. */ +async function mapPool(items: T[], limit: number, fn: (item: T) => Promise): Promise>> { + const results: Array> = new Array(items.length); + let cursor = 0; + async function worker() { + while (true) { + const i = cursor++; + if (i >= items.length) return; + try { + results[i] = { status: 'fulfilled', value: await fn(items[i]) }; + } catch (err) { + results[i] = { status: 'rejected', reason: err }; + } + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + return results; +} + +function withTimeout(promise: Promise, ms: number, label: string): Promise { + return new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); + promise.then( + (v) => { clearTimeout(t); resolve(v); }, + (e) => { clearTimeout(t); reject(e); }, + ); + }); +} + +/** Count open PRs via Link-header pagination — call with per_page=1 and the + * "last" page number IS the total count. One API call, no full pagination. */ +function parseLastPage(linkHeader: string | undefined): number | null { + if (!linkHeader) return null; + const match = linkHeader.match(/<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"/); + return match ? parseInt(match[1], 10) : null; +} + +/** Retry a sub-call on transient errors (5xx / network). 404s and auth + * errors bubble immediately — no point retrying those. Rate-limit + * rotation already happens inside withRotation. */ +async function retrySubcall(fn: () => Promise): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt <= SUBCALL_RETRIES; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + const status = (err as { status?: number })?.status; + // 404, 401, 403 (after rotation exhaustion) — terminal, don't retry. + if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) { + throw err; + } + if (attempt < SUBCALL_RETRIES) { + await new Promise((r) => setTimeout(r, SUBCALL_BACKOFF_MS * (attempt + 1))); + } + } + } + throw lastErr; +} + +/** Track per-repo when we last attempted a fill. Stops the empty-langs + * retry from hammering GitHub when a repo genuinely has no langs (e.g. + * docs-only repo) — we wait EMPTY_LANGS_RETRY_MS between retries. */ +const lastAttemptAt = new Map(); + +/** Per-repo "issues.listForRepo succeeded since process start". Lets the + * partial-refresh helper distinguish "fetched and genuinely empty" + * (legit quiet repo) from "fetch failed → seeded with zeros" (needs + * retry). Without this, the empty-array sentinel is indistinguishable + * from a real empty result and we can't safely re-fetch. */ +const issuesFetched = new Set(); + +async function refresh(): Promise { + const { repos } = await getLiveReposAsyncServer(); + // Seed from the previous cache so a repo that fails *this* refresh keeps + // its last-known langs/description/openPrCount instead of disappearing + // for an hour. Per-field fallbacks below also merge with this so partial + // failures (e.g. pulls.list 403 but listLanguages 200) don't wipe out + // good fields that were just refreshed. + const map: Record = { ...(cache?.data ?? {}) }; + console.warn(`[repos/metadata] refresh begin — ${repos.length} repos`); + const t0 = Date.now(); + + // Per-field counters so the log line tells us *which* call is failing — + // helpful when GitHub rate-limits one endpoint but not others. + let okRepo = 0, okLang = 0, okPulls = 0, okIssues = 0; + let failRepo = 0, failLang = 0, failPulls = 0, failIssues = 0; + + // Day-bin helpers shared by every per-repo issues binning below. + const DAY_MS = 24 * 60 * 60 * 1000; + const now = Date.now(); + const todayStart = Math.floor(now / DAY_MS) * DAY_MS; + const thirtyDaysAgoIso = new Date(now - 30 * DAY_MS).toISOString(); + + const results = await mapPool(repos, CONCURRENCY, async (r) => { + const [owner, name] = r.fullName.split('/'); + if (!owner || !name) return null; + const key = r.fullName.toLowerCase(); + const prior = cache?.data[key]; + + // allSettled per sub-call so one rate-limit doesn't drop the whole + // repo. Each field independently falls back to the prior cached value + // when its specific call fails. Each call also has a small retry + // budget for transient 5xx / network hiccups (rate-limit rotation + // already happens inside withRotation). + lastAttemptAt.set(key, Date.now()); + const [repoResult, langResult, pullsResult, issuesResult] = await withTimeout( + Promise.allSettled([ + retrySubcall(() => withRotation((o) => o.repos.get({ owner, repo: name }))), + retrySubcall(() => withRotation((o) => o.repos.listLanguages({ owner, repo: name }))), + retrySubcall(() => + withRotation((o) => o.pulls.list({ owner, repo: name, state: 'open', per_page: 1 })), + ), + // 30-day issue history. GitHub's `issues` endpoint returns BOTH + // issues and PRs (PRs are technically issues with a + // `pull_request` field); we filter PRs out client-side. The + // `since` parameter filters by `updated_at` (not `created_at`), + // so this returns any issue *touched* in 30 days — including + // old issues with recent comments. We then bin by `created_at` + // and drop out-of-window ones. Wastes some bandwidth but + // simpler than paginating sorted-by-created. + // `per_page: 100` cap: at SN74 scale (~few/day) this is safe; + // a busy repo with >100 touched issues in 30 days would lose + // the oldest creates first (default sort = created desc). + retrySubcall(() => + withRotation((o) => + o.issues.listForRepo({ + owner, repo: name, + state: 'all', + since: thirtyDaysAgoIso, + per_page: 100, + }), + ), + ), + ]), + PER_REPO_TIMEOUT_MS, + `repos/metadata ${r.fullName}`, + ); + + let description = prior?.description ?? ''; + if (repoResult.status === 'fulfilled') { + description = repoResult.value.data.description ?? ''; + okRepo++; + } else { + failRepo++; + console.warn(`[repos/metadata] ${r.fullName} repos.get failed:`, errMsg(repoResult.reason)); + } + + let langs: Array<[string, number]> = prior?.langs ?? []; + if (langResult.status === 'fulfilled') { + const langEntries = Object.entries(langResult.value.data) as Array<[string, number]>; + const total = langEntries.reduce((s, [, v]) => s + (v || 0), 0) || 1; + langs = langEntries + .map(([n, bytes]) => [n, (bytes / total) * 100] as [string, number]) + .sort((a, b) => b[1] - a[1]); + okLang++; + } else { + failLang++; + console.warn(`[repos/metadata] ${r.fullName} listLanguages failed:`, errMsg(langResult.reason)); + } + + let openPrCount = prior?.openPrCount ?? -1; + if (pullsResult.status === 'fulfilled') { + const linkHeader = pullsResult.value.headers?.link as string | undefined; + const lastPage = parseLastPage(linkHeader); + openPrCount = lastPage ?? pullsResult.value.data.length; + okPulls++; + } else { + failPulls++; + console.warn(`[repos/metadata] ${r.fullName} pulls.list failed:`, errMsg(pullsResult.reason)); + } + + // Issue-creation sparkline. Drop entries with a `pull_request` field + // (GitHub returns PRs through this endpoint too) and bin by created_at + // into a 30-day oldest-first array. + let dailyIssues30d: number[] = prior?.dailyIssues30d ?? new Array(30).fill(0); + if (issuesResult.status === 'fulfilled') { + const data = issuesResult.value.data as Array<{ created_at?: string; pull_request?: unknown }>; + if (data.length === 100) { + // Hit the per_page cap → busier repo than expected, oldest + // creates may have been truncated. Surface in the log so we + // know when to add pagination. + console.warn(`[repos/metadata] ${r.fullName} issues.listForRepo returned 100 (cap) — older creates may be truncated`); + } + const bins = new Array(30).fill(0); + for (const it of data) { + if (it.pull_request) continue; // skip PRs + if (!it.created_at) continue; + const t = Date.parse(it.created_at); + if (!Number.isFinite(t) || t <= 0) continue; + const dayStart = Math.floor(t / DAY_MS) * DAY_MS; + const daysAgo = Math.floor((todayStart - dayStart) / DAY_MS); + if (daysAgo < 0 || daysAgo >= 30) continue; + bins[29 - daysAgo] += 1; + } + dailyIssues30d = bins; + issuesFetched.add(key); + okIssues++; + } else { + failIssues++; + console.warn(`[repos/metadata] ${r.fullName} issues.listForRepo failed:`, errMsg(issuesResult.reason)); + } + + return [r.fullName, { description, langs, openPrCount, dailyIssues30d }] as const; + }); + + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + const [fullName, meta] = result.value; + map[fullName.toLowerCase()] = meta; + } else if (result.status === 'rejected') { + // Only path here is withTimeout firing (the per-sub-call rejections + // are already swallowed by allSettled). Prior cache entry, if any, + // is preserved via the initial map spread. + console.warn('[repos/metadata] per-repo timeout:', errMsg(result.reason)); + } + } + console.warn( + `[repos/metadata] refresh done in ${Date.now() - t0}ms — ` + + `repo ${okRepo}/${okRepo + failRepo} · langs ${okLang}/${okLang + failLang} · ` + + `pulls ${okPulls}/${okPulls + failPulls} · issues ${okIssues}/${okIssues + failIssues}`, + ); + + const entry: CacheEntry = { fetchedAt: Date.now(), data: map }; + cache = entry; + return entry; +} + +function errMsg(e: unknown): string { + if (e instanceof Error) return e.message; + return String(e); +} + +/** Re-fetch just the per-repo sub-calls that came back empty / failed + * on the last attempt — covers transient 5xx / rate-limit cases + * without forcing a full hourly refresh. Throttled by `lastAttemptAt` + * per repo. Now handles both `langs` (empty array sentinel) and + * `issues` (never-fetched-since-startup sentinel via `issuesFetched`). */ +let partialInflight: Promise | null = null; +async function refreshMissing(): Promise { + if (partialInflight) return partialInflight; + if (!cache) return; + const { repos } = await getLiveReposAsyncServer(); + const now = Date.now(); + const stale = repos + .map((r) => { + const key = r.fullName.toLowerCase(); + const entry = cache?.data[key]; + const needsLangs = !entry || entry.langs.length === 0; + const needsIssues = !issuesFetched.has(key); + if (!needsLangs && !needsIssues) return null; + const last = lastAttemptAt.get(key) ?? 0; + if (now - last < EMPTY_LANGS_RETRY_MS) return null; + return { r, key, needsLangs, needsIssues }; + }) + .filter((x): x is { r: typeof repos[number]; key: string; needsLangs: boolean; needsIssues: boolean } => x !== null); + if (stale.length === 0) return; + partialInflight = (async () => { + console.warn(`[repos/metadata] partial refresh — ${stale.length} repo(s) (langs/issues backfill)`); + const results = await mapPool(stale, CONCURRENCY, async ({ r, key, needsLangs, needsIssues }) => { + const [owner, name] = r.fullName.split('/'); + if (!owner || !name) return null; + lastAttemptAt.set(key, Date.now()); + + // Run only the sub-calls this repo actually needs. allSettled so a + // langs failure doesn't block issues backfill (or vice-versa). + const tasks: Array> = []; + if (needsLangs) { + tasks.push( + retrySubcall(() => withRotation((o) => o.repos.listLanguages({ owner, repo: name }))), + ); + } else { + tasks.push(Promise.resolve(null)); + } + if (needsIssues) { + tasks.push( + retrySubcall(() => + withRotation((o) => + o.issues.listForRepo({ + owner, repo: name, + state: 'all', + since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + per_page: 100, + }), + ), + ), + ); + } else { + tasks.push(Promise.resolve(null)); + } + const [langRes, issuesRes] = await withTimeout(Promise.allSettled(tasks), PER_REPO_TIMEOUT_MS, `repos/metadata partial ${r.fullName}`); + + let langs: Array<[string, number]> | null = null; + if (needsLangs && langRes.status === 'fulfilled' && langRes.value) { + // Same shape as the main-refresh listLanguages handler. + const data = (langRes.value as { data: Record }).data; + const langEntries = Object.entries(data) as Array<[string, number]>; + const total = langEntries.reduce((s, [, v]) => s + (v || 0), 0) || 1; + langs = langEntries + .map(([n, bytes]) => [n, (bytes / total) * 100] as [string, number]) + .sort((a, b) => b[1] - a[1]); + } else if (needsLangs) { + console.warn(`[repos/metadata] partial ${r.fullName} langs failed:`, errMsg(langRes.status === 'rejected' ? langRes.reason : 'unexpected')); + } + + let issueBins: number[] | null = null; + if (needsIssues && issuesRes.status === 'fulfilled' && issuesRes.value) { + const DAY_MS = 24 * 60 * 60 * 1000; + const todayStart = Math.floor(Date.now() / DAY_MS) * DAY_MS; + const bins = new Array(30).fill(0); + const data = (issuesRes.value as { data: Array<{ created_at?: string; pull_request?: unknown }> }).data; + for (const it of data) { + if (it.pull_request) continue; + if (!it.created_at) continue; + const t = Date.parse(it.created_at); + if (!Number.isFinite(t) || t <= 0) continue; + const dayStart = Math.floor(t / DAY_MS) * DAY_MS; + const daysAgo = Math.floor((todayStart - dayStart) / DAY_MS); + if (daysAgo < 0 || daysAgo >= 30) continue; + bins[29 - daysAgo] += 1; + } + issueBins = bins; + } else if (needsIssues) { + console.warn(`[repos/metadata] partial ${r.fullName} issues failed:`, errMsg(issuesRes.status === 'rejected' ? issuesRes.reason : 'unexpected')); + } + + return { key, fullName: r.fullName, langs, issueBins }; + }); + + let langsRecovered = 0; + let issuesRecovered = 0; + for (const result of results) { + if (result.status !== 'fulfilled' || !result.value) continue; + const { key, langs, issueBins } = result.value; + const entry = cache?.data[key]; + if (!entry) continue; + if (langs && langs.length > 0) { + entry.langs = langs; + langsRecovered++; + } + if (issueBins) { + entry.dailyIssues30d = issueBins; + issuesFetched.add(key); + issuesRecovered++; + } + } + console.warn(`[repos/metadata] partial refresh recovered langs ${langsRecovered}, issues ${issuesRecovered} / ${stale.length}`); + })().finally(() => { + partialInflight = null; + }); + return partialInflight; +} + +async function getCached(): Promise { + const now = Date.now(); + if (cache && now - cache.fetchedAt < CACHE_TTL_MS) { + // Fire-and-forget: opportunistically backfill any repos still missing + // langs. Doesn't block this response — next refetch picks up the + // recovered entries. Throttled per-repo so it can't burst-call. + void refreshMissing(); + return cache; + } + if (inflight) return inflight; + inflight = refresh().finally(() => { + inflight = null; + }); + return inflight; +} + +export async function GET() { + try { + const entry = await getCached(); + return NextResponse.json({ + fetched_at: new Date(entry.fetchedAt).toISOString(), + count: Object.keys(entry.data).length, + repos: entry.data, + }); + } catch (err) { + return NextResponse.json({ error: String(err), repos: {} }, { status: 502 }); + } +} diff --git a/src/app/api/sn74-emission/route.ts b/src/app/api/sn74-emission/route.ts new file mode 100644 index 0000000..2f5ca49 --- /dev/null +++ b/src/app/api/sn74-emission/route.ts @@ -0,0 +1,244 @@ +/* SN74 daily TAO emission, proxied from TaoMarketCap. + * + * Combines two upstream calls: + * 1. /internal/v1/subnets/74/ → alpha price + miners_tao_per_day + * 2. /internal/v1/subnets/neurons/74/ → per-UID alpha_per_day for all 256 UIDs + * + * From those we derive: + * alpha_price = latest_snapshot.subnet_moving_price (TAO/alpha) + * per-UID TAO/day = neuron.alpha_per_day × alpha_price + * recycleTaoPerDay = UID 0 (Gittensor's recycle sink) + * treasuryTaoPerDay = UID 111 (Gittensor's issues treasury) + * activeTaoPerDay = sum of all other UIDs + * totalSubnetTaoPerDay = recycle + treasury + active = the true on-chain + * daily emission to SN74 + * + * Cached in-memory for 60s with in-flight dedup so concurrent client + * refreshes never burst the upstream. + */ + +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 CACHE_TTL_MS = 60_000; +const FETCH_TIMEOUT_MS = 10_000; +const RECYCLE_UID = 0; +const TREASURY_UID = 111; +/** Bittensor block cadence: 12s/block → 7200 blocks/day. */ +const BLOCKS_PER_DAY = 7200; +/** rao → alpha (1 alpha = 1e9 rao). */ +const RAO_PER_ALPHA = 1e9; + +interface UpstreamSnapshot { + latest_snapshot?: { + miners_tao_per_day?: number | string; + /** Current instantaneous sqrt price (DEX-style). Squaring gives the + * alpha→TAO conversion that TaoMarketCap displays on neurons rows. */ + alpha_sqrt_price?: number | string; + /** EMA price — older/smoothed. Used as fallback. */ + subnet_moving_price?: number | string; + dtao?: { + daily_alpha_emission?: number | string; + daily_burn?: number | string; + effective_daily_emission?: number | string; + /** Subnet owner's alpha cut per block, in rao. Taken off the top + * before per-UID distribution, so it doesn't appear in the neurons + * endpoint. Daily owner alpha = owner_cut_per_block × 7200 / 1e9. */ + owner_cut_per_block?: number | string; + }; + }; +} + +interface UpstreamNeuron { + uid?: number; + alpha_per_day?: number | string | null; + is_validator?: boolean | null; + is_miner?: boolean | null; + hotkey?: string | null; +} + +export interface Sn74EmissionSnapshot { + /** Headline emission — matches TaoMarketCap's "Emissions/Day": + * daily_alpha_emission × price = full subnet emission per day. */ + totalTaoPerDay: number; + /** Miner-side daily slice (= (total − owner) / 2). Includes recycle + * (UID 0) and treasury (UID 111) as sub-components since both are + * on-chain miner UIDs. Matches TaoMarketCap's "Miner/Day". */ + minerTaoPerDay: number; + /** Validator-side daily slice (= (total − owner) / 2). The owner cut + * flows to the owner_hotkey (a validator) on-chain but is shown + * separately as `ownerTaoPerDay`. Matches TaoMarketCap's "Validator/Day". */ + validatorTaoPerDay: number; + /** Per-UID recycle emission to UID 0 (Gittensor recycle sink). + * Sub-component of `minerTaoPerDay`. */ + recycleTaoPerDay: number; + /** Per-UID treasury emission to UID 111 (issues treasury). + * Sub-component of `minerTaoPerDay`. */ + treasuryTaoPerDay: number; + /** Per-UID sum of active (non-recycle, non-treasury) miner alpha. + * Used for per-repo TAO math — the "claimable for contributors" + * slice. NOT shown in the headline cards (those use the + * TaoMarketCap-style 50/50 split via `minerTaoPerDay`). */ + activeMinerTaoPerDay: number; + /** Subnet owner's daily TAO cut, derived from + * `dtao.owner_cut_per_block × 7200 blocks/day`. Paid as elevated + * dividends to the owner_hotkey UID on-chain but surfaced separately + * here to match TaoMarketCap's "Owner/Day" card. */ + ownerTaoPerDay: number; + /** Count of UIDs in each category, for context on the cards. */ + minerCount: number; + validatorCount: number; + /** Alpha → TAO price used for the per-UID conversion. */ + alphaPriceInTao: number; + /** TaoMarketCap's `miners_tao_per_day` for cross-reference (a narrower + * metric — not the per-UID miner sum we compute). */ + minersTaoPerDayUpstream: number | null; + /** Back-compat alias (was `taoPerDay`); same as `totalTaoPerDay`. */ + taoPerDay: number; + alphaPerDay: number | null; + effectiveAlphaPerDay: number | null; + alphaBurnPerDay: number | null; + fetched_at: number; +} + +// 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; + +function num(v: unknown): number | null { + if (v == null) return null; + const n = typeof v === 'number' ? v : Number.parseFloat(String(v)); + return Number.isFinite(n) ? n : null; +} + +async function fetchJson(url: string): Promise { + const r = await fetch(url, { + cache: 'no-store', + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + headers: { accept: 'application/json' }, + }); + if (!r.ok) throw new Error(`upstream ${url} ${r.status}`); + return (await r.json()) as T; +} + +async function refresh(): Promise { + const [subnetData, neurons] = await Promise.all([ + fetchJson(SUBNET_URL), + fetchJson(NEURONS_URL), + ]); + const snap = subnetData.latest_snapshot ?? {}; + // TaoMarketCap renders the per-UID τ/Day column as + // alpha_per_day × (alpha_sqrt_price)² + // — the instantaneous DEX price, not the EMA `subnet_moving_price`. + // Match that so our numbers align pixel-for-pixel with their site. + // Fall back to `subnet_moving_price` if the sqrt-price field is missing. + const sqrtPrice = num(snap.alpha_sqrt_price); + const movingPrice = num(snap.subnet_moving_price); + const alphaPrice = sqrtPrice != null ? sqrtPrice * sqrtPrice : movingPrice; + if (alphaPrice == null) throw new Error('upstream missing alpha_sqrt_price and subnet_moving_price'); + + // Classify each UID and accumulate alpha: + // UID 0 → recycle sink + // UID 111 → issues treasury + // is_validator → validator (Bittensor bundles subnet-owner cut into + // validator dividends, so this also covers the owner) + // is_miner → miner (PR contributor — what we surface as "claimable") + let recycleAlpha = 0; + let treasuryAlpha = 0; + let minerAlpha = 0; + let validatorCount = 0; + let minerCount = 0; + for (const n of neurons) { + const a = num(n.alpha_per_day) ?? 0; + if (n.uid === RECYCLE_UID) { + recycleAlpha += a; + } else if (n.uid === TREASURY_UID) { + treasuryAlpha += a; + } else if (n.is_validator) { + // validator UID alpha is not summed — the headline validator + // figure is derived from `(gross − owner) / 2`. We still count + // UIDs for the card sub-text. + validatorCount++; + } else if (n.is_miner) { + minerAlpha += a; + minerCount++; + } + } + + // Headline emission math — match TaoMarketCap's authoritative figures: + // Emissions/Day = daily_alpha_emission × price (gross, 7200α) + // Owner/Day = owner_cut_per_block × 7200 × price + // Miner/Day = (gross − owner) / 2 + // Validator/Day = (gross − owner) / 2 + // TMC uses the 50/50 chain split between miner-side and validator-side + // emission, post-owner-cut. Owner is shown as a separate card even + // though on-chain it's paid to the owner_hotkey UID (which is also a + // validator). The recycle (UID 0) and treasury (UID 111) per-UID sums + // are kept around for the granular sub-breakdown but they're + // sub-components of the miner-side slice — they shouldn't be added + // on top of `minerTaoPerDay`. + const ownerCutPerBlock = num(snap.dtao?.owner_cut_per_block); + const ownerAlphaPerDay = ownerCutPerBlock != null + ? (ownerCutPerBlock * BLOCKS_PER_DAY) / RAO_PER_ALPHA + : 0; + const dailyAlphaEmission = num(snap.dtao?.daily_alpha_emission); + const grossAlphaPerDay = dailyAlphaEmission != null + ? dailyAlphaEmission / RAO_PER_ALPHA + : 0; + const totalTaoPerDay = grossAlphaPerDay * alphaPrice; + const ownerTaoPerDay = ownerAlphaPerDay * alphaPrice; + const participantTaoPerDay = Math.max(0, totalTaoPerDay - ownerTaoPerDay); + const minerTaoPerDay = participantTaoPerDay / 2; + const validatorTaoPerDay = participantTaoPerDay / 2; + // Per-UID granular values — used only for the optional sub-breakdown + // and for per-repo TAO math (which needs the active-miner slice). + const recycleTaoPerDay = recycleAlpha * alphaPrice; + const treasuryTaoPerDay = treasuryAlpha * alphaPrice; + const activeMinerTaoPerDay = minerAlpha * alphaPrice; + + const next: Sn74EmissionSnapshot = { + totalTaoPerDay, + minerTaoPerDay, + validatorTaoPerDay, + activeMinerTaoPerDay, + recycleTaoPerDay, + treasuryTaoPerDay, + ownerTaoPerDay, + minerCount, + validatorCount, + alphaPriceInTao: alphaPrice, + minersTaoPerDayUpstream: num(snap.miners_tao_per_day), + taoPerDay: totalTaoPerDay, + alphaPerDay: num(snap.dtao?.daily_alpha_emission), + effectiveAlphaPerDay: num(snap.dtao?.effective_daily_emission), + alphaBurnPerDay: num(snap.dtao?.daily_burn), + fetched_at: Date.now(), + }; + cacheV4 = 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; + }); + return inflightV4; +} + +export async function GET() { + try { + const fresh = await getCached(); + return NextResponse.json({ ...fresh, source: 'live' }); + } catch (err) { + if (cacheV4) return NextResponse.json({ ...cacheV4, source: 'stale', error: String(err) }); + return NextResponse.json({ error: String(err) }, { status: 502 }); + } +} diff --git a/src/app/repositories/_components/Avatar.tsx b/src/app/repositories/_components/Avatar.tsx new file mode 100644 index 0000000..bd17f38 --- /dev/null +++ b/src/app/repositories/_components/Avatar.tsx @@ -0,0 +1,46 @@ +'use client'; + +import React from 'react'; +import styles from '../page.module.css'; + +interface AvatarProps { + fullName: string; + size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + pxSize?: number; + className?: string; +} + +const sizeClass: Record, string> = { + xs: styles.avatarXs, + sm: styles.avatarSm, + md: styles.avatarMd, + lg: styles.avatarLg, + xl: styles.avatarXl, +}; + +const defaultPx: Record, number> = { + xs: 32, + sm: 48, + md: 64, + lg: 96, + xl: 128, +}; + +export default function Avatar({ fullName, size = 'md', pxSize, className }: AvatarProps) { + const owner = fullName.split('/')[0] ?? fullName; + const px = pxSize ?? defaultPx[size]; + const src = `https://github.com/${encodeURIComponent(owner)}.png?size=${px}`; + const cls = [styles.avatar, sizeClass[size], className].filter(Boolean).join(' '); + // Native — next/image needs predeclared remote patterns and the rest + // of the app (AppHeader, UserMenu, etc.) uses the same direct-fetch pattern. + return ( + // eslint-disable-next-line @next/next/no-img-element + {owner} + ); +} diff --git a/src/app/repositories/_components/CompareModal.tsx b/src/app/repositories/_components/CompareModal.tsx new file mode 100644 index 0000000..349cdb7 --- /dev/null +++ b/src/app/repositories/_components/CompareModal.tsx @@ -0,0 +1,948 @@ +'use client'; + +import React, { useEffect } from 'react'; +import styles from '../page.module.css'; +import Avatar from './Avatar'; +import { LABEL_COLORS, LABEL_KEYS, LANG_COLORS, formatLangPct } from '../_lib/colors'; +import { + competitionLevel, + decisionScore, + effectiveLabelMult, + eligibilityRisk, + expectedTAOPerPR, + formatTAO, + mergeSpeedLevel, + openSlotPressure, + repoDailyTAO, + repoIssueTAO, + repoMaintainerTAO, + repoPerMaintainerTAO, + repoPRTAO, + rewardSignal, + type RepoRow, + type StrategyKey, +} from '../_lib/incentives'; + +interface CompareModalProps { + open: boolean; + repos: RepoRow[]; + subnetTAO: number; + strategy: StrategyKey; + onClose: () => void; + onRemove: (full: string) => void; +} + +export default function CompareModal({ open, repos, subnetTAO, strategy, onClose, onRemove }: CompareModalProps) { + // Lock body scroll while open + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = prev; + }; + }, [open]); + + // ESC closes — matches HTML's global keydown handler that closed drawer + + // palette + compare panel together. + 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 cols = Math.max(1, repos.length); + // Side-by-side columns: left label rail + N panel columns + const colStyle: React.CSSProperties = { + display: 'grid', + gridTemplateColumns: `minmax(120px, 160px) repeat(${cols}, minmax(170px, 1fr))`, + gap: 12, + marginBottom: 12, + }; + + const allLabels = new Set(); + repos.forEach((r) => r.labels && Object.keys(r.labels).forEach((l) => allLabels.add(l))); + // Show canonical labels first (in protocol-declared order), then any extras. + const canonicalKeys: string[] = (LABEL_KEYS as readonly string[]).filter((l) => allLabels.has(l)); + const extraKeys: string[] = Array.from(allLabels).filter((l) => !(LABEL_KEYS as readonly string[]).includes(l)); + const labelList: string[] = [...canonicalKeys, ...extraKeys]; + + return ( + <> +
+
+
+
+
+ Side-by-side comparison +
+

Where should I send my next PR?

+
+ +
+ +
+ {/* Mobile stacked layout — sticky per-repo tabs + vertical sections */} +
+ +
+ + {/* Desktop side-by-side grid (≥768px) */} +
+ {/* Header row: repo cards */} +
+
+ {repos.map((r) => ( +
+
+
+ +
+
+ {r.owner}/ +
+
+ {r.name} +
+
+
+ +
+
+ {r.isSelf ? your repo : null} + {r.trusted ? trusted : null} + {r.share === 0 ? benchmark : null} +
+
+ ))} +
+ + ( + <> +
+ {formatTAO(repoDailyTAO(r, subnetTAO))} + τ/day +
+
{(r.share * 100).toFixed(3)}% × 90% OSS
+ + )} /> + + ( + (r.maintCut || 0) === 0 ? ( +
none
+ ) : ( + <> +
{formatTAO(repoMaintainerTAO(r, subnetTAO))}
+
+ {(r.maintCut * 100).toFixed(0)}% off the top · {r.maintainerCount} maint ·{' '} + {formatTAO(repoPerMaintainerTAO(r, subnetTAO))} ea +
+ {r.demoMaint ? demo : null} + + ) + )} /> + + ( + r.share === 0 ? ( +
+ ) : ( + <> +
{formatTAO(repoPRTAO(r, subnetTAO))}
+
+ {(r.maintCut || 0) > 0 + ? `${((1 - r.maintCut) * (1 - r.issue) * 100).toFixed(0)}% (after the cut)` + : `${((1 - r.issue) * 100).toFixed(0)}% of slice`} + {' · TAO/day'} +
+ + ) + )} /> + + ( + r.share === 0 || r.issue === 0 ? ( +
+ ) : ( + <> +
{formatTAO(repoIssueTAO(r, subnetTAO))}
+
+ {(r.maintCut || 0) > 0 + ? `${((1 - r.maintCut) * r.issue * 100).toFixed(0)}% (after the cut)` + : `${(r.issue * 100).toFixed(0)}% of slice`} + {' · TAO/day'} +
+ + ) + )} /> + + ( + <> +
+ {r.activity.merged30d} + merged +
+
+ {r.activity.openPRs} open · {r.activity.contribs} contribs +
+ + )} /> + + { + const cred = r.activity.merged30d + r.activity.closed30d > 0 + ? r.activity.merged30d / (r.activity.merged30d + r.activity.closed30d) + : 0; + const color = + cred >= 0.85 ? 'var(--color-moss-400)' : + cred >= 0.7 ? 'var(--color-enh)' : + 'var(--color-refact)'; + return ( +
{(cred * 100).toFixed(0)}%
+ ); + }} /> + + {/* Practical decision factors heading */} +
+
+ Practical factors +
+ {repos.map((r) =>
)} +
+ + { + if (r.share === 0) return
; + const v = expectedTAOPerPR(r, strategy, subnetTAO); + return ( + <> +
+ {formatTAO(v)} τ +
+
+ ~ pool ÷ merge rate ·{' '} + {strategy !== 'none' && strategy !== 'issue' + ? `×${effectiveLabelMult(r, strategy).toFixed(2)} ${strategy}` + : 'no strategy applied'} +
+ + ); + }} /> + + { + const c = competitionLevel(r); + return ( + <> +
+ + {c.label} +
+
{c.desc}
+ + ); + }} /> + + { + const s = mergeSpeedLevel(r); + return ( + <> +
{s.label}
+
+ {s.desc} demo +
+ + ); + }} /> + + { + const e = eligibilityRisk(r); + return ( + <> +
{e.level}
+
{e.text}
+ + ); + }} /> + + { + const p = openSlotPressure(r); + return ( + <> +
+ + {p.label} + demo +
+
{p.text}
+ + ); + }} /> + + ( +
+ {r.langs.slice(0, 3).map(([n, p]) => ( + + + {n} {formatLangPct(p)} + + ))} + {r.langs.length === 0 ? : null} +
+ )} /> + + {/* Label multipliers section */} +
+
+ Label multipliers +
+ {repos.map((r) =>
)} +
+ + {labelList.length === 0 ? ( +
+
+ {repos.map((r) => ( +
+ No multipliers configured. +
+ ))} +
+ ) : labelList.map((label) => { + const color = LABEL_COLORS[label] ?? { fg: 'var(--fg-muted)', soft: 'rgba(146,152,163,0.10)' }; + const highlight = strategy === label; + return ( +
+
+ + {label} +
+ {repos.map((r) => { + const m = r.labels?.[label] ?? r.defaultLabel; + const configured = r.labels?.[label] !== undefined; + const isPenalty = m < 1; + const isHigh = m >= 1.3; + const barPct = Math.min(100, (m / 2.0) * 100); + return ( +
+
+ + ×{m.toFixed(2)} + + {configured ? null : ( + + default + + )} +
+
+ +
+
+ ); + })} +
+ ); + })} + + { + if (r.eligibility) { + return ( + <> + {Object.entries(r.eligibility).map(([k, v]) => ( +
+ {k} + {v} +
+ ))} + + ); + } + return ( +
+ Defaults: 3 valid PRs · cred ≥ 0.80 +
+ ); + }} /> + + ( + r.trusted ? ( +
+ + + + Yes +
+ ) : ( +
No
+ ) + )} /> + + {/* Verdict row */} +
+
+ {strategy === 'none' ? 'For miners' : 'For your strategy'} +
+ {repos.map((r) => ( +
+ +
+ ))} +
+
{/* /cmpDesktop */} + + {/* Decision summary sits below both layouts */} + +
+
+ + ); +} + +function CompareRow({ + label, + repos, + colStyle, + render, +}: { + label: string; + repos: RepoRow[]; + colStyle: React.CSSProperties; + render: (r: RepoRow) => React.ReactNode; +}) { + return ( +
+
+ {label} +
+ {repos.map((r) => ( +
+ {render(r)} +
+ ))} +
+ ); +} + +function Verdict({ r, strategy, subnetTAO }: { r: RepoRow; strategy: StrategyKey; subnetTAO: number }) { + if (r.share === 0) { + return <>Benchmark repo — PRs don't pay TAO. Calibration target only.; + } + if (strategy === 'issue') { + if (r.issue === 0) return <>No issue-discovery stream. Skip for issue strategy.; + return ( + <> + {(r.issue * 100).toFixed(0)}% of slice for issues:{' '} + {formatTAO(repoIssueTAO(r, subnetTAO))} TAO/day in the issue pool. + + ); + } + if (strategy !== 'none') { + const m = effectiveLabelMult(r, strategy); + const sig = subnetTAO * rewardSignal(r, strategy); + if (m >= 1.3) { + return ( + <> + ×{m.toFixed(2)} for {strategy} — premium. + Max signal: {formatTAO(sig)} TAO/day if you dominate the repo's PR scoring. + + ); + } + if (m >= 1.0) { + return ( + <> + ×{m.toFixed(2)} for {strategy} — neutral. + Max signal: {formatTAO(sig)} TAO/day. + + ); + } + if (m >= 0.5) { + return ( + <> + ×{m.toFixed(2)} for {strategy} — partial. + Cap: {formatTAO(sig)} TAO/day. + + ); + } + return ( + <> + ×{m.toFixed(2)} for {strategy} — heavily penalized. + Cap: {formatTAO(sig)} TAO/day. Avoid for {strategy} work. + + ); + } + if (r.share >= 0.05) { + return ( + <> + Top share. {formatTAO(repoDailyTAO(r, subnetTAO))} TAO/day in the slice, but expect competition. + + ); + } + if (r.share >= 0.02) { + return ( + <> + Mid-tier. {formatTAO(repoDailyTAO(r, subnetTAO))} TAO/day — quieter than the top. + + ); + } + return ( + <> + Tail share — {formatTAO(repoDailyTAO(r, subnetTAO))} TAO/day. Good for credibility-building (3 valid PRs at cred ≥ 0.80). + + ); +} + +function DecisionSummary({ repos, strategy, subnetTAO }: { repos: RepoRow[]; strategy: StrategyKey; subnetTAO: number }) { + const eligible = repos.filter((r) => r.share > 0); + if (eligible.length < 2) return null; + const scored = eligible + .map((r) => ({ repo: r, score: decisionScore(r, strategy, subnetTAO) })) + .sort((a, b) => b.score - a.score); + const top = scored[0]; + const others = scored.slice(1); + const r = top.repo; + + const reasons: React.ReactNode[] = []; + const exp = expectedTAOPerPR(r, strategy, subnetTAO); + if (exp > 0) { + reasons.push( + + yields ~{formatTAO(exp)} τ per merged PR + , + ); + } + if (r.activity.medianMergeHours != null && r.activity.medianMergeHours <= 24) { + reasons.push( + + merges in ~{r.activity.medianMergeHours}h + , + ); + } + const cred = (r.activity.merged30d || 0) / Math.max(1, (r.activity.merged30d || 0) + (r.activity.closed30d || 0)); + if (cred >= 0.85) { + reasons.push( + + {(cred * 100).toFixed(0)}% repo merge rate + , + ); + } + if ((r.activity.userOpenPRs || 0) === 0) reasons.push(no open-PR pressure for you); + if (strategy !== 'none' && strategy !== 'issue' && effectiveLabelMult(r, strategy) >= 1.25) { + reasons.push( + + ×{effectiveLabelMult(r, strategy).toFixed(2)} {strategy} multiplier + , + ); + } + + return ( +
+
+
+ + + +
+
+
+ Suggested pick +
+
+ + + {top.repo.owner}/ + {top.repo.name} + +
+ {reasons.length > 0 ? ( +
+ {reasons.map((r2, i) => ( + {i > 0 ? ' · ' : ''}{r2} + ))} +
+ ) : null} + {others.length > 0 ? ( +
+ Runners-up: + {others.slice(0, 3).map((o, i) => ( + + {o.repo.owner}/ + {o.repo.name} ({o.score.toFixed(3)}) + {i < Math.min(others.length, 3) - 1 ? ' ·' : ''} + + ))} +
+ ) : null} +
+ Composite score: expected per-PR × merge speed × credibility × open-slot pressure. A heuristic, not a guarantee. +
+
+
+
+ ); +} + +/* ────────────────────────────────────────────────────────────────────── + * Mobile stacked variant (< 768px) + * + * Instead of fighting horizontal scroll, give each repo its own vertical + * panel with every metric inside. A sticky tab strip at the top jumps + * between repos via anchor scroll. Ported from `renderCompareMobile` in + * the HTML prototype. + * ────────────────────────────────────────────────────────────────────── */ +function CompareMobile({ + repos, + labelList, + strategy, + subnetTAO, + onRemove, +}: { + repos: RepoRow[]; + labelList: string[]; + strategy: StrategyKey; + subnetTAO: number; + onRemove: (full: string) => void; +}) { + return ( + <> +
+ {repos.map((r, i) => ( + + + {r.name} + + ))} +
+ +
+ {repos.map((r, i) => { + const cred = + r.activity.merged30d + r.activity.closed30d > 0 + ? r.activity.merged30d / (r.activity.merged30d + r.activity.closed30d) + : 0; + const credColor = + cred >= 0.85 ? 'var(--color-moss-400)' : + cred >= 0.7 ? 'var(--color-enh)' : + 'var(--color-refact)'; + const comp = competitionLevel(r); + const merge = mergeSpeedLevel(r); + const pressure = openSlotPressure(r); + const risk = eligibilityRisk(r); + return ( +
+ {/* Repo header */} +
+
+ +
+
{r.owner}/
+
+ {r.name} +
+
+ {r.isSelf ? your repo : null} + {r.trusted ? trusted : null} + {r.share === 0 ? benchmark : null} + {r.issue === 1 ? issues only : null} + {r.issue > 0 && r.issue < 1 ? mixed : null} +
+
+
+ +
+ + {/* Headline TAO */} +
+
Daily TAO emission
+
+ {formatTAO(repoDailyTAO(r, subnetTAO))} + τ/day +
+
+ {(r.share * 100).toFixed(3)}% × 90% OSS +
+
+ + {/* Pool grid: maint / activity / PR / issue / credibility */} +
+ {(r.maintCut || 0) > 0 ? ( +
+
Maintainer cut
+
+ {formatTAO(repoMaintainerTAO(r, subnetTAO))} +
+
+ {(r.maintCut * 100).toFixed(0)}% · {r.maintainerCount} maint +
+
+ {formatTAO(repoPerMaintainerTAO(r, subnetTAO))} ea + {r.demoMaint ? demo : null} +
+
+ ) : ( +
+
Maintainer cut
+
none
+
+ )} +
+
PR activity · 30d
+
+ {r.activity.merged30d} + merged +
+
+ {r.activity.openPRs} open · {r.activity.contribs} contrib +
+
+ {r.share > 0 ? ( +
+
PR slice
+
+ {formatTAO(repoPRTAO(r, subnetTAO))} +
+
+ {(r.maintCut || 0) > 0 + ? `${((1 - r.maintCut) * (1 - r.issue) * 100).toFixed(0)}% (after cut)` + : `${((1 - r.issue) * 100).toFixed(0)}% of slice`} +
+
+ ) : null} + {r.share > 0 && r.issue > 0 ? ( +
+
Issue discovery slice
+
+ {formatTAO(repoIssueTAO(r, subnetTAO))} +
+
+ {(r.maintCut || 0) > 0 + ? `${((1 - r.maintCut) * r.issue * 100).toFixed(0)}% (after cut)` + : `${(r.issue * 100).toFixed(0)}% of slice`} +
+
+ ) : null} +
+
Merge rate · 30d
+
+ {(cred * 100).toFixed(0)}% +
+
+
+ + {/* Practical factors */} + {r.share > 0 ? ( + <> +
+ Practical factors +
+
+
+
Per merged PR
+
+ {formatTAO(expectedTAOPerPR(r, strategy, subnetTAO))} + τ +
+
expected yield
+
+
+
Competition
+
+ + {comp.label} +
+
{comp.desc}
+
+
+
Time to merge
+
{merge.label}
+
{merge.desc}
+
+
+
Open-PR pressure
+
+ + {pressure.label} +
+
+ {r.activity.userOpenPRs || 0} of your PRs open +
+
+
+
+
Eligibility risk
+
{risk.level}
+
{risk.text}
+
+ + ) : null} + + {/* Languages */} + {r.langs.length > 0 ? ( +
+
Primary languages
+
+ {r.langs.slice(0, 4).map(([n, p]) => ( + + + {n} {formatLangPct(p)} + + ))} +
+
+ ) : null} + + {/* Label multipliers */} + {labelList.length > 0 ? ( +
+
Label multipliers
+
+ {labelList.map((label) => { + const color = LABEL_COLORS[label] ?? { fg: 'var(--fg-subtle)', soft: '' }; + const m = r.labels?.[label] ?? r.defaultLabel; + const configured = r.labels?.[label] !== undefined; + const isPenalty = m < 1; + const isHigh = m >= 1.3; + const barPct = Math.min(100, (m / 2.0) * 100); + const highlight = strategy === label; + return ( +
+ + {label} + +
+ +
+ + ×{m.toFixed(2)} + +
+ ); + })} +
+
+ ) : ( +
+ No label multipliers configured. +
+ )} + + {/* Verdict */} +
+
+ {strategy === 'none' ? 'For miners' : 'For your strategy'} +
+
+ +
+
+
+ ); + })} +
+ + ); +} diff --git a/src/app/repositories/_components/CompareTray.tsx b/src/app/repositories/_components/CompareTray.tsx new file mode 100644 index 0000000..46ec10a --- /dev/null +++ b/src/app/repositories/_components/CompareTray.tsx @@ -0,0 +1,100 @@ +'use client'; + +import React from 'react'; +import styles from '../page.module.css'; +import Avatar from './Avatar'; +import { formatTAO, repoDailyTAO, type RepoRow } from '../_lib/incentives'; + +interface CompareTrayProps { + rows: RepoRow[]; + subnetTAO: number; + onRemove: (fullName: string) => void; + onClear: () => void; + onOpen: () => void; +} + +const MAX = 4; + +export default function CompareTray({ rows, subnetTAO, onRemove, onClear, onOpen }: CompareTrayProps) { + const n = rows.length; + return ( +
0 ? styles.open : ''}`} aria-hidden={n === 0}> +
+
+ Comparing +
+
+ {rows.map((r) => ( +
+ + + {r.owner}/ + {r.name} + + + {formatTAO(repoDailyTAO(r, subnetTAO))} τ/d + + +
+ ))} + {n < MAX ? ( + + {MAX - n} more slot{n === MAX - 1 ? '' : 's'} + + ) : null} +
+ + +
+
+ ); +} diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx new file mode 100644 index 0000000..4e40d86 --- /dev/null +++ b/src/app/repositories/_components/Drawer.tsx @@ -0,0 +1,454 @@ +'use client'; + +import React, { useEffect } from 'react'; +import styles from '../page.module.css'; +import Avatar from './Avatar'; +import { LABEL_COLORS, LANG_COLORS, LANG_NAME_ICONS, formatLangPct } from '../_lib/colors'; +import LangIcon from './LangIcon'; +import { + formatTAO, + repoDailyTAO, + repoIssueTAO, + repoMaintainerTAO, + repoPerMaintainerTAO, + repoPRTAO, + type RepoRow, +} from '../_lib/incentives'; + +interface DrawerProps { + open: boolean; + row: RepoRow | null; + subnetTAO: number; + isInCompare: boolean; + /** Whether /api/repos/metadata has resolved (regardless of whether this + * specific repo has a description / languages on GitHub). Lets the drawer + * distinguish "still loading" from "loaded but empty". */ + metadataLoaded: boolean; + onClose: () => void; + onToggleCompare: (full: string) => void; +} + +export default function Drawer({ + open, + row, + subnetTAO, + isInCompare, + metadataLoaded, + onClose, + onToggleCompare, +}: DrawerProps) { + // Esc closes + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!row) { + return ( + <> +
+
{r.isSelf ? you : null} @@ -934,3 +959,94 @@ function Leaderboard({
); } + +/* ============== Per-repo top earners (inside BarInspector) ============== */ +/** Subtle metallic tints for ranks 1–3 (gold / silver / bronze). */ +const EARNER_TINTS = [ + { fg: '#d4a857', glow: 'rgba(212, 168, 87, 0.14)' }, // gold + { fg: '#a8b3bd', glow: 'rgba(168, 179, 189, 0.12)' }, // silver + { fg: '#b08763', glow: 'rgba(176, 135, 99, 0.12)' }, // bronze +] as const; + + +/** Top OSS contributors for the selected repo (top 3 by score, eligible + * only). Rendered as a second row inside the BarInspector when a repo + * segment is in focus. Empty array → row is not rendered. */ +function TopEarnersRow({ owner, name }: { owner: string; name: string }) { + const { data: repoMiners } = useQuery({ + queryKey: ['gt-repo-miners', owner, name], + queryFn: async ({ signal }) => { + const r = await fetch(`/api/gt/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/miners`, { signal }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }, + refetchInterval: 120_000, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + const top = useMemo(() => { + /* Eligibility is computed by the route from the validator's per-repo + * RepoEvaluation.is_eligible flag (https://api.gittensor.io/repos/.../miners). + * Only surface contributors who currently meet the repo's thresholds. */ + const list = repoMiners?.ossContributions ?? []; + return list + .filter((m) => m.isEligible === true) + .filter((m) => (m.score ?? 0) > 0) + .sort((a, b) => b.score - a.score) + .slice(0, 3); + }, [repoMiners]); + // Distinguish "still loading" from "loaded, none eligible" — the loading + // case stays silent so the inspector doesn't flicker, but once the fetch + // resolves we surface an explicit empty state so the user can tell the + // repo simply has nobody eligible right now (vs. data not arriving). + if (!repoMiners) return null; + if (top.length === 0) { + return ( +
+ Top earners +
+ + + + + + No miners currently eligible on this repo +
+
+ ); + } + return ( +
+ Top earners +
+ {top.map((m, i) => { + const tint = EARNER_TINTS[i] ?? EARNER_TINTS[2]; + return ( + + + {i + 1} + + + @{m.githubUsername} + + {m.score.toFixed(1)} + + + ); + })} +
+
+ ); +} From 86b21dacbe8cd12512be049584f4558cd78ca179 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 04:08:04 +0200 Subject: [PATCH 08/18] feat: active miners section in repo drawer with Treemap + Ribbon views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "Active miners" section between Activity and Languages in the drawer. Two views toggled via chip group: * Treemap (default) — hand-laid grid (leader takes half the canvas, others fill the right half) so 1-5 miners always render with readable 1.25:1-ish aspect tiles. Eligible miners get the Linear indigo palette graded by rank; ineligibles get a subtle gray wash so they fade into the background. Wide-short tiles switch to a horizontal avatar+name layout so the score/meta don't get clipped. * Ribbon — vertical leaderboard with accent strip, rank, avatar, name, share bar, TAO/score. Eligibles first, ineligibles dimmed. Each tile shows avatar with a credibility ring (Linear green/yellow/ red palette by repo PR cred), uid in the top-right, name below avatar, score + share/TAO at the bottom. Light-mode palette tuned so white text stays legible across all eligible tiers and dark text reads on the muted ineligible tiles. All tile drop/text shadows removed for a flat Linear-style look. --- src/app/repositories/_components/Drawer.tsx | 498 ++++++++++++++++++- src/app/repositories/page.module.css | 511 +++++++++++++++++++- 2 files changed, 1002 insertions(+), 7 deletions(-) diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx index 3c47fc8..eb85db3 100644 --- a/src/app/repositories/_components/Drawer.tsx +++ b/src/app/repositories/_components/Drawer.tsx @@ -1,6 +1,7 @@ 'use client'; -import React, { useEffect } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import styles from '../page.module.css'; import Avatar from './Avatar'; import { LABEL_COLORS, LANG_COLORS, LANG_NAME_ICONS, formatLangPct } from '../_lib/colors'; @@ -14,6 +15,8 @@ import { repoPRTAO, type RepoRow, } from '../_lib/incentives'; +import { squarify } from '../_lib/squarify'; +import type { RepoMiner, RepoMinersResponse } from '@/types/entities'; interface DrawerProps { open: boolean; @@ -341,6 +344,16 @@ export default function Drawer({
+ {/* Miner contributors — per-repo ranked treemap from the validator. + * Eligible miners are full-opacity; historical-but-ineligible + * miners are dimmed in-place rather than split into a separate + * section. */} + + {/* Languages — always render the section so the drawer's shape * matches the HTML; show a loading-style placeholder while the * /api/repos/metadata endpoint is still fetching. */} @@ -349,12 +362,12 @@ export default function Drawer({ Primary languages
{r.langs.length > 0 ? ( -
+
{r.langs.map(([n, p]) => { const color = LANG_COLORS[n] ?? 'var(--fg-subtle)'; const spec = LANG_NAME_ICONS[n.toLowerCase()]; return ( -
+
- {n} - {formatLangPct(p)} + {n} + {formatLangPct(p)}
); })} @@ -428,6 +441,481 @@ export default function Drawer({ ); } +function minerKey(m: RepoMiner): string { + return `${m.githubId || m.githubUsername}-${m.uid ?? m.githubUsername}`; +} + +const TOP_ACTIVE_MINERS_LIMIT = 5; + +function repoWorkScore(m: RepoMiner): number { + return Math.max( + m.score ?? 0, + m.baseScore ?? 0, + m.collateralScore ?? 0, + (m.totalPrCount ?? m.prCount ?? 0) * 0.25, + ); +} + +function tileScale(value: number): number { + return Math.pow(Math.max(value, 0), 0.35); +} + +function minerTileWeight(m: RepoMiner, topEligibleScore: number): number { + const finalScore = Math.max(m.score ?? 0, 0); + const topEligibleUnit = tileScale(topEligibleScore); + if (m.isEligible === true) { + return Math.max(tileScale(finalScore), topEligibleUnit > 0 ? topEligibleUnit * 0.24 : 0, 0.75); + } + + const baseRepoScore = Math.max( + m.baseScore ?? 0, + finalScore, + 0.15, + ); + if (topEligibleScore <= 0) return Math.max(tileScale(baseRepoScore), 0.75); + + // Keep historical/ineligible miners visible, but visually subordinate to + // every eligible tile in the top-five set. The power scale keeps a huge + // leader dominant without compressing the rest of the map into slivers. + const damped = tileScale(baseRepoScore) * 0.55; + return Math.min(Math.max(damped, 0.35), Math.max(0.35, topEligibleUnit * 0.18)); +} + +function useNarrowTreemap(): boolean { + const [isNarrow, setIsNarrow] = useState(false); + + useEffect(() => { + const mq = window.matchMedia('(max-width: 640px)'); + const update = () => setIsNarrow(mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); + }, []); + + return isNarrow; +} + +function credibilityPct(miner: RepoMiner): number | null { + if (miner.credibility == null || !Number.isFinite(miner.credibility)) return null; + const pct = miner.credibility <= 1 ? miner.credibility * 100 : miner.credibility; + return Math.max(0, Math.min(100, Math.round(pct))); +} + +/** Credibility tint — borrows Linear's status palette so the badge feels + * at home in this product family. Linear uses subtle, slightly + * desaturated tones graded by completion state: deep green for "done", + * yellow for "in progress", red for "canceled", gray for "backlog". */ +function credibilityColor(pct: number | null): string { + if (pct == null) return '#95a2b3'; // Linear: backlog / unknown + if (pct >= 90) return '#26b574'; // Linear: done (deep green) + if (pct >= 75) return '#4cb782'; // Linear: in review (soft green) + if (pct >= 60) return '#f2c94c'; // Linear: in progress (yellow) + if (pct >= 40) return '#f5a623'; // Linear: warning amber + return '#eb5757'; // Linear: blocked / canceled (red) +} + +function MinerCredAvatar({ miner, size }: { miner: RepoMiner; size: 'xs' | 'sm' | 'md' | 'lg' }) { + const pct = credibilityPct(miner); + const color = credibilityColor(pct); + return ( + + + {pct != null ? {pct}% : null} + + ); +} + +/** Per-repo miner contributors panel with a squarified treemap. */ +function MinersSection({ owner, name, repoPRTAOValue }: { owner: string; name: string; repoPRTAOValue: number }) { + const { data, isLoading, isError } = useQuery({ + queryKey: ['gt-repo-miners', owner, name], + queryFn: async ({ signal }) => { + const r = await fetch(`/api/gt/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/miners`, { signal }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }, + refetchInterval: 120_000, + staleTime: 60_000, + refetchOnWindowFocus: false, + }); + + const { allRows, totalRows, totalEligibleScore, top1Pct, conc, eligibleCount, ineligibleCount } = useMemo(() => { + const list = (data?.ossContributions ?? []) + .filter((m) => m.isEligible === true || repoWorkScore(m) > 0) + .slice() + .sort((a, b) => { + if ((a.isEligible ? 1 : 0) !== (b.isEligible ? 1 : 0)) return a.isEligible ? -1 : 1; + return (b.score ?? 0) - (a.score ?? 0) || repoWorkScore(b) - repoWorkScore(a); + }); + const topRows = list.slice(0, TOP_ACTIVE_MINERS_LIMIT); + const totalRowsCount = list.length; + const eligible = topRows.filter((m) => m.isEligible); + const ineligible = topRows.length - eligible.length; + const totalEligible = eligible.reduce((s, m) => s + m.score, 0); + const top1 = totalEligible > 0 ? ((eligible[0]?.score ?? 0) / totalEligible) * 100 : 0; + const concentration = + top1 >= 50 ? { label: 'concentrated', color: '#c5503a' } : + top1 >= 30 ? { label: 'top-heavy', color: '#eab308' } : + top1 >= 20 ? { label: 'balanced', color: '#9eb872' } : + { label: 'distributed', color: '#7fb992' }; + return { + allRows: topRows, + totalRows: totalRowsCount, + totalEligibleScore: totalEligible, + top1Pct: top1, + conc: concentration, + eligibleCount: eligible.length, + ineligibleCount: ineligible, + }; + }, [data]); + + const containerStyle = { padding: '16px 20px', borderBottom: '1px solid var(--soft-border, rgba(255,255,255,0.06))' } as const; + + if (isLoading) { + return ( +
+
+ Active miners +
+
Loading miners…
+
+ ); + } + if (isError) { + return ( +
+
+ Active miners +
+
Failed to load miner contributors.
+
+ ); + } + if (allRows.length === 0) { + return ( +
+
+ Active miners +
+
+ Benchmark repo — no miners. +
+
+ ); + } + + return ( +
+
+ + Active miners{' '} + + top {TOP_ACTIVE_MINERS_LIMIT} by repo score + + +
+ +
+
+ {allRows.length} + + active · top earner takes{' '} + {top1Pct.toFixed(0)}% + + + {conc.label} + +
+
+ + + +
+ + + eligible + + + historical + + + + Top five only · tile size follows repo score + +
+
+ ); +} + +/** Squarified treemap of repo contributors. Tile area is based on the + * miner's score for this repo, with ineligible/historical miners damped so + * eligible miners remain visually dominant while every status still has a + * place in the map. */ +function MinerTreemap({ + miners, + totalEligibleScore, + repoPRTAOValue, +}: { + miners: RepoMiner[]; + totalEligibleScore: number; + repoPRTAOValue: number; +}) { + const isNarrow = useNarrowTreemap(); + const W = isNarrow ? 600 : 1000; + const H = isNarrow ? 990 : 560; + const tiles = useMemo(() => { + const topEligibleScore = Math.max(0, ...miners.filter((m) => m.isEligible).map((m) => m.score ?? 0)); + return squarify( + miners.map((m) => ({ w: minerTileWeight(m, topEligibleScore), data: m })), + 0, + 0, + W, + H, + ); + }, [H, W, miners]); + // Rank among ELIGIBLE only — drives the leader crown and the lime tier + // shading. Ineligible miners don't get a "leader" treatment regardless + // of their historical score. + const eligibleRankByUser = useMemo(() => { + const ranks = new Map(); + miners + .filter((m) => m.isEligible) + .forEach((m, i) => ranks.set(minerKey(m), i + 1)); + return ranks; + }, [miners]); + + return ( +
+ {tiles.map((t) => { + const m = t.data; + const eligible = m.isEligible === true; + const eligRank = eligibleRankByUser.get(minerKey(m)) ?? null; + const share = eligible && totalEligibleScore > 0 ? m.score / totalEligibleScore : 0; + const tao = eligible ? share * repoPRTAOValue : 0; + const xPct = (t.x / W) * 100; + const yPct = (t.y / H) * 100; + const wPct = (t.w / W) * 100; + const hPct = (t.h / H) * 100; + // Thresholds tuned against the 1000x560 reference canvas so text + // only appears when it has enough real room in the responsive tile. + const sizeClass = + t.w >= 300 && t.h >= 190 ? 'xl' : + t.w >= 220 && t.h >= 160 ? 'lg' : + t.w >= 160 && t.h >= 125 ? 'md' : + t.w >= 112 && t.h >= 96 ? 'sm' : + 'xs'; + // Wide-short tiles can't fit the vertical 3-row layout (avatar + // on top, name below, score+meta at the bottom) — the bottom + // gets clipped. Switch to a horizontal layout where the avatar + // and name share the top row, freeing vertical room for the + // score/meta to render in full. + const wideShort = (sizeClass === 'lg' || sizeClass === 'md') && t.w > t.h * 1.6; + // Tier styling lives in CSS so light/dark themes can swap palettes + // without recomputing colors here. textTone is inherited from the + // tile's CSS `color` per tier. + const tierClass = + !eligible ? styles.mtileTierIneligible : + eligRank === 1 ? styles.mtileTierLeader : + eligRank !== null && eligRank <= 3 ? styles.mtileTierTop : + styles.mtileTierMid; + const textTone = 'inherit'; + const sizeClassName = + sizeClass === 'xl' ? styles.mtileXl : + sizeClass === 'lg' ? styles.mtileLg : + sizeClass === 'md' ? styles.mtileMd : + sizeClass === 'sm' ? styles.mtileSm : + styles.mtileXs; + const isLeader = eligRank === 1; + const cls = [styles.mtile, sizeClassName, tierClass].filter(Boolean).join(' '); + const credPct = credibilityPct(m); + const credText = credPct == null ? 'unknown credibility' : `${credPct}% repo PR credibility`; + const title = eligible + ? `@${m.githubUsername} · ${credText} · repo score ${m.score.toFixed(2)} · ${formatTAO(tao)} T/Day · ${(share * 100).toFixed(1)}% · ${m.prCount} merged · eligible` + : `@${m.githubUsername} · ${credText} · base score ${(m.baseScore ?? 0).toFixed(2)} · ${m.prCount} merged · ineligible`; + return ( + + + + ); + })} +
+ ); +} + +function MinerTileContent({ + sizeClass, + wideShort, + miner, + eligible, + isLeader, + tao, + share, + textTone, +}: { + sizeClass: 'xl' | 'lg' | 'md' | 'sm' | 'xs'; + wideShort: boolean; + miner: RepoMiner; + eligible: boolean; + isLeader: boolean; + tao: number; + share: number; + textTone: string; +}) { + const visibleScore = eligible ? miner.score : (miner.baseScore ?? 0); + const scoreNode = ( + <> + {visibleScore.toFixed(1)} + {eligible ? 'score' : 'base score'} + + ); + const shareText = eligible ? `${(share * 100).toFixed(1)}%` : 'ineligible'; + const taoText = eligible ? `${formatTAO(tao)} T/Day` : '0 T/Day'; + // Let the score text inherit from the tile's tier color so it adapts + // to dark/light mode automatically (was hardcoded `#edf0f2` and + // disappeared on light backgrounds). + const scoreColor: string | undefined = undefined; + + // Top-right meta: uid + optional crown. Sits on the top row next to + // the avatar without stealing horizontal space from the name (which + // gets its own full-width row in mtileMid below). + const topMeta = miner.uid != null || isLeader ? ( +
+ {miner.uid != null ? ( +
uid {miner.uid}
+ ) : null} + {isLeader ?
: null} +
+ ) : null; + + if (wideShort && (sizeClass === 'lg' || sizeClass === 'md')) { + // Horizontal layout: avatar+name share the top row, score+meta fill + // the bottom row. Saves the vertical space that the standard + // 3-section layout was eating up. + return ( + <> +
+ +
+ {miner.githubUsername} +
+ {topMeta} +
+
+
+ {scoreNode} +
+
+ {shareText} + · + {taoText} +
+
+ + ); + } + if (sizeClass === 'xl' || sizeClass === 'lg') { + return ( + <> +
+ + {topMeta} +
+
+
{miner.githubUsername}
+
+
+
+ {scoreNode} +
+
+ {shareText} + · + {taoText} +
+
+ + ); + } + if (sizeClass === 'md') { + return ( + <> +
+ + {topMeta} +
+
+
{miner.githubUsername}
+
+
+
+ {scoreNode} +
+
+ {shareText} · {taoText} +
+
+ + ); + } + if (sizeClass === 'sm') { + return ( +
+
+ +
+ {miner.githubUsername} +
+
+
+ ); + } + // xs — identity-only. Hide UID/star from the visible layout because those + // controls steal the horizontal space the username needs in tiny tiles. + return ( +
+
+ +
+ {miner.githubUsername} +
+
+
+ ); +} + function ActivityStat({ value, label, tone }: { value: number; label: string; tone: 'strong' | 'dim' }) { return (
diff --git a/src/app/repositories/page.module.css b/src/app/repositories/page.module.css index 10b0204..b3935a9 100644 --- a/src/app/repositories/page.module.css +++ b/src/app/repositories/page.module.css @@ -193,8 +193,12 @@ display: inline-flex; align-items: center; gap: 6px; + text-decoration: none !important; +} +.priBtn:hover, .priBtn:focus, .priBtn:focus-visible { + background: var(--btn-primary-hover-bg); + text-decoration: none !important; } -.priBtn:hover { background: var(--btn-primary-hover-bg); } /* Light-mode primary stays on app vars (which already re-theme). */ .secBtn { @@ -209,10 +213,12 @@ align-items: center; gap: 6px; transition: background 100ms, color 100ms; + text-decoration: none !important; } -.secBtn:hover { +.secBtn:hover, .secBtn:focus, .secBtn:focus-visible { background: var(--app-elev); color: var(--fg); + text-decoration: none !important; } .ghostBtn { @@ -481,6 +487,10 @@ display: flex; align-items: center; gap: 10px; + /* Allow the per-repo earners row (flex-basis: 100%) to wrap onto its + * own line below the main flex row. Existing children don't have any + * shrink-trigger conditions that would cause them to wrap. */ + flex-wrap: wrap; background: var(--app-surface); border: 1px solid var(--soft-border); border-radius: 7px; @@ -488,6 +498,472 @@ min-height: 64px; transition: border-color 150ms, background 150ms; } + +/* ─── Per-repo top earners (rendered inline inside .barInspector) ───── */ +.inspectorEarners { + /* Label sits on its own row above the chip list. The block still + * lives inside the inspector flex row, so the whole earner column + * stays compact next to the stats group. */ + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + min-width: 0; +} +.inspectorEarnersLabel { + font-size: 9.5px; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--fg-subtle); + flex-shrink: 0; +} +.inspectorEarnersList { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + min-width: 0; +} +.inspectorEarnersEmpty { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--fg-subtle); + font-style: italic; +} +.inspectorEarnersEmpty svg { + color: var(--color-refact, #c5503a); + opacity: 0.85; + flex-shrink: 0; +} +.inspectorEarnerChip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 7px 3px 4px; + border-radius: 999px; + background: var(--app-elev); + 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; +} +.inspectorEarnerChip:hover { + border-color: rgba(255, 255, 255, 0.14); + background: var(--app-surface); +} +.inspectorEarnerRank { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 4px; + border: 1px solid; + font-size: 9.5px; + font-weight: 600; + flex-shrink: 0; +} +.inspectorEarnerName { + font-size: 11px; + color: var(--fg-muted); + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.inspectorEarnerScore { + font-size: 10.5px; + font-weight: 500; +} + +.minersHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + flex-wrap: wrap; +} +.minersHeaderMain { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.minersHeaderCount { + font-size: 16px; + font-weight: 600; + color: var(--fg-default); + letter-spacing: -0.02em; +} +.minersHeaderTag { + font-size: 9.5px; + font-family: ui-monospace, monospace; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 7px; + border-radius: 3px; + border: 1px solid; +} + +/* ─── Drawer miner treemap ─────────────────────────────────────────── */ +.mtileContainer { + position: relative; + width: 100%; + aspect-ratio: 5 / 2.7; + min-height: 260px; + max-height: 360px; + border-radius: 7px; + overflow: hidden; + border: 1px solid var(--soft-border); + background: var(--app-deep, var(--app-surface)); +} +.mtile { + position: absolute; + border: 1px solid; + box-sizing: border-box; + overflow: hidden; + transition: filter 100ms, z-index 0s; + cursor: pointer; + display: flex; + flex-direction: column; + /* `flex-start` so the avatar/name section anchors to the top instead + * of floating in the middle. The bottom block uses `margin-top: auto` + * to stick to the floor. */ + justify-content: flex-start; + min-width: 0; + min-height: 0; + color: inherit; + text-decoration: none; +} +.mtile, +.mtile:hover, +.mtile:focus, +.mtile:focus-visible { + text-decoration: none !important; +} +.mtile:hover { + filter: brightness(1.18); + z-index: 4; +} +/* ─── Tile tier palettes (Linear-indigo for eligibles, neutral for not) ─── */ +.mtileTierLeader { + background: rgba(94, 106, 210, 0.42); + border-color: rgba(94, 106, 210, 0.55); + color: #ffffff; +} +.mtileTierTop { + background: rgba(94, 106, 210, 0.24); + border-color: rgba(94, 106, 210, 0.32); + color: #f7f8f8; +} +.mtileTierMid { + background: rgba(94, 106, 210, 0.12); + border-color: rgba(94, 106, 210, 0.22); + color: #f4f6f5; +} +.mtileTierIneligible { + /* Subtle wash so the tile sits quietly behind eligible tiles. */ + background: rgba(120, 125, 135, 0.05); + border-color: rgba(120, 125, 135, 0.14); + color: #d5d9df; +} +/* Light-mode: solid darker indigo so the tile reads against the lighter + * page background. White text stays legible across all eligible tiers; + * ineligible flips to dark text on a soft neutral. */ +[data-theme="light"] .mtileTierLeader { + background: rgba(94, 106, 210, 0.92); + border-color: rgba(94, 106, 210, 1); + color: #ffffff; +} +[data-theme="light"] .mtileTierTop { + background: rgba(94, 106, 210, 0.68); + border-color: rgba(94, 106, 210, 0.85); + color: #ffffff; +} +[data-theme="light"] .mtileTierMid { + /* Bumped from 0.42 → 0.55 so white text is legible at this tier too, + * matching the leader/top tiers' text color for a uniform look. */ + background: rgba(94, 106, 210, 0.55); + border-color: rgba(94, 106, 210, 0.72); + color: #ffffff; +} +[data-theme="light"] .mtileTierIneligible { + /* Very light wash + dark text so scores read clearly. */ + background: rgba(120, 125, 135, 0.07); + border-color: rgba(120, 125, 135, 0.20); + color: #2b2e36; +} +.mtileXl { padding: 14px 14px; } +.mtileLg { padding: 10px 11px; } +.mtileMd { padding: 7px 8px; } +.mtileSm { padding: 5px 7px; } +.mtileXs { padding: 3px 5px; } +.mtileTop { + display: flex; + align-items: flex-start; + gap: 6px; + justify-content: space-between; + min-width: 0; +} +.mtileTopMeta { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 7px; + margin-left: auto; + min-width: 0; + flex-shrink: 0; +} +.mtileCrown { + color: #fde047; + font-size: 14px; + line-height: 1; +} +.mtileMid { + margin: 6px 0 4px; + min-width: 0; +} +.mtileIdentityStack { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + /* flex:1 makes the column claim all remaining width after the + * right-aligned topMeta — without it the stack sized to its widest + * intrinsic child (the avatar) and squeezed the name. */ + flex: 1 1 0; + min-width: 0; +} +.mtileNameRow .mtileName { + /* Single-line ellipsis on the full-row name. Username fits in the + * tile's content width directly without the uid stealing space. */ + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mtileIdentityRow { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + flex: 1; +} +.mtileIdentityText { + min-width: 0; + flex: 1; +} +.mtileName { + max-width: 100%; + font-size: 13px; + font-weight: 650; + letter-spacing: -0.005em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mtileNameSmall { + font-size: 11.5px; + line-height: 1.15; +} +.mtileCompactName { + width: 100%; + max-width: 100%; + /* Single-line ellipsis instead of mid-word wrapping — "jakearmstrong" + * was being broken into "jakearmstro…" on a new line, which looked + * worse than a clean truncation. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + line-height: 1.15; +} +.mtileNameTiny { + width: 100%; + max-width: 100%; + font-size: 9px; + line-height: 1.15; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; +} +/* Avatars inside tiles render as circles — uses the global Avatar img + * directly so we don't have to introduce a new prop just for this view. */ +.mtile img { + border-radius: 50%; +} +.mtileAvatarWrap { + --cred-color: #7b8494; + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 2px; + border: 1.5px solid var(--cred-color); + border-radius: 999px; + background: rgba(0, 0, 0, 0.18); +} +.mtileAvatarWrap img { + display: block; + border: 0; +} +.mtileCredBadge { + position: absolute; + right: -8px; + bottom: -5px; + min-width: 22px; + height: 14px; + padding: 0 4px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--cred-color); + color: #fff; + border: 1px solid rgba(0, 0, 0, 0.28); + font-size: 8.5px; + font-weight: 700; + line-height: 1; +} +.mtileUid { + font-size: 10px; + color: inherit; + opacity: 0.68; + white-space: nowrap; +} +.mtileBottom { + min-width: 0; + overflow: hidden; + padding-bottom: 1px; + /* Pins this row to the bottom of the tile so the avatar/name stay at + * the top regardless of how much vertical space is left over. */ + margin-top: auto; +} +.mtileNameRow { + /* Name sits directly below the avatar row, anchored to the top. + * Spans full content width so a username like `jakearmstrong` gets + * the entire tile to itself instead of competing with the uid. */ + margin-top: 8px; + min-width: 0; +} +/* Horizontal layout for wide-short tiles: avatar + name on one row, + * uid/crown pushed to the far right. Saves the vertical space that the + * standard 3-section layout was eating up. */ +.mtileTopHorizontal { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.mtileHorizontalName { + font-size: 12px; + font-weight: 500; + letter-spacing: -0.005em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; + min-width: 0; +} +.mtileTao { + font-size: 14.5px; + color: inherit; + font-weight: 600; + letter-spacing: -0.01em; + white-space: nowrap; + line-height: 1.2; +} +.mtileTaoUnit { + font-size: 10px; + font-weight: 400; + color: inherit; + opacity: 0.72; + margin-left: 1px; +} +.mtileMeta { + font-size: 10.5px; + color: inherit; + opacity: 0.78; + margin-top: 2px; + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mtileSep { color: inherit; opacity: 0.4; } +.mtileCompact { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; + gap: 7px; +} +.mtileCompactIdentity { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + overflow: hidden; +} +.mtileTiny { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; + overflow: hidden; +} +.mtileTinyIdentity { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + overflow: hidden; +} +.mtileTiny .mtileAvatarWrap { + margin-right: 7px; + margin-bottom: 2px; +} +.mtileTiny .mtileCredBadge { + right: -7px; + bottom: -4px; + min-width: 20px; + height: 13px; + font-size: 8px; + padding: 0 3px; +} +.mtileLegendDot { + width: 7px; + height: 7px; + border-radius: 50%; + display: inline-block; +} + +@media (max-width: 640px) { + .mtileContainer { + aspect-ratio: 1 / 1.65; + min-height: 520px; + max-height: 720px; + } +} .barInspector.isActive { border-color: var(--accent-glow); background: var(--accent-subtle); @@ -1860,3 +2336,34 @@ color: rgba(255,255,255,0.95); box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08); } + +.drawerLangGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 18px; + row-gap: 7px; +} +.drawerLangRow { + display: flex; + align-items: center; + gap: 9px; + min-width: 0; +} +.drawerLangName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12.5px; +} +.drawerLangPct { + flex-shrink: 0; + font-size: 11.5px; +} + +@media (max-width: 430px) { + .drawerLangGrid { + grid-template-columns: 1fr; + } +} From 24fb15ad2c7ed9e08d6b9796d106ef143f742629 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 04:26:08 +0200 Subject: [PATCH 09/18] feat: consolidate slice + merge rate into a 4-column drawer row Combines PR slice, Issue discovery slice, Merge rate, and Resolved into a single 4-column grid under the TAO emission block. Drops the now-duplicate Merge rate / Resolved row that sat at the bottom of the Activity section. Also renames the miner treemap legend's "historical" swatch to "ineligible" so the wording matches the tile text. --- src/app/repositories/_components/Drawer.tsx | 59 +++++++++------------ 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx index eb85db3..07f493e 100644 --- a/src/app/repositories/_components/Drawer.tsx +++ b/src/app/repositories/_components/Drawer.tsx @@ -264,7 +264,7 @@ export default function Drawer({
+
+
+ Merge rate · 30d +
+
+ {(cred * 100).toFixed(0)}% +
+
+ merged ÷ resolved +
+
+
+
+ Resolved +
+
+ {r.activity.merged30d + r.activity.closed30d} +
+
+ PRs · last 30d +
+
) : null}
@@ -309,39 +331,6 @@ export default function Drawer({
-
-
-
- Merge rate · 30d -
-
- {(cred * 100).toFixed(0)}% -
-
-
-
- Resolved -
-
- {r.activity.merged30d + r.activity.closed30d} PRs -
-
-
{/* Miner contributors — per-repo ranked treemap from the validator. @@ -647,7 +636,7 @@ function MinersSection({ owner, name, repoPRTAOValue }: { owner: string; name: s eligible - historical + ineligible From 16fc15f9f7554ba3033bed89d13c67e8b5606008 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 04:58:59 +0200 Subject: [PATCH 10/18] fix: remove unused miner counts --- src/app/repositories/_components/Drawer.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx index 07f493e..612da64 100644 --- a/src/app/repositories/_components/Drawer.tsx +++ b/src/app/repositories/_components/Drawer.tsx @@ -532,7 +532,7 @@ function MinersSection({ owner, name, repoPRTAOValue }: { owner: string; name: s refetchOnWindowFocus: false, }); - const { allRows, totalRows, totalEligibleScore, top1Pct, conc, eligibleCount, ineligibleCount } = useMemo(() => { + const { allRows, totalEligibleScore, top1Pct, conc } = useMemo(() => { const list = (data?.ossContributions ?? []) .filter((m) => m.isEligible === true || repoWorkScore(m) > 0) .slice() @@ -541,9 +541,7 @@ function MinersSection({ owner, name, repoPRTAOValue }: { owner: string; name: s return (b.score ?? 0) - (a.score ?? 0) || repoWorkScore(b) - repoWorkScore(a); }); const topRows = list.slice(0, TOP_ACTIVE_MINERS_LIMIT); - const totalRowsCount = list.length; const eligible = topRows.filter((m) => m.isEligible); - const ineligible = topRows.length - eligible.length; const totalEligible = eligible.reduce((s, m) => s + m.score, 0); const top1 = totalEligible > 0 ? ((eligible[0]?.score ?? 0) / totalEligible) * 100 : 0; const concentration = @@ -553,12 +551,9 @@ function MinersSection({ owner, name, repoPRTAOValue }: { owner: string; name: s { label: 'distributed', color: '#7fb992' }; return { allRows: topRows, - totalRows: totalRowsCount, totalEligibleScore: totalEligible, top1Pct: top1, conc: concentration, - eligibleCount: eligible.length, - ineligibleCount: ineligible, }; }, [data]); From 6818aebb2eddebb73b86f34db542c240c2c2f9c3 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 05:19:42 +0200 Subject: [PATCH 11/18] fix: address repositories review notes --- .../repositories/_components/CompareTray.tsx | 13 ++++++++++--- .../_components/MarketSection.tsx | 14 ++++++++++---- src/app/repositories/_components/RepoCard.tsx | 2 +- .../repositories/_components/RepoListRow.tsx | 2 +- src/app/repositories/_lib/squarify.ts | 19 +++++++++++++++++-- src/app/repositories/page.module.css | 1 + src/components/Dropdown.tsx | 9 ++++++++- 7 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/app/repositories/_components/CompareTray.tsx b/src/app/repositories/_components/CompareTray.tsx index 46ec10a..e3231fa 100644 --- a/src/app/repositories/_components/CompareTray.tsx +++ b/src/app/repositories/_components/CompareTray.tsx @@ -17,8 +17,10 @@ const MAX = 4; export default function CompareTray({ rows, subnetTAO, onRemove, onClear, onOpen }: CompareTrayProps) { const n = rows.length; + if (n === 0) return null; + return ( -
0 ? styles.open : ''}`} aria-hidden={n === 0}> +
Clear -
diff --git a/src/app/repositories/_components/MarketSection.tsx b/src/app/repositories/_components/MarketSection.tsx index 84c9f73..517dfda 100644 --- a/src/app/repositories/_components/MarketSection.tsx +++ b/src/app/repositories/_components/MarketSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import styles from '../page.module.css'; import Avatar from './Avatar'; @@ -95,9 +95,15 @@ export default function MarketSection({ /* Build bar segments. The HTML floored each repo's visual width on touch * devices for tappability — we do the same when the user is on a coarse * pointer, otherwise honor the true share. */ - const isTouchPrimary = useMemo(() => { - if (typeof window === 'undefined') return false; - return window.matchMedia('(pointer: coarse), (hover: none)').matches; + const [isTouchPrimary, setIsTouchPrimary] = useState(false); + + useEffect(() => { + if (typeof window === 'undefined') return; + const mq = window.matchMedia('(pointer: coarse), (hover: none)'); + const update = () => setIsTouchPrimary(mq.matches); + update(); + mq.addEventListener('change', update); + return () => mq.removeEventListener('change', update); }, []); interface Seg { diff --git a/src/app/repositories/_components/RepoCard.tsx b/src/app/repositories/_components/RepoCard.tsx index d23fd53..d8278a8 100644 --- a/src/app/repositories/_components/RepoCard.tsx +++ b/src/app/repositories/_components/RepoCard.tsx @@ -179,7 +179,7 @@ export default function RepoCard({ labelColor="var(--fg-subtle)" labelWeight={400} labelFontSize={10.5} - barPct={(r.defaultLabel / 2) * 100} + barPct={Math.min(100, (r.defaultLabel / 2) * 100)} barBg="var(--border-strong)" value={`×${r.defaultLabel.toFixed(2)}`} valueColor="var(--fg-subtle)" diff --git a/src/app/repositories/_components/RepoListRow.tsx b/src/app/repositories/_components/RepoListRow.tsx index 2ab63ee..d6f06bf 100644 --- a/src/app/repositories/_components/RepoListRow.tsx +++ b/src/app/repositories/_components/RepoListRow.tsx @@ -80,7 +80,7 @@ export default function RepoListRow({ ); } else { // No strategy: show highest configured mult, or "—" if no labels - if (r.labels) { + if (r.labels && Object.keys(r.labels).length > 0) { const entries = Object.entries(r.labels); const [topLabel, topVal] = entries.reduce((a, b) => (a[1] >= b[1] ? a : b)); const c = LABEL_COLORS[topLabel] ?? { fg: 'var(--fg-subtle)', soft: '' }; diff --git a/src/app/repositories/_lib/squarify.ts b/src/app/repositories/_lib/squarify.ts index 756939e..e89df61 100644 --- a/src/app/repositories/_lib/squarify.ts +++ b/src/app/repositories/_lib/squarify.ts @@ -24,9 +24,24 @@ export function squarify( w: number, h: number, ): Array> { - const totalWeight = segs.reduce((a, b) => a + b.w, 0); + if (segs.length === 0 || !Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) { + return []; + } + const totalArea = w * h; - const items = segs.map((seg) => ({ data: seg.data, area: (seg.w / totalWeight) * totalArea })); + 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) + .sort((a, b) => b.area - a.area); + + if (items.length === 0) return []; const result: Array> = []; diff --git a/src/app/repositories/page.module.css b/src/app/repositories/page.module.css index b3935a9..e9e117c 100644 --- a/src/app/repositories/page.module.css +++ b/src/app/repositories/page.module.css @@ -801,6 +801,7 @@ } .mtileAvatarWrap { --cred-color: #7b8494; + position: relative; display: inline-flex; align-items: center; diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index a912632..6a5f04e 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -117,7 +117,14 @@ export default function Dropdown({ // Scroll-to-close (opt-in). Captures scroll on any scrollable // ancestor since the menu is portal-rendered to body — `true` for // capture phase so we catch nested scrollers too. - const onScroll = () => setOpen(false); + const onScroll = (e: Event) => { + const target = e.target; + if (target instanceof Node) { + if (menuRef.current?.contains(target)) return; + if (triggerRef.current?.contains(target)) return; + } + setOpen(false); + }; document.addEventListener('keydown', onKey); document.addEventListener('mousedown', onClick); if (closeOnScroll) window.addEventListener('scroll', onScroll, true); From d708d3ea4ddf14616892fa7ee644dce5c6658e1e Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 05:31:15 +0200 Subject: [PATCH 12/18] fix: show compact miner status --- src/app/repositories/_components/Drawer.tsx | 29 ++++++++++- src/app/repositories/page.module.css | 58 +++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx index 612da64..686799d 100644 --- a/src/app/repositories/_components/Drawer.tsx +++ b/src/app/repositories/_components/Drawer.tsx @@ -705,6 +705,11 @@ function MinerTreemap({ // and name share the top row, freeing vertical room for the // score/meta to render in full. const wideShort = (sizeClass === 'lg' || sizeClass === 'md') && t.w > t.h * 1.6; + const showCompactStats = + !wideShort && ( + (sizeClass === 'sm' && t.h >= 145) || + (sizeClass === 'xs' && t.w >= 72 && t.h >= 170) + ); // Tier styling lives in CSS so light/dark themes can swap palettes // without recomputing colors here. textTone is inherited from the // tile's CSS `color` per tier. @@ -751,6 +756,7 @@ function MinerTreemap({ tao={tao} share={share} textTone={textTone} + showCompactStats={showCompactStats} /> ); @@ -768,6 +774,7 @@ function MinerTileContent({ tao, share, textTone, + showCompactStats, }: { sizeClass: 'xl' | 'lg' | 'md' | 'sm' | 'xs'; wideShort: boolean; @@ -777,6 +784,7 @@ function MinerTileContent({ tao: number; share: number; textTone: string; + showCompactStats: boolean; }) { const visibleScore = eligible ? miner.score : (miner.baseScore ?? 0); const scoreNode = ( @@ -876,26 +884,43 @@ function MinerTileContent({ } if (sizeClass === 'sm') { return ( -
+
{miner.githubUsername}
+ {showCompactStats ? ( +
+
+ {visibleScore.toFixed(1)} + {eligible ? ' score' : ' base'} +
+
+ {eligible ? `${formatTAO(tao)} T/Day` : 'ineligible'} +
+
+ ) : null}
); } // xs — identity-only. Hide UID/star from the visible layout because those // controls steal the horizontal space the username needs in tiny tiles. return ( -
+
{miner.githubUsername}
+ {showCompactStats ? ( +
+ {visibleScore.toFixed(1)} + {eligible ? 'score' : 'ineligible'} +
+ ) : null}
); } diff --git a/src/app/repositories/page.module.css b/src/app/repositories/page.module.css index e9e117c..78ad74b 100644 --- a/src/app/repositories/page.module.css +++ b/src/app/repositories/page.module.css @@ -911,6 +911,11 @@ height: 100%; gap: 7px; } +.mtileCompactWithStats { + justify-content: space-between; + padding-top: 1px; + padding-bottom: 1px; +} .mtileCompactIdentity { width: 100%; min-width: 0; @@ -921,6 +926,37 @@ gap: 8px; overflow: hidden; } +.mtileCompactStats { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + overflow: hidden; + line-height: 1.1; +} +.mtileCompactScore { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10.5px; + font-weight: 700; +} +.mtileCompactScore span { + font-size: 8px; + font-weight: 500; + opacity: 0.72; +} +.mtileCompactMeta { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 8.5px; + opacity: 0.76; +} .mtileTiny { display: flex; flex-direction: column; @@ -929,6 +965,10 @@ height: 100%; overflow: hidden; } +.mtileTinyWithStats { + justify-content: space-between; + gap: 4px; +} .mtileTinyIdentity { width: 100%; min-width: 0; @@ -939,6 +979,24 @@ gap: 7px; overflow: hidden; } +.mtileTinyStats { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + overflow: hidden; + font-size: 8px; + line-height: 1.05; + opacity: 0.76; +} +.mtileTinyStats span { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .mtileTiny .mtileAvatarWrap { margin-right: 7px; margin-bottom: 2px; From 2fd5a7cb5c4397dc7959b9ed35856f65bbe1c361 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 06:53:30 +0200 Subject: [PATCH 13/18] fix: prioritize eligible miner tiles --- src/app/repositories/_components/Drawer.tsx | 22 +++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx index 686799d..82be7bb 100644 --- a/src/app/repositories/_components/Drawer.tsx +++ b/src/app/repositories/_components/Drawer.tsx @@ -449,11 +449,12 @@ function tileScale(value: number): number { return Math.pow(Math.max(value, 0), 0.35); } -function minerTileWeight(m: RepoMiner, topEligibleScore: number): number { +function minerTileWeight(m: RepoMiner, topEligibleScore: number, hasEligibleMiners: boolean): number { const finalScore = Math.max(m.score ?? 0, 0); - const topEligibleUnit = tileScale(topEligibleScore); + const eligibleFloor = 3.2; + const topEligibleUnit = Math.max(tileScale(topEligibleScore), eligibleFloor); if (m.isEligible === true) { - return Math.max(tileScale(finalScore), topEligibleUnit > 0 ? topEligibleUnit * 0.24 : 0, 0.75); + return Math.max(tileScale(finalScore), topEligibleUnit * 0.72, eligibleFloor); } const baseRepoScore = Math.max( @@ -461,13 +462,13 @@ function minerTileWeight(m: RepoMiner, topEligibleScore: number): number { finalScore, 0.15, ); - if (topEligibleScore <= 0) return Math.max(tileScale(baseRepoScore), 0.75); + if (!hasEligibleMiners) return Math.max(tileScale(baseRepoScore), 0.75); // Keep historical/ineligible miners visible, but visually subordinate to - // every eligible tile in the top-five set. The power scale keeps a huge - // leader dominant without compressing the rest of the map into slivers. - const damped = tileScale(baseRepoScore) * 0.55; - return Math.min(Math.max(damped, 0.35), Math.max(0.35, topEligibleUnit * 0.18)); + // every eligible tile in the top-five set, even when the eligible miner's + // current repo score is zero and the historical base scores are large. + const damped = tileScale(baseRepoScore) * 0.22; + return Math.min(Math.max(damped, 0.35), topEligibleUnit * 0.42); } function useNarrowTreemap(): boolean { @@ -659,9 +660,10 @@ function MinerTreemap({ const W = isNarrow ? 600 : 1000; const H = isNarrow ? 990 : 560; const tiles = useMemo(() => { - const topEligibleScore = Math.max(0, ...miners.filter((m) => m.isEligible).map((m) => m.score ?? 0)); + const eligibleMiners = miners.filter((m) => m.isEligible); + const topEligibleScore = Math.max(0, ...eligibleMiners.map((m) => m.score ?? 0)); return squarify( - miners.map((m) => ({ w: minerTileWeight(m, topEligibleScore), data: m })), + miners.map((m) => ({ w: minerTileWeight(m, topEligibleScore, eligibleMiners.length > 0), data: m })), 0, 0, W, From 0f66b97ad2c21403807b120adeb5082c7377fa5e Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 06:59:33 +0200 Subject: [PATCH 14/18] fix: cap dominant miner tile --- src/app/repositories/_components/Drawer.tsx | 26 +++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx index 82be7bb..f4af0a8 100644 --- a/src/app/repositories/_components/Drawer.tsx +++ b/src/app/repositories/_components/Drawer.tsx @@ -435,6 +435,7 @@ function minerKey(m: RepoMiner): string { } const TOP_ACTIVE_MINERS_LIMIT = 5; +const MAX_DOMINANT_MINER_TILE_SHARE = 0.64; function repoWorkScore(m: RepoMiner): number { return Math.max( @@ -471,6 +472,23 @@ function minerTileWeight(m: RepoMiner, topEligibleScore: number, hasEligibleMine return Math.min(Math.max(damped, 0.35), topEligibleUnit * 0.42); } +function capDominantMinerTile(items: Array<{ w: number; data: T }>): Array<{ w: number; data: T }> { + if (items.length < 2) return items; + const total = items.reduce((sum, item) => sum + item.w, 0); + if (!Number.isFinite(total) || total <= 0) return items; + + let maxIndex = 0; + for (let i = 1; i < items.length; i++) { + if (items[i].w > items[maxIndex].w) maxIndex = i; + } + + const rest = total - items[maxIndex].w; + if (rest <= 0 || items[maxIndex].w / total <= MAX_DOMINANT_MINER_TILE_SHARE) return items; + + const cappedMax = (MAX_DOMINANT_MINER_TILE_SHARE / (1 - MAX_DOMINANT_MINER_TILE_SHARE)) * rest; + return items.map((item, i) => (i === maxIndex ? { ...item, w: cappedMax } : item)); +} + function useNarrowTreemap(): boolean { const [isNarrow, setIsNarrow] = useState(false); @@ -636,7 +654,7 @@ function MinersSection({ owner, name, repoPRTAOValue }: { owner: string; name: s - Top five only · tile size follows repo score + Top five only · tile size follows repo score, capped for readability
@@ -662,8 +680,12 @@ function MinerTreemap({ const tiles = useMemo(() => { const eligibleMiners = miners.filter((m) => m.isEligible); const topEligibleScore = Math.max(0, ...eligibleMiners.map((m) => m.score ?? 0)); + const weightedMiners = miners.map((m) => ({ + w: minerTileWeight(m, topEligibleScore, eligibleMiners.length > 0), + data: m, + })); return squarify( - miners.map((m) => ({ w: minerTileWeight(m, topEligibleScore, eligibleMiners.length > 0), data: m })), + capDominantMinerTile(weightedMiners), 0, 0, W, From 07e1debb2e8e8ebe41a9fd63f0defba49f8a2155 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 11:03:29 +0200 Subject: [PATCH 15/18] fix: balance miner tile sizing --- src/app/repositories/_components/Drawer.tsx | 77 ++++++++++++++++----- 1 file changed, 58 insertions(+), 19 deletions(-) diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx index f4af0a8..de77471 100644 --- a/src/app/repositories/_components/Drawer.tsx +++ b/src/app/repositories/_components/Drawer.tsx @@ -435,7 +435,9 @@ function minerKey(m: RepoMiner): string { } const TOP_ACTIVE_MINERS_LIMIT = 5; -const MAX_DOMINANT_MINER_TILE_SHARE = 0.64; +const MAX_DOMINANT_MINER_TILE_SHARE = 0.58; +const MIN_INELIGIBLE_TILE_POOL = 0.2; +const MAX_INELIGIBLE_TILE_POOL = 0.32; function repoWorkScore(m: RepoMiner): number { return Math.max( @@ -450,26 +452,69 @@ function tileScale(value: number): number { return Math.pow(Math.max(value, 0), 0.35); } -function minerTileWeight(m: RepoMiner, topEligibleScore: number, hasEligibleMiners: boolean): number { +function eligibleTileUnit(m: RepoMiner, topEligibleScore: number): number { const finalScore = Math.max(m.score ?? 0, 0); const eligibleFloor = 3.2; const topEligibleUnit = Math.max(tileScale(topEligibleScore), eligibleFloor); - if (m.isEligible === true) { - return Math.max(tileScale(finalScore), topEligibleUnit * 0.72, eligibleFloor); - } + return Math.max(tileScale(finalScore), topEligibleUnit * 0.72, eligibleFloor); +} +function ineligibleTileUnit(m: RepoMiner): number { const baseRepoScore = Math.max( m.baseScore ?? 0, - finalScore, + m.score ?? 0, 0.15, ); - if (!hasEligibleMiners) return Math.max(tileScale(baseRepoScore), 0.75); + return Math.max(tileScale(baseRepoScore), 0.75); +} + +function distributeMinerPool( + miners: RepoMiner[], + pool: number, + unitFor: (m: RepoMiner) => number, +): Array<{ w: number; data: RepoMiner }> { + if (miners.length === 0 || pool <= 0) return []; + const units = miners.map((miner) => Math.max(unitFor(miner), 0.72)); + const total = units.reduce((sum, unit) => sum + unit, 0); + if (!Number.isFinite(total) || total <= 0) { + const even = pool / miners.length; + return miners.map((miner) => ({ w: even, data: miner })); + } + return miners.map((miner, i) => ({ w: (units[i] / total) * pool, data: miner })); +} + +function buildMinerTileWeights(miners: RepoMiner[]): Array<{ w: number; data: RepoMiner }> { + const eligible = miners.filter((m) => m.isEligible); + const ineligible = miners.filter((m) => !m.isEligible); + + if (eligible.length === 0) { + return distributeMinerPool(ineligible, 1, ineligibleTileUnit); + } + if (ineligible.length === 0) { + const topEligibleScore = Math.max(0, ...eligible.map((m) => m.score ?? 0)); + return distributeMinerPool(eligible, 1, (m) => eligibleTileUnit(m, topEligibleScore)); + } + + const topEligibleScore = Math.max(0, ...eligible.map((m) => m.score ?? 0)); + const eligibleUnits = eligible.map((m) => eligibleTileUnit(m, topEligibleScore)); + const ineligibleUnits = ineligible.map(ineligibleTileUnit); + const eligibleTotal = eligibleUnits.reduce((sum, unit) => sum + unit, 0); + const ineligibleTotal = ineligibleUnits.reduce((sum, unit) => sum + unit, 0); + const smallestEligibleShare = eligibleTotal > 0 ? Math.min(...eligibleUnits) / eligibleTotal : 1; + const largestIneligibleShare = ineligibleTotal > 0 ? Math.max(...ineligibleUnits) / ineligibleTotal : 1; + const ineligibleShareCap = + (smallestEligibleShare * 0.86) / (largestIneligibleShare + smallestEligibleShare * 0.86); + const requestedIneligiblePool = Math.min( + MAX_INELIGIBLE_TILE_POOL, + Math.max(MIN_INELIGIBLE_TILE_POOL, ineligible.length * 0.11), + ); + const ineligiblePool = Math.min(requestedIneligiblePool, ineligibleShareCap); + const eligiblePool = 1 - ineligiblePool; - // Keep historical/ineligible miners visible, but visually subordinate to - // every eligible tile in the top-five set, even when the eligible miner's - // current repo score is zero and the historical base scores are large. - const damped = tileScale(baseRepoScore) * 0.22; - return Math.min(Math.max(damped, 0.35), topEligibleUnit * 0.42); + return [ + ...distributeMinerPool(eligible, eligiblePool, (m) => eligibleTileUnit(m, topEligibleScore)), + ...distributeMinerPool(ineligible, ineligiblePool, ineligibleTileUnit), + ]; } function capDominantMinerTile(items: Array<{ w: number; data: T }>): Array<{ w: number; data: T }> { @@ -678,14 +723,8 @@ function MinerTreemap({ const W = isNarrow ? 600 : 1000; const H = isNarrow ? 990 : 560; const tiles = useMemo(() => { - const eligibleMiners = miners.filter((m) => m.isEligible); - const topEligibleScore = Math.max(0, ...eligibleMiners.map((m) => m.score ?? 0)); - const weightedMiners = miners.map((m) => ({ - w: minerTileWeight(m, topEligibleScore, eligibleMiners.length > 0), - data: m, - })); return squarify( - capDominantMinerTile(weightedMiners), + capDominantMinerTile(buildMinerTileWeights(miners)), 0, 0, W, From 0ade1c482870bc121b9d95991ea4dc2efef3da07 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 11:20:03 +0200 Subject: [PATCH 16/18] fix: make miner treemap readable --- src/app/repositories/_components/Drawer.tsx | 119 ++++++++++++-------- src/app/repositories/page.module.css | 5 +- 2 files changed, 77 insertions(+), 47 deletions(-) diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx index de77471..787d9df 100644 --- a/src/app/repositories/_components/Drawer.tsx +++ b/src/app/repositories/_components/Drawer.tsx @@ -436,8 +436,8 @@ function minerKey(m: RepoMiner): string { const TOP_ACTIVE_MINERS_LIMIT = 5; const MAX_DOMINANT_MINER_TILE_SHARE = 0.58; -const MIN_INELIGIBLE_TILE_POOL = 0.2; -const MAX_INELIGIBLE_TILE_POOL = 0.32; +const MIN_INELIGIBLE_TILE_REGION = 0.18; +const MAX_INELIGIBLE_TILE_REGION = 0.44; function repoWorkScore(m: RepoMiner): number { return Math.max( @@ -468,52 +468,85 @@ function ineligibleTileUnit(m: RepoMiner): number { return Math.max(tileScale(baseRepoScore), 0.75); } -function distributeMinerPool( +function buildSoftMinerWeights( miners: RepoMiner[], - pool: number, unitFor: (m: RepoMiner) => number, ): Array<{ w: number; data: RepoMiner }> { - if (miners.length === 0 || pool <= 0) return []; - const units = miners.map((miner) => Math.max(unitFor(miner), 0.72)); - const total = units.reduce((sum, unit) => sum + unit, 0); - if (!Number.isFinite(total) || total <= 0) { - const even = pool / miners.length; - return miners.map((miner) => ({ w: even, data: miner })); - } - return miners.map((miner, i) => ({ w: (units[i] / total) * pool, data: miner })); + if (miners.length === 0) return []; + const rawUnits = miners.map((miner) => Math.max(unitFor(miner), 0.72)); + const maxUnit = Math.max(...rawUnits); + const floor = Number.isFinite(maxUnit) && maxUnit > 0 ? maxUnit * 0.58 : 1; + return miners.map((miner, i) => ({ w: Math.max(rawUnits[i], floor), data: miner })); +} + +function sumWeights(items: Array<{ w: number }>): number { + return items.reduce((sum, item) => sum + item.w, 0); +} + +function ineligibleRegionShare( + eligibleWeights: Array<{ w: number; data: RepoMiner }>, + ineligibleWeights: Array<{ w: number; data: RepoMiner }>, +): number { + if (eligibleWeights.length === 0) return 1; + if (ineligibleWeights.length === 0) return 0; + + const eligibleTotal = sumWeights(eligibleWeights); + const ineligibleTotal = sumWeights(ineligibleWeights); + const smallestEligibleShare = eligibleTotal > 0 + ? Math.min(...eligibleWeights.map((item) => item.w)) / eligibleTotal + : 1; + const largestIneligibleShare = ineligibleTotal > 0 + ? Math.max(...ineligibleWeights.map((item) => item.w)) / ineligibleTotal + : 1; + const dominanceCap = + (smallestEligibleShare * 0.88) / (largestIneligibleShare + smallestEligibleShare * 0.88); + const readableTarget = Math.min( + MAX_INELIGIBLE_TILE_REGION, + Math.max(MIN_INELIGIBLE_TILE_REGION, ineligibleWeights.length * 0.13), + ); + + return Math.min(readableTarget, dominanceCap); } -function buildMinerTileWeights(miners: RepoMiner[]): Array<{ w: number; data: RepoMiner }> { +function layoutMinerRegion( + items: Array<{ w: number; data: RepoMiner }>, + x: number, + y: number, + w: number, + h: number, +) { + return squarify(capDominantMinerTile(items), x, y, w, h); +} + +function layoutMinerTiles(miners: RepoMiner[], W: number, H: number, isNarrow: boolean) { const eligible = miners.filter((m) => m.isEligible); const ineligible = miners.filter((m) => !m.isEligible); + const topEligibleScore = Math.max(0, ...eligible.map((m) => m.score ?? 0)); + const eligibleWeights = buildSoftMinerWeights(eligible, (m) => eligibleTileUnit(m, topEligibleScore)); + const ineligibleWeights = buildSoftMinerWeights(ineligible, ineligibleTileUnit); - if (eligible.length === 0) { - return distributeMinerPool(ineligible, 1, ineligibleTileUnit); + if (eligibleWeights.length === 0) { + return layoutMinerRegion(ineligibleWeights, 0, 0, W, H); } - if (ineligible.length === 0) { - const topEligibleScore = Math.max(0, ...eligible.map((m) => m.score ?? 0)); - return distributeMinerPool(eligible, 1, (m) => eligibleTileUnit(m, topEligibleScore)); + if (ineligibleWeights.length === 0) { + return layoutMinerRegion(eligibleWeights, 0, 0, W, H); } - const topEligibleScore = Math.max(0, ...eligible.map((m) => m.score ?? 0)); - const eligibleUnits = eligible.map((m) => eligibleTileUnit(m, topEligibleScore)); - const ineligibleUnits = ineligible.map(ineligibleTileUnit); - const eligibleTotal = eligibleUnits.reduce((sum, unit) => sum + unit, 0); - const ineligibleTotal = ineligibleUnits.reduce((sum, unit) => sum + unit, 0); - const smallestEligibleShare = eligibleTotal > 0 ? Math.min(...eligibleUnits) / eligibleTotal : 1; - const largestIneligibleShare = ineligibleTotal > 0 ? Math.max(...ineligibleUnits) / ineligibleTotal : 1; - const ineligibleShareCap = - (smallestEligibleShare * 0.86) / (largestIneligibleShare + smallestEligibleShare * 0.86); - const requestedIneligiblePool = Math.min( - MAX_INELIGIBLE_TILE_POOL, - Math.max(MIN_INELIGIBLE_TILE_POOL, ineligible.length * 0.11), - ); - const ineligiblePool = Math.min(requestedIneligiblePool, ineligibleShareCap); - const eligiblePool = 1 - ineligiblePool; + const ineligibleShare = ineligibleRegionShare(eligibleWeights, ineligibleWeights); + const eligibleShare = 1 - ineligibleShare; + + if (isNarrow) { + const eligibleH = H * eligibleShare; + return [ + ...layoutMinerRegion(eligibleWeights, 0, 0, W, eligibleH), + ...layoutMinerRegion(ineligibleWeights, 0, eligibleH, W, H - eligibleH), + ]; + } + const eligibleW = W * eligibleShare; return [ - ...distributeMinerPool(eligible, eligiblePool, (m) => eligibleTileUnit(m, topEligibleScore)), - ...distributeMinerPool(ineligible, ineligiblePool, ineligibleTileUnit), + ...layoutMinerRegion(eligibleWeights, 0, 0, eligibleW, H), + ...layoutMinerRegion(ineligibleWeights, eligibleW, 0, W - eligibleW, H), ]; } @@ -721,16 +754,10 @@ function MinerTreemap({ }) { const isNarrow = useNarrowTreemap(); const W = isNarrow ? 600 : 1000; - const H = isNarrow ? 990 : 560; + const H = isNarrow ? 990 : 540; const tiles = useMemo(() => { - return squarify( - capDominantMinerTile(buildMinerTileWeights(miners)), - 0, - 0, - W, - H, - ); - }, [H, W, miners]); + return layoutMinerTiles(miners, W, H, isNarrow); + }, [H, W, isNarrow, miners]); // Rank among ELIGIBLE only — drives the leader crown and the lime tier // shading. Ineligible miners don't get a "leader" treatment regardless // of their historical score. @@ -961,7 +988,7 @@ function MinerTileContent({ {eligible ? ' score' : ' base'}
- {eligible ? `${formatTAO(tao)} T/Day` : 'ineligible'} + {eligible ? `${formatTAO(tao)} T/Day` : '0 T/Day'}
) : null} @@ -981,7 +1008,7 @@ function MinerTileContent({ {showCompactStats ? (
{visibleScore.toFixed(1)} - {eligible ? 'score' : 'ineligible'} + {eligible ? 'score' : '0 T/Day'}
) : null}
diff --git a/src/app/repositories/page.module.css b/src/app/repositories/page.module.css index 78ad74b..320125f 100644 --- a/src/app/repositories/page.module.css +++ b/src/app/repositories/page.module.css @@ -896,10 +896,13 @@ margin-top: 2px; display: flex; align-items: center; + flex-wrap: wrap; gap: 4px; min-width: 0; overflow: hidden; - text-overflow: ellipsis; + line-height: 1.25; +} +.mtileMeta span { white-space: nowrap; } .mtileSep { color: inherit; opacity: 0.4; } From c98c85c3e7f97ba72011b3ef6c0efcb523ed91c6 Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 11:27:51 +0200 Subject: [PATCH 17/18] fix: simplify miner tile status text --- src/app/repositories/_components/Drawer.tsx | 23 ++++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx index 787d9df..001e359 100644 --- a/src/app/repositories/_components/Drawer.tsx +++ b/src/app/repositories/_components/Drawer.tsx @@ -883,8 +883,19 @@ function MinerTileContent({ {eligible ? 'score' : 'base score'} ); - const shareText = eligible ? `${(share * 100).toFixed(1)}%` : 'ineligible'; + const shareText = eligible ? `${(share * 100).toFixed(1)}%` : null; const taoText = eligible ? `${formatTAO(tao)} T/Day` : '0 T/Day'; + const metaNode = ( + <> + {shareText ? ( + <> + {shareText} + · + + ) : null} + {taoText} + + ); // Let the score text inherit from the tile's tier color so it adapts // to dark/light mode automatically (was hardcoded `#edf0f2` and // disappeared on light backgrounds). @@ -920,9 +931,7 @@ function MinerTileContent({ {scoreNode}
- {shareText} - · - {taoText} + {metaNode}
@@ -943,9 +952,7 @@ function MinerTileContent({ {scoreNode}
- {shareText} - · - {taoText} + {metaNode}
@@ -966,7 +973,7 @@ function MinerTileContent({ {scoreNode}
- {shareText} · {taoText} + {metaNode}
From 5a03423c7cd79d4218a5d2cde14c5f8bbd06964b Mon Sep 17 00:00:00 2001 From: bitloi Date: Tue, 26 May 2026 20:20:31 +0200 Subject: [PATCH 18/18] fix: clarify miner drawer states --- src/app/api/gt/repos/[owner]/[name]/miners/route.ts | 2 +- src/app/repositories/_components/Drawer.tsx | 2 +- src/app/repositories/_components/MarketSection.tsx | 1 + src/app/repositories/_lib/squarify.ts | 12 ++++++++++-- 4 files changed, 13 insertions(+), 4 deletions(-) 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 eec8b70..cd3a8f9 100644 --- a/src/app/api/gt/repos/[owner]/[name]/miners/route.ts +++ b/src/app/api/gt/repos/[owner]/[name]/miners/route.ts @@ -147,7 +147,7 @@ async function fetchRepoMiners(fullName: string): Promise { cache: 'no-store', signal: AbortSignal.timeout(8_000), }); - if (!r.ok) return []; + if (!r.ok) throw new Error(`upstream repo miners ${fullName} ${r.status}`); const raw = (await r.json()) as unknown; const rows = Array.isArray(raw) ? raw diff --git a/src/app/repositories/_components/Drawer.tsx b/src/app/repositories/_components/Drawer.tsx index 001e359..026c26b 100644 --- a/src/app/repositories/_components/Drawer.tsx +++ b/src/app/repositories/_components/Drawer.tsx @@ -683,7 +683,7 @@ function MinersSection({ owner, name, repoPRTAOValue }: { owner: string; name: s Active miners
- Benchmark repo — no miners. + No miner data available for this repo yet.
); diff --git a/src/app/repositories/_components/MarketSection.tsx b/src/app/repositories/_components/MarketSection.tsx index 517dfda..3c7feb9 100644 --- a/src/app/repositories/_components/MarketSection.tsx +++ b/src/app/repositories/_components/MarketSection.tsx @@ -580,6 +580,7 @@ function Treemap({ 0, 1, 1, + { sort: false }, ); return ( diff --git a/src/app/repositories/_lib/squarify.ts b/src/app/repositories/_lib/squarify.ts index e89df61..6bd332b 100644 --- a/src/app/repositories/_lib/squarify.ts +++ b/src/app/repositories/_lib/squarify.ts @@ -17,12 +17,17 @@ export interface SquarifyRect { 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 []; @@ -38,8 +43,11 @@ export function squarify( ? 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) - .sort((a, b) => b.area - a.area); + .filter((seg) => seg.area > 0); + + if (options.sort !== false) { + items.sort((a, b) => b.area - a.area); + } if (items.length === 0) return [];