diff --git a/app/api/developer/route.js b/app/api/developer/route.js
index f080b04..29da0ca 100644
--- a/app/api/developer/route.js
+++ b/app/api/developer/route.js
@@ -50,7 +50,7 @@ export async function GET(request) {
const container = client.database(DATABASE).container(CONTAINER);
const { resources } = await container.items.query({
- query: "SELECT c.id, c.login, c.name, c.avatarUrl, c.bio, c.githubUrl, c.location, c.lat, c.lng, c.followers, c.totalStars, c.totalForks, c.totalCommits, c.topLanguage, c.languages, c.publicRepos, c.topRepos, c.soReputation, c.soAnswers, c.soAcceptRate, c.soBadges, c.soUserId, c.specialTags, c.claimed, c.claimedAt, c.metricsUpdatedAt, c.aiProfile FROM c WHERE (c.id = @id OR c.login = @id) AND (NOT IS_DEFINED(c.nomination) OR c.nomination.status = 'approved')",
+ query: "SELECT c.id, c.login, c.name, c.avatarUrl, c.bio, c.githubUrl, c.location, c.lat, c.lng, c.followers, c.totalStars, c.totalForks, c.totalWatchers, c.totalCommits, c.topLanguage, c.languages, c.publicRepos, c.topRepos, c.soReputation, c.soAnswers, c.soAcceptRate, c.soBadges, c.soUserId, c.specialTags, c.claimed, c.claimedAt, c.metricsUpdatedAt, c.aiProfile FROM c WHERE (c.id = @id OR c.login = @id) AND (NOT IS_DEFINED(c.nomination) OR c.nomination.status = 'approved')",
parameters: [{ name: '@id', value: id }]
}).fetchAll();
diff --git a/app/api/developers/route.js b/app/api/developers/route.js
index 9d0464b..3426e03 100644
--- a/app/api/developers/route.js
+++ b/app/api/developers/route.js
@@ -33,7 +33,7 @@ export async function GET() {
const container = client.database(DATABASE).container(CONTAINER);
const { resources } = await container.items
- .query("SELECT c.id, c.login, c.name, c.avatarUrl, c.githubUrl, c.location, c.lat, c.lng, c.followers, c.totalStars, c.totalForks, c.totalCommits, c.topLanguage, c.soReputation, c.soAnswers, c.soAcceptRate, c.soBadges, c.specialTags, c.claimed, c.metricsUpdatedAt, c.collaborators, c.aiProfile FROM c WHERE NOT IS_DEFINED(c.nomination) OR c.nomination.status = 'approved'")
+ .query("SELECT c.id, c.login, c.name, c.avatarUrl, c.githubUrl, c.location, c.lat, c.lng, c.followers, c.publicRepos, c.totalStars, c.totalForks, c.totalWatchers, c.totalCommits, c.topLanguage, c.soUserId, c.soReputation, c.soAnswers, c.soAcceptRate, c.soBadges, c.specialTags, c.claimed, c.metricsUpdatedAt, c.collaborators, c.aiProfile FROM c WHERE NOT IS_DEFINED(c.nomination) OR c.nomination.status = 'approved'")
.fetchAll();
return NextResponse.json(projectAgentReadinessList(resources), {
diff --git a/app/page.jsx b/app/page.jsx
index 372e582..6b490b2 100644
--- a/app/page.jsx
+++ b/app/page.jsx
@@ -16,6 +16,7 @@ import PlatformActivityBanner from '../components/PlatformActivityBanner.jsx';
import { scoreAll } from '../lib/scoring.js';
import { addDeveloperRanks } from '../lib/ranking.js';
import { enrichWithCollaborators } from '../lib/collaboration.js';
+import { withOssWorth } from '../lib/oss-worth.js';
import dynamic from 'next/dynamic';
const Globe = dynamic(() => import('../components/Globe.jsx'), { ssr: false });
@@ -131,7 +132,7 @@ export default function Home() {
const devRes = await fetch('/api/developers', { cache: 'no-store' });
if (devRes.ok) {
const raw = await devRes.json();
- const scored = enrichWithCollaborators(addDeveloperRanks(scoreAll(raw)));
+ const scored = enrichWithCollaborators(addDeveloperRanks(scoreAll(raw))).map(withOssWorth);
setDevelopers(scored);
setFiltered(scored);
const claimed = new Set(raw.filter(d => d.claimed).map(d => d.login));
@@ -220,7 +221,7 @@ export default function Home() {
const res = await fetch('/api/developers', { signal: AbortSignal.timeout(30000) });
if (!res.ok) throw new Error(`Failed to load data: ${res.status}`);
const raw = await res.json();
- const scored = enrichWithCollaborators(addDeveloperRanks(scoreAll(raw)));
+ const scored = enrichWithCollaborators(addDeveloperRanks(scoreAll(raw))).map(withOssWorth);
setDevelopers(scored);
setFiltered(scored);
// Build set of all claimed logins from data
diff --git a/components/DetailPanel.jsx b/components/DetailPanel.jsx
index 66556f2..e0fd001 100644
--- a/components/DetailPanel.jsx
+++ b/components/DetailPanel.jsx
@@ -226,6 +226,8 @@ export default function DetailPanel({ dev, onClose, onCardGenerated, claimedLogi
+ {dev.ossWorth && }
+
{/* Charts */}
@@ -407,6 +409,63 @@ function StatCard({ label, value, className = '' }) {
);
}
+function OssWorthSection({ worth }) {
+ return (
+
+
+
+ Public contribution footprint
+
OSS Worth
+
+
+ {formatNum(worth.totalCredits)} OSC
+
+
+
+ Fictional OSS Credits celebrate public contributions. They are not compensation, skill, employability, or financial value.
+
+
+
+
+
+
+ How this is calculated
+ Inputs are log-normalized against fixed reference caps. Formula {worth.formulaVersion}.
+
+
+ );
+}
+
+function WorthCard({ platform, allocation, worth }) {
+ const platformClass = platform === 'GitHub' ? 'github' : 'stackoverflow';
+ return (
+
+
+ {platform}
+ {allocation}
+
+
+ {formatNum(worth.credits)} / {formatNum(worth.maxCredits)} OSC
+
+ {!worth.available ? (
+ No linked Stack Overflow profile
+ ) : (
+
+ {worth.breakdown.map(dimension => (
+ -
+
+ {dimension.label}
+ {Math.round(dimension.weight * 100)}% · cap {formatNum(dimension.cap)}
+
+ {formatNum(dimension.sourceValue)}
+
+ ))}
+
+ )}
+
+ );
+}
+
function SOBars({ rep, answers, acceptRate, badges, userId }) {
const metrics = [
{ label: 'Reputation', value: rep, max: 1000000, color: '#f48024' },
diff --git a/components/Leaderboard.jsx b/components/Leaderboard.jsx
index 1a52395..32b14df 100644
--- a/components/Leaderboard.jsx
+++ b/components/Leaderboard.jsx
@@ -4,6 +4,7 @@ import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react'
import { formatNum } from '../lib/format.js';
import { extractCountry, normalizeCountry, countryKey } from '../lib/country.js';
import { SCORE_METHODOLOGY } from '../lib/scoring.js';
+import { compareOssWorth } from '../lib/oss-worth.js';
import SpecialTags from './SpecialTags.jsx';
import GlobalActivityFeed from './GlobalActivityFeed.jsx';
import AgentNetworkPanel from './AgentNetworkPanel.jsx';
@@ -78,6 +79,7 @@ export default function Leaderboard({
case 'stars': return (b.totalStars || 0) - (a.totalStars || 0);
case 'commits': return (b.totalCommits || 0) - (a.totalCommits || 0);
case 'soRep': return (b.soReputation || 0) - (a.soReputation || 0);
+ case 'worth': return compareOssWorth(a, b);
default: return b.score - a.score;
}
});
@@ -213,6 +215,7 @@ export default function Leaderboard({
+
{sortBy === 'score' && (
@@ -257,6 +260,13 @@ export default function Leaderboard({
★ {formatNum(dev.totalStars)}
{dev.soReputation ? ● {formatNum(dev.soReputation)} : null}
+
+ OSC {formatNum(dev.ossWorth?.totalCredits || 0)}
+
diff --git a/docs/prd/oss-worth.md b/docs/prd/oss-worth.md
new file mode 100644
index 0000000..1962c34
--- /dev/null
+++ b/docs/prd/oss-worth.md
@@ -0,0 +1,274 @@
+# PRD: OSS Worth
+
+**Status:** Proposed
+**Issue:** [#185](https://github.com/sajeetharan/devglobe/issues/185)
+**Priority:** P1
+**Last updated:** 2026-08-17
+
+## Summary
+
+DevGlobe will add a playful, transparent OSS Worth measure derived from the public GitHub and Stack Overflow contribution metrics it already indexes. A developer profile will show separate GitHub Worth and Stack Overflow Worth cards, while the leaderboard will show the combined OSS Worth and allow sorting by it.
+
+OSS Worth is denominated in fictional **OSS Credits (OSC)**. It is a celebration of visible open-source participation, not compensation, employability, skill, economic output, or a financial valuation.
+
+The combined maximum is 1,000,000 OSC, allocated exactly as follows:
+
+- GitHub: up to 600,000 OSC (60%).
+- Stack Overflow: up to 400,000 OSC (40%).
+
+Stack Overflow weight is never redistributed. A profile without linked Stack Overflow data receives zero Stack Overflow credits and clearly shows that the source is unavailable.
+
+## Problem
+
+DevGlobe exposes detailed contribution metrics and a relative 0-100 score, but users do not have a simple, shareable summary of their contribution footprint across both major sources. Existing public worth calculators provide engaging GitHub-only experiences. DevGlobe can provide a more complete version because it already combines GitHub creation signals with Stack Overflow knowledge-sharing signals.
+
+The current DevGlobe score cannot be reused directly as worth because it is normalized against the current dataset. Its value can move when the indexed cohort changes, even if a developer's metrics do not. OSS Worth must be deterministic for the same inputs and formula version.
+
+## Goals
+
+- Produce a deterministic OSS Worth from existing public contribution metrics.
+- Preserve an exact 60% GitHub and 40% Stack Overflow maximum allocation.
+- Explain every input and contribution to the result.
+- Show GitHub and Stack Overflow as two distinct, equally understandable surfaces.
+- Show compact OSS Worth in the leaderboard and support sorting by it.
+- Keep values stable as the DevGlobe dataset grows.
+- Handle absent and partial source data honestly.
+- Make the formula independently testable and versioned.
+
+## Non-goals
+
+- Estimating salary, consulting rates, employability, seniority, or commercial value.
+- Replacing the existing DevGlobe score or changing existing rankings.
+- Scraping private activity or data not already available through public APIs.
+- Converting OSC to USD, NGN, BRL, or another real currency.
+- Redistributing missing Stack Overflow allocation to GitHub.
+- Comparing contribution quality, code correctness, or answer correctness.
+
+## Product principles
+
+1. **Celebratory, not evaluative.** Every surface labels OSC as fictional and avoids language such as net worth, market value, cheap, expensive, or hireable.
+2. **Transparent.** Users can inspect source metrics, normalized values, weights, caps, and formula version.
+3. **Stable.** Fixed reference caps replace dataset maxima. A result changes only when source metrics or the formula version changes.
+4. **Source-aware.** Missing Stack Overflow data is unavailable, not zero participation and not a reason to inflate GitHub weight.
+5. **Outlier-resistant.** Log normalization lets early contributions matter without allowing very large accounts to dominate the display.
+
+## Formula v1
+
+### Normalization
+
+Every non-negative metric uses capped log normalization:
+
+```text
+normalize(value, cap) = min(log(1 + max(value, 0)) / log(1 + cap), 1)
+```
+
+Reference caps are product constants, not values calculated from the indexed dataset. Inputs above a cap remain visible in the breakdown but contribute the capped normalized value of 1.
+
+### GitHub Worth
+
+| Dimension | Source field | Within-platform weight | Reference cap |
+|---|---|---:|---:|
+| Repository stars | `totalStars` | 30% | 100,000 |
+| Commit activity | `totalCommits` | 30% | 10,000 |
+| Repository reach | `totalForks + totalWatchers` | 25% | 50,000 |
+| Community reach | `followers` | 10% | 25,000 |
+| Public projects | `publicRepos` | 5% | 100 |
+
+```text
+githubIndex =
+ starsNormalized * 0.30 +
+ commitsNormalized * 0.30 +
+ repoReachNormalized * 0.25 +
+ followersNormalized * 0.10 +
+ publicReposNormalized * 0.05
+
+githubCredits = round(githubIndex * 600000)
+```
+
+### Stack Overflow Worth
+
+Accepted answers are estimated from the aggregate fields DevGlobe currently stores:
+
+```text
+acceptedAnswerEstimate = soAnswers * clamp(soAcceptRate, 0, 100) / 100
+```
+
+| Dimension | Source field | Within-platform weight | Reference cap |
+|---|---|---:|---:|
+| Knowledge reputation | `soReputation` | 55% | 100,000 |
+| Accepted-answer engagement | `acceptedAnswerEstimate` | 35% | 1,000 |
+| Community recognition | `soBadges` | 10% | 100 |
+
+```text
+stackoverflowIndex =
+ reputationNormalized * 0.55 +
+ acceptedAnswersNormalized * 0.35 +
+ badgesNormalized * 0.10
+
+stackoverflowCredits = hasStackOverflowData
+ ? round(stackoverflowIndex * 400000)
+ : 0
+```
+
+`hasStackOverflowData` is true when `soUserId` is present or at least one Stack Overflow metric is positive. A linked profile with all-zero public metrics is available with zero credits; an unlinked profile is unavailable with zero credits.
+
+### Combined OSS Worth
+
+```text
+ossWorth = githubCredits + stackoverflowCredits
+```
+
+This construction guarantees a maximum allocation of 600,000 GitHub credits plus 400,000 Stack Overflow credits. No secondary weighting or redistribution is applied.
+
+### Returned model
+
+The pure calculator returns:
+
+```json
+{
+ "formulaVersion": "oss-worth-v1",
+ "totalCredits": 0,
+ "github": {
+ "available": true,
+ "credits": 0,
+ "maxCredits": 600000,
+ "index": 0,
+ "breakdown": []
+ },
+ "stackoverflow": {
+ "available": false,
+ "credits": 0,
+ "maxCredits": 400000,
+ "index": 0,
+ "breakdown": []
+ }
+}
+```
+
+Each breakdown entry contains the source value, reference cap, normalized value, within-platform weight, and resulting credits. Internal calculations retain full precision; only displayed credits and final serialized credits are rounded.
+
+## Data contract
+
+No new external API calls are required. The v1 inputs already exist in developer documents:
+
+- GitHub: `totalStars`, `totalCommits`, `totalForks`, `totalWatchers`, `followers`, `publicRepos`.
+- Stack Overflow: `soUserId`, `soReputation`, `soAnswers`, `soAcceptRate`, `soBadges`.
+
+The public developer list currently omits `totalWatchers` and `publicRepos`; its Cosmos projection must include them. The detail projection must include every formula input consistently. Search, cards, MCP, and persisted documents do not need new fields for MVP unless they display OSS Worth.
+
+Worth should be computed through one shared pure module, proposed as `lib/oss-worth.js`. Callers may enrich a developer response with the returned model, but formula logic must not be duplicated in React components or API routes.
+
+Persisting calculated credits is not required for MVP. If later needed for Cosmos sorting or querying, persist `ossWorth`, `githubWorth`, `stackoverflowWorth`, and `ossWorthFormulaVersion` together and refresh them whenever source metrics change.
+
+## User experience
+
+### Developer detail
+
+Add an unframed **OSS Worth** section after the primary contribution statistics and before the existing score breakdown.
+
+The section contains:
+
+- A combined headline value such as `428K OSC` and a short fictional-credit disclaimer.
+- A GitHub Worth card showing credits out of 600K, its 60% allocation, and the five source metrics.
+- A Stack Overflow Worth card showing credits out of 400K, its 40% allocation, and the three source metrics.
+- A compact “How this is calculated” disclosure with formula version and reference caps.
+
+The two cards are siblings, never nested. On desktop they use a two-column grid; on narrow screens they stack. GitHub and Stack Overflow retain their recognizable platform accents without making the page a one-color theme.
+
+When Stack Overflow is unavailable, its card remains visible in an unavailable state with `No linked Stack Overflow profile` and `0 / 400K OSC`. It must not imply poor performance.
+
+### Leaderboard
+
+- Add a compact badge such as `OSC 428K` to each row.
+- Add `Worth` to the existing sort menu.
+- Sort descending by `totalCredits`, with the existing score and login as deterministic tie-breakers.
+- Preserve the row's fixed height and virtualized layout; the new badge must not shift action controls or truncate the developer name.
+- The existing score remains visible and remains the default sort.
+
+### Accessibility and formatting
+
+- Screen-reader text expands OSC to “OSS Credits.”
+- Values use the existing compact-number formatter; tooltips expose the full integer.
+- Platform cards do not rely on color alone.
+- The disclaimer is visible text, not tooltip-only content.
+- Zero and unavailable are distinct labels.
+
+## Integration plan
+
+1. Add `lib/oss-worth.js` with constants, normalization, platform calculations, and combined calculation.
+2. Add fixture-driven unit tests in `tests/oss-worth.test.js`.
+3. Include `totalWatchers` and `publicRepos` in the developer list/detail projections.
+4. Enrich developers once in the landing-page data pipeline; do not calculate repeatedly during renders or sorting.
+5. Add the OSS Worth section and two platform cards to `DetailPanel`.
+6. Add the compact value and Worth sort option to `Leaderboard`.
+7. Add responsive styles using the existing design tokens.
+8. Document the formula and fictional-value disclaimer in public methodology copy.
+
+## Analytics
+
+Track only aggregate interaction events:
+
+- `oss_worth_detail_viewed`
+- `oss_worth_breakdown_opened`
+- `leaderboard_sorted_by_worth`
+
+Do not include raw contribution metrics, calculated credit values, email, or private profile data in analytics payloads.
+
+## Testing
+
+### Unit tests
+
+- All-zero inputs produce zero credits.
+- Values at or above every cap produce exactly 600,000 GitHub credits and 400,000 Stack Overflow credits.
+- Missing Stack Overflow data produces `available: false`, zero SO credits, and no redistribution.
+- A linked all-zero Stack Overflow profile produces `available: true` and zero SO credits.
+- Increasing any one metric while others remain fixed never decreases credits.
+- Negative, missing, non-finite, and over-100 acceptance-rate inputs are safely clamped.
+- Rounding occurs only at platform-credit output.
+- Formula version is always returned.
+
+### UI tests
+
+- Both cards render with complete data.
+- The unavailable Stack Overflow state renders without hiding the card.
+- Compact and full values are formatted correctly.
+- Worth sorting is descending with deterministic ties.
+- Leaderboard rows retain stable dimensions at desktop and mobile widths.
+- The detail section stacks without overflow on mobile.
+
+### Regression checks
+
+- Existing DevGlobe scores and ranks are unchanged.
+- Profiles, search, globe markers, and leaderboard still load when optional metrics are absent.
+- `npm test` and `npm run build` pass.
+
+## Rollout
+
+1. Ship the pure calculator and tests behind no UI dependency.
+2. Add API projections and compare sampled outputs for low, median, and high-activity profiles.
+3. Enable the detail section.
+4. Enable leaderboard display and sorting after virtualized-row checks.
+5. Monitor UI errors and sort usage; formula changes require a new version and release note.
+
+## Success metrics
+
+- Percentage of profile-detail visitors who open the calculation disclosure.
+- Percentage of leaderboard sessions that sort by Worth.
+- Share-card generation after viewing OSS Worth, if sharing is added later.
+- No measurable regression in leaderboard rendering or initial data-load time.
+
+## Risks and mitigations
+
+- **Misread as financial value:** use OSS Credits, display the disclaimer, and prohibit currency conversion in v1.
+- **Metric gaming:** cap and log-normalize inputs; describe the result as playful rather than authoritative.
+- **Stale source data:** show the existing metrics freshness timestamp near the methodology.
+- **Missing Stack Overflow profiles:** keep the 40% unavailable instead of silently reallocating it.
+- **Formula drift:** export constants from one module and include `formulaVersion` in every result.
+- **Large payloads:** return the full breakdown only on detail surfaces if list payload size becomes material; leaderboard needs only total credits.
+
+## References
+
+- [GitHub Worth Calculator](https://github.com/enigma-137/github-worth): playful GitHub-only scoring, transparent breakdown, fixed multiplier, sharing, and an explicit entertainment disclaimer.
+- [CommitWorth](https://github.com/andreluizdasilvaa/CommitWorth): direct contribution rates, dashboard metrics, achievements, and generated cards.
+
+The references inform product behavior only. DevGlobe will implement its own formula and code against its existing data model.
diff --git a/lib/oss-worth.js b/lib/oss-worth.js
new file mode 100644
index 0000000..7042383
--- /dev/null
+++ b/lib/oss-worth.js
@@ -0,0 +1,103 @@
+export const OSS_WORTH_FORMULA_VERSION = 'oss-worth-v1';
+export const OSS_WORTH_MAX_CREDITS = 1_000_000;
+
+export const OSS_WORTH_PLATFORMS = {
+ github: {
+ maxCredits: 600_000,
+ dimensions: [
+ { key: 'stars', label: 'Repository stars', field: 'totalStars', weight: 0.30, cap: 100_000 },
+ { key: 'commits', label: 'Commit activity', field: 'totalCommits', weight: 0.30, cap: 10_000 },
+ { key: 'repoReach', label: 'Repository reach', weight: 0.25, cap: 50_000 },
+ { key: 'followers', label: 'Community reach', field: 'followers', weight: 0.10, cap: 25_000 },
+ { key: 'publicRepos', label: 'Public projects', field: 'publicRepos', weight: 0.05, cap: 100 },
+ ],
+ },
+ stackoverflow: {
+ maxCredits: 400_000,
+ dimensions: [
+ { key: 'reputation', label: 'Knowledge reputation', field: 'soReputation', weight: 0.55, cap: 100_000 },
+ { key: 'acceptedAnswers', label: 'Accepted-answer engagement', weight: 0.35, cap: 1_000 },
+ { key: 'badges', label: 'Community recognition', field: 'soBadges', weight: 0.10, cap: 100 },
+ ],
+ },
+};
+
+function nonNegativeNumber(value) {
+ const number = Number(value);
+ return Number.isFinite(number) ? Math.max(number, 0) : 0;
+}
+
+function normalize(value, cap) {
+ return Math.min(Math.log1p(nonNegativeNumber(value)) / Math.log1p(cap), 1);
+}
+
+function calculatePlatform(dimensions, maxCredits, values, available = true) {
+ const breakdown = dimensions.map(dimension => {
+ const sourceValue = nonNegativeNumber(values[dimension.key]);
+ const normalized = normalize(sourceValue, dimension.cap);
+ return {
+ ...dimension,
+ sourceValue,
+ normalized,
+ credits: normalized * dimension.weight * maxCredits,
+ };
+ });
+ const index = available
+ ? breakdown.reduce((total, dimension) => total + dimension.normalized * dimension.weight, 0)
+ : 0;
+
+ return {
+ available,
+ credits: Math.round(index * maxCredits),
+ maxCredits,
+ index,
+ breakdown,
+ };
+}
+
+export function calculateOssWorth(developer = {}) {
+ const acceptRate = Math.min(nonNegativeNumber(developer.soAcceptRate), 100);
+ const hasStackOverflowData = Boolean(developer.soUserId) ||
+ nonNegativeNumber(developer.soReputation) > 0 ||
+ nonNegativeNumber(developer.soAnswers) > 0 ||
+ nonNegativeNumber(developer.soBadges) > 0;
+
+ const github = calculatePlatform(
+ OSS_WORTH_PLATFORMS.github.dimensions,
+ OSS_WORTH_PLATFORMS.github.maxCredits,
+ {
+ stars: developer.totalStars,
+ commits: developer.totalCommits,
+ repoReach: nonNegativeNumber(developer.totalForks) + nonNegativeNumber(developer.totalWatchers),
+ followers: developer.followers,
+ publicRepos: developer.publicRepos,
+ }
+ );
+ const stackoverflow = calculatePlatform(
+ OSS_WORTH_PLATFORMS.stackoverflow.dimensions,
+ OSS_WORTH_PLATFORMS.stackoverflow.maxCredits,
+ {
+ reputation: developer.soReputation,
+ acceptedAnswers: nonNegativeNumber(developer.soAnswers) * acceptRate / 100,
+ badges: developer.soBadges,
+ },
+ hasStackOverflowData
+ );
+
+ return {
+ formulaVersion: OSS_WORTH_FORMULA_VERSION,
+ totalCredits: github.credits + stackoverflow.credits,
+ github,
+ stackoverflow,
+ };
+}
+
+export function withOssWorth(developer) {
+ return { ...developer, ossWorth: calculateOssWorth(developer) };
+}
+
+export function compareOssWorth(left, right) {
+ return (right.ossWorth?.totalCredits || 0) - (left.ossWorth?.totalCredits || 0) ||
+ (right.score || 0) - (left.score || 0) ||
+ String(left.login || '').localeCompare(String(right.login || ''));
+}
\ No newline at end of file
diff --git a/styles/main.css b/styles/main.css
index c1ef008..307f103 100644
--- a/styles/main.css
+++ b/styles/main.css
@@ -2578,6 +2578,154 @@ body {
margin-top: 4px;
}
+/* OSS Worth */
+.oss-worth {
+ margin: 0 0 24px;
+ padding: 18px 0 20px;
+ border-top: 1px solid var(--border);
+ border-bottom: 1px solid var(--border);
+}
+
+.oss-worth__heading {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.oss-worth__heading span {
+ color: var(--text-muted);
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.oss-worth__heading h3 {
+ margin: 3px 0 0;
+ color: var(--text-primary);
+ font-size: 18px;
+}
+
+.oss-worth__heading > strong {
+ color: #22d3ee;
+ font-size: 24px;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+.oss-worth__heading > strong small {
+ font-size: 11px;
+}
+
+.oss-worth__disclaimer {
+ margin: 9px 0 14px;
+ color: var(--text-muted);
+ font-size: 10px;
+ line-height: 1.45;
+}
+
+.oss-worth__cards {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.worth-card {
+ min-width: 0;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-card);
+}
+
+.worth-card--github { border-top: 2px solid #2ea44f; }
+.worth-card--stackoverflow { border-top: 2px solid #f48024; }
+.worth-card--unavailable { opacity: 0.72; }
+
+.worth-card__header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 6px;
+ margin-bottom: 7px;
+}
+
+.worth-card__header span {
+ overflow: hidden;
+ color: var(--text-primary);
+ font-size: 12px;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.worth-card__header small,
+.worth-card > strong small {
+ color: var(--text-muted);
+ font-size: 9px;
+ font-weight: 500;
+}
+
+.worth-card > strong {
+ color: var(--text-primary);
+ font-size: 17px;
+}
+
+.worth-card ul {
+ display: grid;
+ gap: 4px;
+ margin: 10px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.worth-card li {
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ color: var(--text-muted);
+ font-size: 9px;
+}
+
+.worth-card li span {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.worth-card li small {
+ color: var(--text-muted);
+ font-size: 8px;
+}
+
+.worth-card li b {
+ color: var(--text-secondary);
+ font-weight: 600;
+}
+
+.worth-card > p {
+ margin: 11px 0 0;
+ color: var(--text-muted);
+ font-size: 10px;
+ line-height: 1.4;
+}
+
+.oss-worth__methodology {
+ margin-top: 11px;
+ color: var(--text-muted);
+ font-size: 10px;
+}
+
+.oss-worth__methodology summary {
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.oss-worth__methodology p { margin: 6px 0 0; line-height: 1.45; }
+
/* SO bars */
.so-bars {
display: flex;
@@ -2641,6 +2789,8 @@ body {
display: flex;
gap: 6px;
margin-top: 3px;
+ overflow: hidden;
+ white-space: nowrap;
}
.lb-badge {
@@ -2660,6 +2810,11 @@ body {
color: var(--accent-so);
}
+.lb-badge--worth {
+ background: rgba(34, 211, 238, 0.13);
+ color: #22d3ee;
+}
+
/* Compare selection */
.sidebar__compare-control {
display: grid;
@@ -3351,6 +3506,9 @@ body {
}
@media (max-width: 480px) {
+ .oss-worth__cards {
+ grid-template-columns: 1fr;
+ }
.activity-banner {
justify-content: flex-start;
padding: 0 12px;
diff --git a/tests/oss-worth.test.js b/tests/oss-worth.test.js
new file mode 100644
index 0000000..ac33b84
--- /dev/null
+++ b/tests/oss-worth.test.js
@@ -0,0 +1,86 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ calculateOssWorth,
+ compareOssWorth,
+ OSS_WORTH_FORMULA_VERSION,
+ OSS_WORTH_MAX_CREDITS,
+} from '../lib/oss-worth.js';
+
+test('zero inputs produce zero credits with unavailable Stack Overflow', () => {
+ const worth = calculateOssWorth({});
+
+ assert.equal(worth.formulaVersion, OSS_WORTH_FORMULA_VERSION);
+ assert.equal(worth.totalCredits, 0);
+ assert.equal(worth.github.available, true);
+ assert.equal(worth.stackoverflow.available, false);
+});
+
+test('reference caps produce the exact 60/40 maximum allocation', () => {
+ const worth = calculateOssWorth({
+ totalStars: 100_000,
+ totalCommits: 10_000,
+ totalForks: 25_000,
+ totalWatchers: 25_000,
+ followers: 25_000,
+ publicRepos: 100,
+ soUserId: 1,
+ soReputation: 100_000,
+ soAnswers: 1_000,
+ soAcceptRate: 100,
+ soBadges: 100,
+ });
+
+ assert.equal(worth.github.credits, 600_000);
+ assert.equal(worth.stackoverflow.credits, 400_000);
+ assert.equal(worth.totalCredits, OSS_WORTH_MAX_CREDITS);
+});
+
+test('missing Stack Overflow data is not redistributed to GitHub', () => {
+ const worth = calculateOssWorth({
+ totalStars: 100_000,
+ totalCommits: 10_000,
+ totalForks: 50_000,
+ followers: 25_000,
+ publicRepos: 100,
+ });
+
+ assert.equal(worth.github.credits, 600_000);
+ assert.equal(worth.stackoverflow.available, false);
+ assert.equal(worth.stackoverflow.credits, 0);
+ assert.equal(worth.totalCredits, 600_000);
+});
+
+test('a linked all-zero Stack Overflow profile is available with zero credits', () => {
+ const worth = calculateOssWorth({ soUserId: 42 });
+
+ assert.equal(worth.stackoverflow.available, true);
+ assert.equal(worth.stackoverflow.credits, 0);
+});
+
+test('credits are monotonic and invalid inputs are clamped', () => {
+ const baseline = calculateOssWorth({ totalStars: 10, soUserId: 1, soAnswers: 10, soAcceptRate: 50 });
+ const increased = calculateOssWorth({ totalStars: 20, soUserId: 1, soAnswers: 10, soAcceptRate: 150 });
+ const invalid = calculateOssWorth({ totalStars: -1, totalCommits: Infinity, soUserId: 1, soAnswers: -10, soAcceptRate: NaN });
+
+ assert.ok(increased.github.credits >= baseline.github.credits);
+ assert.ok(increased.stackoverflow.credits >= baseline.stackoverflow.credits);
+ assert.equal(invalid.github.credits, 0);
+ assert.equal(invalid.stackoverflow.credits, 0);
+});
+
+test('worth sorting uses score and login as deterministic tie-breakers', () => {
+ const developers = [
+ { login: 'charlie', score: 20, ossWorth: { totalCredits: 200 } },
+ { login: 'bravo', score: 40, ossWorth: { totalCredits: 100 } },
+ { login: 'alpha', score: 40, ossWorth: { totalCredits: 100 } },
+ { login: 'delta', score: 90, ossWorth: { totalCredits: 300 } },
+ ];
+
+ assert.deepEqual(developers.sort(compareOssWorth).map(developer => developer.login), [
+ 'delta',
+ 'charlie',
+ 'alpha',
+ 'bravo',
+ ]);
+});
\ No newline at end of file