Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 122 additions & 47 deletions src/app/discovery/page.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,137 @@
import { mockDiscovery } from "@/data/mock-discovery";

function scoreBounty(bounty: typeof mockDiscovery[number]) {
const fundedBoost = bounty.fundedPercent >= 80 ? 20 : bounty.fundedPercent / 4;
const activityBoost = bounty.claimedCount * 5;
const recencyBoost = Math.max(0, 14 - bounty.postedDaysAgo);
return fundedBoost + activityBoost + recencyBoost + bounty.reward / 50;
}
import { rankBounties, ScoredBounty } from "@/lib/discovery-algorithm";

export default function DiscoveryPage() {
const ranked = [...mockDiscovery]
.map((bounty) => ({ ...bounty, score: scoreBounty(bounty) }))
.sort((a, b) => b.score - a.score);
const ranked = rankBounties(mockDiscovery);

return (
<div className="space-y-6">
<div>
<div className="card p-6">
<h1 className="text-2xl font-semibold">Discovery Algorithm</h1>
<p className="text-slate-600">
Improve or replace the scoring function to rank bounties by relevance.
<p className="text-slate-600 mt-2">
Bounties ranked by relevance using a multi-factor scoring system.
</p>
<div className="mt-4 p-4 bg-slate-50 rounded-lg text-sm">
<h3 className="font-semibold mb-2">Scoring Factors (0-100 total):</h3>
<ul className="grid gap-1 md:grid-cols-2">
<li className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-blue-500"></span>
Funding Progress (0-30 pts)
</li>
<li className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-green-500"></span>
Activity Level (0-25 pts)
</li>
<li className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-amber-500"></span>
Recency (0-20 pts)
</li>
<li className="flex items-center gap-2">
<span className="w-3 h-3 rounded bg-purple-500"></span>
Reward Amount (0-25 pts)
</li>
</ul>
</div>
</div>

<div className="grid gap-4 md:grid-cols-2">
{ranked.map((bounty) => (
<div key={bounty.id} className="card p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold">{bounty.title}</h3>
<div className="mt-2 flex flex-wrap gap-2">
{bounty.tags.map((tag) => (
<span key={tag} className="pill">
{tag}
</span>
))}
</div>
</div>
<div className="text-right">
<div className="text-sm text-slate-500">Reward</div>
<div className="text-xl font-bold">${bounty.reward}</div>
</div>
</div>
<div className="mt-4 grid grid-cols-3 gap-3 text-xs text-slate-500">
<div>
Funded: <span className="font-semibold text-slate-900">{bounty.fundedPercent}%</span>
</div>
<div>
Claims: <span className="font-semibold text-slate-900">{bounty.claimedCount}</span>
</div>
<div>
Posted: <span className="font-semibold text-slate-900">{bounty.postedDaysAgo}d ago</span>
</div>
</div>
<div className="mt-3 text-xs text-slate-500">
Score: <span className="font-semibold text-brand-700">{bounty.score.toFixed(1)}</span>
</div>
</div>
<div className="grid gap-4">
{ranked.map((bounty, index) => (
<BountyCard key={bounty.id} bounty={bounty} rank={index + 1} />
))}
</div>
</div>
);
}

function BountyCard({ bounty, rank }: { bounty: ScoredBounty; rank: number }) {
return (
<div className="card p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3">
<span className="flex-shrink-0 w-8 h-8 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center font-bold text-sm">
#{rank}
</span>
<h3 className="text-lg font-semibold">{bounty.title}</h3>
</div>
<div className="mt-2 flex flex-wrap gap-2 ml-11">
{bounty.tags.map((tag) => (
<span key={tag} className="pill">
{tag}
</span>
))}
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="text-sm text-slate-500">Reward</div>
<div className="text-2xl font-bold text-green-600">${bounty.reward}</div>
</div>
</div>

{/* Metrics Row */}
<div className="mt-4 ml-11 grid grid-cols-3 gap-4 text-sm">
<div>
<div className="text-slate-500">Funded</div>
<div className="font-semibold">{bounty.fundedPercent}%</div>
</div>
<div>
<div className="text-slate-500">Claims</div>
<div className="font-semibold">{bounty.claimedCount}</div>
</div>
<div>
<div className="text-slate-500">Posted</div>
<div className="font-semibold">{bounty.postedDaysAgo}d ago</div>
</div>
</div>

{/* Score Breakdown */}
<div className="mt-4 ml-11">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm text-slate-500">Total Score:</span>
<span className="text-lg font-bold text-brand-600">{bounty.score}</span>
</div>
<div className="grid grid-cols-4 gap-2 text-xs">
<ScoreBar label="Funding" score={bounty.scoreBreakdown.funding} max={30} color="blue" />
<ScoreBar label="Activity" score={bounty.scoreBreakdown.activity} max={25} color="green" />
<ScoreBar label="Recency" score={bounty.scoreBreakdown.recency} max={20} color="amber" />
<ScoreBar label="Reward" score={bounty.scoreBreakdown.reward} max={25} color="purple" />
</div>
</div>
</div>
);
}

function ScoreBar({
label,
score,
max,
color
}: {
label: string;
score: number;
max: number;
color: "blue" | "green" | "amber" | "purple"
}) {
const percent = (score / max) * 100;
const colorClasses = {
blue: "bg-blue-500",
green: "bg-green-500",
amber: "bg-amber-500",
purple: "bg-purple-500",
};

return (
<div>
<div className="flex justify-between text-slate-500 mb-1">
<span>{label}</span>
<span className="font-medium text-slate-700">{score}</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${colorClasses[color]}`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
);
}
179 changes: 179 additions & 0 deletions src/lib/discovery-algorithm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* Discovery Algorithm - Bounty Ranking System
*
* This module implements a scoring algorithm to rank bounties by relevance.
* The score is a weighted combination of multiple factors:
*
* 1. Funding Progress (0-30 points) - Higher funding = higher priority
* 2. Activity Level (0-25 points) - More claims = more validated
* 3. Recency (0-20 points) - Fresh bounties get priority
* 4. Reward Amount (0-25 points) - Higher rewards attract contributors
*
* Total max score: 100 points
*/

export interface Bounty {
id: string;
title: string;
reward: number;
fundedPercent: number;
tags: string[];
claimedCount: number;
postedDaysAgo: number;
}

export interface ScoredBounty extends Bounty {
score: number;
scoreBreakdown: {
funding: number;
activity: number;
recency: number;
reward: number;
};
}

/**
* Calculate funding score (0-30 points)
*
* Logic: Uses a piecewise function to reward well-funded bounties
* - 80-100% funded: Full points (25-30) - nearly complete, high confidence
* - 50-80% funded: Medium points (15-25) - good momentum
* - 20-50% funded: Low-medium points (8-15) - building momentum
* - 0-20% funded: Low points (0-8) - just started
*/
function calculateFundingScore(fundedPercent: number): number {
if (fundedPercent >= 80) {
// Exponential bonus for near-complete funding
return 25 + ((fundedPercent - 80) / 20) * 5;
} else if (fundedPercent >= 50) {
// Linear scaling in the middle range
return 15 + ((fundedPercent - 50) / 30) * 10;
} else if (fundedPercent >= 20) {
// Gradual increase for building momentum
return 8 + ((fundedPercent - 20) / 30) * 7;
} else {
// Low points for just-started bounties
return (fundedPercent / 20) * 8;
}
}

/**
* Calculate activity score (0-25 points)
*
* Logic: More claims indicates a validated, valuable bounty
* Uses diminishing returns to prevent claim spam from dominating
* - 5+ claims: Max points (25) - highly validated
* - 3-4 claims: High points (18-22) - well validated
* - 1-2 claims: Medium points (8-15) - some validation
* - 0 claims: 0 points - unvalidated
*/
function calculateActivityScore(claimedCount: number): number {
if (claimedCount >= 5) {
return 25;
} else if (claimedCount >= 4) {
return 22;
} else if (claimedCount >= 3) {
return 18;
} else if (claimedCount >= 2) {
return 15;
} else if (claimedCount >= 1) {
return 8;
}
return 0;
}

/**
* Calculate recency score (0-20 points)
*
* Logic: Exponential decay from posting date
* Fresh bounties get priority to ensure new opportunities surface
* - 0-2 days: High priority (18-20 points)
* - 3-7 days: Medium-high priority (10-18 points)
* - 8-14 days: Medium priority (4-10 points)
* - 15+ days: Low priority (0-4 points)
*/
function calculateRecencyScore(postedDaysAgo: number): number {
if (postedDaysAgo <= 2) {
return 20 - postedDaysAgo;
} else if (postedDaysAgo <= 7) {
// Exponential decay: 18 -> 10 over 5 days
return 18 * Math.exp(-0.12 * (postedDaysAgo - 2));
} else if (postedDaysAgo <= 14) {
// Continued decay but slower
return 10 * Math.exp(-0.08 * (postedDaysAgo - 7));
} else {
// Very old bounties get minimal points
return Math.max(0, 4 - (postedDaysAgo - 14) * 0.5);
}
}

/**
* Calculate reward score (0-25 points)
*
* Logic: Normalize reward amounts to a 0-25 scale
* Uses logarithmic scaling to prevent high rewards from dominating
* - $500+: Max points (25)
* - $200-500: High points (18-25)
* - $100-200: Medium points (12-18)
* - $0-100: Low points (0-12)
*/
function calculateRewardScore(reward: number): number {
// Logarithmic scaling to compress the range
// Using natural log with base conversion: log10(x) = ln(x) / ln(10)
// log10(1000) β‰ˆ 3, so we get a nice 0-25 range
const log10 = Math.log(reward + 1) / Math.log(10);
const normalizedScore = log10 / 3 * 25;
return Math.min(25, normalizedScore);
}

/**
* Main scoring function - calculates total score for a bounty
*
* @param bounty - The bounty to score
* @returns ScoredBounty with total score and breakdown
*/
export function scoreBounty(bounty: Bounty): ScoredBounty {
const funding = calculateFundingScore(bounty.fundedPercent);
const activity = calculateActivityScore(bounty.claimedCount);
const recency = calculateRecencyScore(bounty.postedDaysAgo);
const reward = calculateRewardScore(bounty.reward);

const score = funding + activity + recency + reward;

return {
...bounty,
score: Math.round(score * 10) / 10, // Round to 1 decimal place
scoreBreakdown: {
funding: Math.round(funding * 10) / 10,
activity: Math.round(activity * 10) / 10,
recency: Math.round(recency * 10) / 10,
reward: Math.round(reward * 10) / 10,
},
};
}

/**
* Rank an array of bounties by score (descending)
*
* @param bounties - Array of bounties to rank
* @returns Array of scored and sorted bounties
*/
export function rankBounties(bounties: Bounty[]): ScoredBounty[] {
return bounties
.map(scoreBounty)
.sort((a, b) => b.score - a.score);
}

/**
* Get top N bounties by score
*/
export function getTopBounties(bounties: Bounty[], n: number): ScoredBounty[] {
return rankBounties(bounties).slice(0, n);
}

/**
* Filter bounties by minimum score threshold
*/
export function filterByMinScore(bounties: Bounty[], minScore: number): ScoredBounty[] {
return rankBounties(bounties).filter(b => b.score >= minScore);
}