diff --git a/.gitignore b/.gitignore index 9a5aced..93bdc13 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,4 @@ dist # Vite logs files vite.config.js.timestamp-* vite.config.ts.timestamp-* +server/ \ No newline at end of file diff --git a/algorithm.md b/algorithm.md new file mode 100644 index 0000000..93891cb --- /dev/null +++ b/algorithm.md @@ -0,0 +1,137 @@ +# DevImpact + + + + +### 🧠 Main +``` + compareUsers(user1, user2): + + score1 = calculateUserScore(user1) + score2 = calculateUserScore(user2) + + IF score1 > score2: + RETURN user1 as winner + ELSE: + RETURN user2 as winner +``` + +### 🧠 User Score +``` + calculateUserScore(user): + + repos = getUserRepositories(user) // get the first 100 top repos + prs = getUserPullRequests(user) // get the latest 100 top merged PRs that's not merged to the user repo + contributions = getUserContributions(user) + + repoScore = calculateRepoScore(repos) + prScore = calculatePRScore(prs, user) + contributionScore = calculateContributionScore(contributions) + + finalScore = + repoScore * 0.4 + + prScore * 0.4 + + contributionScore * 0.2 + + RETURN finalScore +``` + +### 📦 Repository Score +``` + calculateRepoScore(repos): + + scores = [] + + FOR EACH repo IN repos: + score = + log(repo.stars + 1) * 5 + + log(repo.forks + 1) * 3 + + log(repo.watchers + 1) * 2 + + ADD score TO scores + + SORT scores DESC + + total = 0 + + FOR i FROM 0 TO length(scores)-1: + IF i < 5: + weight = 1 // top repos matter most + ELSE: + weight = 0.1 // others have low impact + + total += scores[i] * weight + + RETURN total +``` + + +### 🔥 Pull Request Score +``` + calculatePRScore(prs, username): + + groupedPRs = groupPRsByRepository(prs) + + totalScore = 0 + + FOR EACH repo IN groupedPRs: + + repoPRs = groupedPRs[repo] + + prScores = [] + + FOR EACH pr IN repoPRs: + + // ❌ Ignore PRs to user's own repo + IF pr.repoOwner == username: + CONTINUE + + // ❌ Ignore non-merged PRs + IF NOT pr.isMerged: + CONTINUE + + // ✅ Base score (only for valid PRs) + base = + log(pr.repoStars + 1) * 2 + + // Optional: PR size factor (recommended) + sizeFactor = log(pr.additions + pr.deletions + 1) + + score = base * sizeFactor + + ADD score TO prScores + + // If no valid PRs, skip repo + IF length(prScores) == 0: + CONTINUE + + SORT prScores DESC + + // diminishing returns inside same repo + repoTotal = 0 + + FOR i FROM 0 TO length(prScores)-1: + weight = 1 / (i + 1) + repoTotal += prScores[i] * weight + + totalScore += repoTotal + + RETURN totalScore +``` + + +### 🌍 Contribution Score (Activity) +``` + calculateContributionScore(contributions): + + commits = contributions.commits // public commits + prs = contributions.prs + issues = contributions.issues // public issues + + score = + commits * 0.5 + + prs * 2 + + issues * 0.3 + + RETURN score +``` \ No newline at end of file diff --git a/app/api/compare/route.ts b/app/api/compare/route.ts index c7991a0..72ce25d 100644 --- a/app/api/compare/route.ts +++ b/app/api/compare/route.ts @@ -1,50 +1,229 @@ import { NextResponse } from "next/server"; import { fetchGitHubUserData } from "../../../lib/github"; import { calculateUserScore } from "../../../lib/score"; +import { normalizeSelectedLanguages } from "@/lib/scoring/languageScoring"; export const runtime = "nodejs"; +type CompareRequestBody = { + username1?: string; + username2?: string; + selectedLanguages?: string[]; +}; + +type ComparedUserResult = { + username: string; + name: string | null; + avatarUrl: string; + repoScore: number; + prScore: number; + contributionScore: number; + finalScore: number; + normalizedRepoScore: number; + normalizedPRScore: number; + normalizedContributionScore: number; + normalizedFinalScore: number; + topRepos: ReturnType["topRepos"]; + topPullRequests: ReturnType["topPullRequests"]; + topCommunityContributions: ReturnType< + typeof calculateUserScore + >["topCommunityContributions"]; + languageScores: ReturnType["languageScores"]; + signals: ReturnType["signals"]; + explanations: ReturnType["explanations"]; +}; + +function parseSelectedLanguagesFromSearchParams( + searchParams: URLSearchParams, +): string[] { + const fromRepeated = searchParams.getAll("selectedLanguage"); + const fromCsv = searchParams + .get("selectedLanguages") + ?.split(",") + .map((language) => language.trim()) + .filter(Boolean); + + return normalizeSelectedLanguages([...(fromRepeated ?? []), ...(fromCsv ?? [])]); +} + +function calculateWinner(users: ComparedUserResult[]): { + winner?: { + username: string; + finalScoreDifference: number; + percentageDifference: number; + }; + languageWinner?: { + username: string; + finalScoreDifference: number; + percentageDifference: number; + selectedLanguages: string[]; + }; +} { + if (users.length !== 2) { + return {}; + } + + const [userA, userB] = users; + const overallWinner = userA.finalScore >= userB.finalScore ? userA : userB; + const overallLoser = overallWinner.username === userA.username ? userB : userA; + const overallDifference = Math.abs(userA.finalScore - userB.finalScore); + const overallPercentage = + overallLoser.finalScore > 0 + ? (overallDifference / overallLoser.finalScore) * 100 + : 0; + + const result: { + winner: { + username: string; + finalScoreDifference: number; + percentageDifference: number; + }; + languageWinner?: { + username: string; + finalScoreDifference: number; + percentageDifference: number; + selectedLanguages: string[]; + }; + } = { + winner: { + username: overallWinner.username, + finalScoreDifference: Math.round(overallDifference), + percentageDifference: Math.round(overallPercentage), + }, + }; + + if (userA.languageScores && userB.languageScores) { + const languageWinner = + userA.languageScores.finalScore >= userB.languageScores.finalScore + ? userA + : userB; + const languageLoser = languageWinner.username === userA.username ? userB : userA; + const winnerLanguageScores = languageWinner.languageScores!; + const loserLanguageScores = languageLoser.languageScores!; + const languageDifference = Math.abs( + winnerLanguageScores.finalScore - loserLanguageScores.finalScore, + ); + const languagePercentage = + loserLanguageScores.finalScore > 0 + ? (languageDifference / loserLanguageScores.finalScore) * 100 + : 0; + + result.languageWinner = { + username: languageWinner.username, + finalScoreDifference: Math.round(languageDifference), + percentageDifference: Math.round(languagePercentage), + selectedLanguages: winnerLanguageScores.selectedLanguages, + }; + } + + return result; +} + +async function compareUsers( + usernames: string[], + selectedLanguages: string[], +): Promise { + return Promise.all( + usernames.map(async (username) => { + const data = await fetchGitHubUserData(username); + const score = calculateUserScore( + { + ...data, + selectedLanguages, + }, + username, + ); + + return { + username, + name: data.name, + avatarUrl: data.avatarUrl, + repoScore: Math.round(score.repoScore), + prScore: Math.round(score.prScore), + contributionScore: Math.round(score.contributionScore), + finalScore: Math.round(score.finalScore), + normalizedRepoScore: Math.round(score.normalizedRepoScore), + normalizedPRScore: Math.round(score.normalizedPRScore), + normalizedContributionScore: Math.round(score.normalizedContributionScore), + normalizedFinalScore: Math.round(score.normalizedFinalScore), + topRepos: score.topRepos, + topPullRequests: score.topPullRequests, + topCommunityContributions: score.topCommunityContributions, + languageScores: score.languageScores, + signals: score.signals, + explanations: score.explanations, + }; + }), + ); +} + export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const usernames = searchParams.getAll("username"); + const usernames = searchParams + .getAll("username") + .map((username) => username.trim()) + .filter(Boolean); if (usernames.length === 0) { return NextResponse.json( { success: false, error: "provide at least one username param" }, - { status: 400 } + { status: 400 }, ); } try { - const results = await Promise.all( - usernames.map(async (username) => { - const data = await fetchGitHubUserData(username); - const score = calculateUserScore(data, username); - - return { - username, - name: data.name, - avatarUrl: data.avatarUrl, - repoScore: Math.round(score.repoScore), - prScore: Math.round(score.prScore), - contributionScore: Math.round(score.contributionScore), - finalScore: Math.round(score.finalScore), - topRepos: score.topRepos, - topPullRequests: score.topPullRequests, - }; - }) - ); - - return NextResponse.json({ success: true, users: results }); + const selectedLanguages = parseSelectedLanguagesFromSearchParams(searchParams); + const users = await compareUsers(usernames, selectedLanguages); + const winnerData = calculateWinner(users); + return NextResponse.json({ success: true, users, ...winnerData }); } catch (error: unknown) { console.error("GitHub score error:", error); const message = error instanceof Error && error.message === "User not found" ? "GitHub user not found" : "Failed to calculate score"; + return NextResponse.json({ success: false, error: message }, { status: 500 }); + } +} + +export async function POST(request: Request) { + let body: CompareRequestBody; + + try { + body = (await request.json()) as CompareRequestBody; + } catch { + return NextResponse.json( + { success: false, error: "Invalid JSON body" }, + { status: 400 }, + ); + } + + const usernames = [body.username1, body.username2] + .map((username) => username?.trim() ?? "") + .filter(Boolean); + + if (usernames.length !== 2) { return NextResponse.json( - { success: false, error: message }, - { status: 500 } + { + success: false, + error: "Provide username1 and username2 in the request body", + }, + { status: 400 }, ); } + + const selectedLanguages = normalizeSelectedLanguages(body.selectedLanguages); + + try { + const users = await compareUsers(usernames, selectedLanguages); + const winnerData = calculateWinner(users); + return NextResponse.json({ success: true, users, ...winnerData }); + } catch (error: unknown) { + console.error("GitHub score error:", error); + const message = + error instanceof Error && error.message === "User not found" + ? "GitHub user not found" + : "Failed to calculate score"; + return NextResponse.json({ success: false, error: message }, { status: 500 }); + } } diff --git a/app/page.tsx b/app/page.tsx index 9f83c9a..d474712 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,6 +11,14 @@ import { AppHeader } from "@/components/app-header"; import { AppFooter } from "@/components/app-footer"; import { useTranslation } from "@/components/language-provider"; import { ApiResponse } from "@/types/api-response"; +import { cn } from "@/lib/utils"; + +type ComparisonData = { + user1: UserResult; + user2: UserResult; +}; + +const EXIT_ANIMATION_MS = 240; function HomePageInner() { const { t } = useTranslation(); @@ -23,13 +31,12 @@ function HomePageInner() { const [error, setError] = useState(null); const [username1, setUsername1] = useState(initialUsername1); const [username2, setUsername2] = useState(initialUsername2); - const [data, setData] = useState<{ - user1: UserResult; - user2: UserResult; - } | null>(null); + const [data, setData] = useState(null); + const [displayData, setDisplayData] = useState(null); // Track the URL pair we last fetched against so back/forward navigation // can resync the form and results without re-fetching identical pairs. const lastFetchedPairRef = useRef<[string, string] | null>(null); + const hideTimerRef = useRef(null); const localizeErrorMessage = (message?: string) => { switch (message) { @@ -56,7 +63,6 @@ function HomePageInner() { ); setLoading(true); setError(null); - setData(null); try { const params = new URLSearchParams(); @@ -71,19 +77,26 @@ function HomePageInner() { } if (body.users[0].finalScore > body.users[1].finalScore) { - setData({ + const nextData = { user1: { ...body.users[0], isWinner: true }, user2: body.users[1], - }); + }; + setData(nextData); + setDisplayData(nextData); } else if (body.users[1].finalScore > body.users[0].finalScore) { - setData({ + const nextData = { user1: body.users[0], user2: { ...body.users[1], isWinner: true }, - }); + }; + setData(nextData); + setDisplayData(nextData); } else { - setData({ user1: body.users[0], user2: body.users[1] }); + const nextData = { user1: body.users[0], user2: body.users[1] }; + setData(nextData); + setDisplayData(nextData); } } catch (err: unknown) { + setData(null); setError(localizeErrorMessage(err instanceof Error ? err.message : undefined)); } finally { setLoading(false); @@ -119,9 +132,44 @@ function HomePageInner() { syncToUrl(params[0] ?? "", params[1] ?? ""); }, [searchParams]); + useEffect(() => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + + if (data) { + setDisplayData(data); + return; + } + + if (loading) { + return; + } + + if (!displayData) { + return; + } + + hideTimerRef.current = window.setTimeout(() => { + setDisplayData(null); + hideTimerRef.current = null; + }, EXIT_ANIMATION_MS); + + return () => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + }; + }, [data, displayData, loading]); + const skeleton = useMemo(() => , []); + const isRefreshing = loading && Boolean(displayData); + const isExiting = !loading && !data && Boolean(displayData); const reset = () => { + setLoading(false); setData(null); setError(null); setUsername1(""); @@ -161,22 +209,52 @@ function HomePageInner() { hasData={Boolean(data)} /> - {loading && skeleton} - {error && ( -
- {error} -
- )} - - {data && } - - {!loading && !error && !data && ( -
- -

{t("page.empty.title")}

-

{t("page.empty.description")}

-
- )} +
+ {displayData ? ( +
+ +
+ ) : loading ? ( +
+ {skeleton} +
+ ) : null} + + {loading && displayData && ( +
+
+ {t("form.compare.ing")} +
+
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && !displayData && ( +
+ +

{t("page.empty.title")}

+

{t("page.empty.description")}

+
+ )} +
diff --git a/components/app-footer.tsx b/components/app-footer.tsx index da386ee..0e8422d 100644 --- a/components/app-footer.tsx +++ b/components/app-footer.tsx @@ -56,7 +56,7 @@ export function AppFooter() { > {t("footer.note")}

- + diff --git a/components/brand-logo.tsx b/components/brand-logo.tsx index 969b587..9f71678 100644 --- a/components/brand-logo.tsx +++ b/components/brand-logo.tsx @@ -17,7 +17,7 @@ const sizeClasses = { export function BrandLogo({ size = "md", className, - priority = false, + priority = true, }: BrandLogoProps) { return ( ); diff --git a/components/github-link.tsx b/components/github-link.tsx index 22ff1fb..2c209c9 100644 --- a/components/github-link.tsx +++ b/components/github-link.tsx @@ -1,24 +1,46 @@ "use client"; -import { Button } from "./ui/button"; import { FaGithub } from "react-icons/fa"; -export function GithubLink() { +import { cn } from "@/lib/utils"; + +type GithubLinkProps = { + variant?: "compact" | "prominent"; +}; + +export function GithubLink({ variant = "compact" }: GithubLinkProps) { + const isProminent = variant === "prominent"; + return ( - + + + + + {isProminent ? ( + + GitHub + + ) : null} + ); -} \ No newline at end of file +} diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx index 6a2cab1..915dfc2 100644 --- a/components/theme-toggle.tsx +++ b/components/theme-toggle.tsx @@ -44,12 +44,7 @@ export function ThemeToggle() { > {mounted && current === "dark" ? : } - - - + - - - ); } diff --git a/github-issues.json b/github-issues.json new file mode 100644 index 0000000..d75818f --- /dev/null +++ b/github-issues.json @@ -0,0 +1,8 @@ +[ + + { + "title": "refactor: Extract ApiResponse type to the types directory", + "body": "The `ApiResponse` type is currently defined directly inside `app/page.tsx`. To improve code organization and make the type reusable across other files (such as API routes), it should be extracted into its own file within the `types` directory.\n\n### Tasks\n- [ ] Create a new file `types/api-response.ts`.\n- [ ] Move the `ApiResponse` type definition from `app/page.tsx` into this new file and `export` it.\n- [ ] Update `app/page.tsx` to import the `ApiResponse` type from the new file.", + "labels": "help wanted,good first issue,refactor,easy,beginner friendly" + } +] \ No newline at end of file diff --git a/lib/github.ts b/lib/github.ts index f836f2b..7a0c9c0 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -1,12 +1,76 @@ -import { ContributionTotals, GitHubUserData, PullRequestNode, RepoNode } from "@/types/github"; +import type { + DiscussionNode, + GitHubUserData, + IssueNode, + PullRequestNode, + ReactionSummary, + RepoNode, +} from "@/types/github"; import { graphql } from "@octokit/graphql"; type GitHubRawUser = { name: string | null; avatarUrl: string; - repositories: { nodes: RepoNode[] }; - pullRequests: { nodes: PullRequestNode[] }; - contributionsCollection: ContributionTotals; + repositories: { nodes: Array }; + contributionsCollection: { + totalCommitContributions: number; + totalPullRequestContributions: number; + totalIssueContributions: number; + }; +}; + +type RawIssueNode = { + title: string; + url?: string; + comments: { totalCount: number }; + thumbsUp: { totalCount: number }; + thumbsDown: { totalCount: number }; + heart: { totalCount: number }; + hooray: { totalCount: number }; + rocket: { totalCount: number }; + eyes: { totalCount: number }; + confused: { totalCount: number }; + laugh: { totalCount: number }; + repository: { + nameWithOwner: string; + stargazerCount: number; + owner: { login: string }; + languages?: RepoNode["languages"]; + }; +}; + +type RawDiscussionNode = { + title: string; + url?: string; + comments: { totalCount: number }; + thumbsUp: { totalCount: number }; + thumbsDown: { totalCount: number }; + heart: { totalCount: number }; + hooray: { totalCount: number }; + rocket: { totalCount: number }; + eyes: { totalCount: number }; + confused: { totalCount: number }; + laugh: { totalCount: number }; + repository: { + nameWithOwner: string; + stargazerCount: number; + owner: { login: string }; + languages?: RepoNode["languages"]; + }; +}; + +type FetchUserDataResponse = { + user: GitHubRawUser | null; + pullRequests: { nodes: Array }; + issues: { nodes: Array }; + discussions: { nodes: Array }; + rateLimit: { + limit: number; + remaining: number; + used: number; + resetAt: string; + cost: number; + }; }; if (!process.env.GITHUB_TOKEN) { @@ -19,12 +83,21 @@ const client = graphql.defaults({ }, }); - const QUERY = /* GraphQL */ ` - query FetchUserData($login: String!, $repoCount: Int = 100, $prCount: Int = 100) { + query FetchUserData( + $login: String! + $repoCount: Int = 100 + $prCount: Int = 100 + $issueCount: Int = 100 + $discussionCount: Int = 100 + $externalPrQuery: String! + $externalIssueQuery: String! + $externalDiscussionQuery: String! + ) { user(login: $login) { name avatarUrl(size: 80) + repositories( first: $repoCount privacy: PUBLIC @@ -33,46 +106,239 @@ const QUERY = /* GraphQL */ ` ) { nodes { name + nameWithOwner + url + isFork stargazerCount forkCount + pushedAt watchers { totalCount } + languages(first: 10, orderBy: { field: SIZE, direction: DESC }) { + edges { + size + node { + name + } + } + } } } - pullRequests( - first: $prCount - states: [MERGED] - orderBy: { field: CREATED_AT, direction: DESC } - ) { - nodes { + + contributionsCollection { + totalCommitContributions + totalPullRequestContributions + totalIssueContributions + } + } + + pullRequests: search(query: $externalPrQuery, type: ISSUE, first: $prCount) { + nodes { + ... on PullRequest { merged additions deletions title url + repository { nameWithOwner + url stargazerCount + pushedAt owner { login } + languages(first: 10, orderBy: { field: SIZE, direction: DESC }) { + edges { + size + node { + name + } + } + } } } } - contributionsCollection { - totalCommitContributions - totalPullRequestContributions - totalIssueContributions + } + + issues: search(query: $externalIssueQuery, type: ISSUE, first: $issueCount) { + nodes { + ... on Issue { + title + url + comments { + totalCount + } + thumbsUp: reactions(content: THUMBS_UP) { + totalCount + } + thumbsDown: reactions(content: THUMBS_DOWN) { + totalCount + } + heart: reactions(content: HEART) { + totalCount + } + hooray: reactions(content: HOORAY) { + totalCount + } + rocket: reactions(content: ROCKET) { + totalCount + } + eyes: reactions(content: EYES) { + totalCount + } + confused: reactions(content: CONFUSED) { + totalCount + } + laugh: reactions(content: LAUGH) { + totalCount + } + repository { + nameWithOwner + stargazerCount + owner { + login + } + languages(first: 10, orderBy: { field: SIZE, direction: DESC }) { + edges { + size + node { + name + } + } + } + } + } + } + } + + discussions: search( + query: $externalDiscussionQuery + type: DISCUSSION + first: $discussionCount + ) { + nodes { + ... on Discussion { + title + url + comments { + totalCount + } + thumbsUp: reactions(content: THUMBS_UP) { + totalCount + } + thumbsDown: reactions(content: THUMBS_DOWN) { + totalCount + } + heart: reactions(content: HEART) { + totalCount + } + hooray: reactions(content: HOORAY) { + totalCount + } + rocket: reactions(content: ROCKET) { + totalCount + } + eyes: reactions(content: EYES) { + totalCount + } + confused: reactions(content: CONFUSED) { + totalCount + } + laugh: reactions(content: LAUGH) { + totalCount + } + repository { + nameWithOwner + stargazerCount + owner { + login + } + languages(first: 10, orderBy: { field: SIZE, direction: DESC }) { + edges { + size + node { + name + } + } + } + } + } } } + + rateLimit { + limit + remaining + used + resetAt + cost + } } `; +function isDefined(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} + +function toReactionSummary(item: RawIssueNode): ReactionSummary { + return { + thumbsUp: item.thumbsUp.totalCount, + thumbsDown: item.thumbsDown.totalCount, + heart: item.heart.totalCount, + hooray: item.hooray.totalCount, + rocket: item.rocket.totalCount, + eyes: item.eyes.totalCount, + confused: item.confused.totalCount, + laugh: item.laugh.totalCount, + }; +} + +function toIssueNode(item: RawIssueNode): IssueNode { + return { + title: item.title, + url: item.url, + comments: item.comments, + reactions: toReactionSummary(item), + repository: item.repository, + }; +} + +function toDiscussionNode(item: RawDiscussionNode): DiscussionNode { + return { + title: item.title, + url: item.url, + comments: item.comments, + reactions: toReactionSummary(item), + repository: item.repository, + }; +} + export async function fetchGitHubUserData( - username: string + username: string, ): Promise { - const { user } = await client<{ user: GitHubRawUser | null }>(QUERY, { login: username }); + const externalPrQuery = `type:pr is:merged author:${username} -user:${username}`; + const externalIssueQuery = `type:issue author:${username} -user:${username}`; + const externalDiscussionQuery = `author:${username} -user:${username}`; + + const response = await client(QUERY, { + login: username, + repoCount: 100, + prCount: 100, + issueCount: 100, + discussionCount: 100, + externalPrQuery, + externalIssueQuery, + externalDiscussionQuery, + headers: { + authorization: `bearer ${process.env.GITHUB_TOKEN}`, + }, + }); + + const user = response.user; + console.log(response.rateLimit); if (!user) { throw new Error("User not found"); @@ -81,8 +347,12 @@ export async function fetchGitHubUserData( return { name: user.name, avatarUrl: user.avatarUrl, - repos: user.repositories.nodes, - pullRequests: user.pullRequests.nodes, + repos: user.repositories.nodes.filter(isDefined), + pullRequests: response.pullRequests.nodes.filter(isDefined), contributions: user.contributionsCollection, + issues: response.issues.nodes.filter(isDefined).map(toIssueNode), + discussions: response.discussions.nodes + .filter(isDefined) + .map(toDiscussionNode), }; } diff --git a/lib/i18n.ts b/lib/i18n.ts index b6b4ee8..421e82b 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -40,8 +40,17 @@ function persistLocale(locale: Locale) { } export function useI18nProvider(initialLocale: Locale = DEFAULT_LOCALE) { - const [locale, setLocaleState] = useState(initialLocale); - const [messages, setMessages] = useState(() => messagesByLocale[initialLocale]); + const getInitialLocale = () => { + if (typeof window !== "undefined") { + const stored = window.localStorage.getItem(LOCALE_COOKIE); + if (isSupportedLocale(stored)) return stored; + } + return initialLocale; + }; + + const initialLoc = getInitialLocale(); + const [locale, setLocaleState] = useState(initialLoc); + const [messages, setMessages] = useState(() => messagesByLocale[initialLoc]); const [ready, setReady] = useState(true); const changeLocale = useCallback((next: Locale) => { @@ -61,15 +70,6 @@ export function useI18nProvider(initialLocale: Locale = DEFAULT_LOCALE) { }); }, []); - useEffect(() => { - if (typeof window === "undefined") return; - - const stored = window.localStorage.getItem(LOCALE_COOKIE); - if (isSupportedLocale(stored) && stored !== locale) { - changeLocale(stored); - } - }, [changeLocale, locale]); - useEffect(() => { if (typeof document === "undefined") return; const meta = localeMeta[locale]; diff --git a/lib/score.ts b/lib/score.ts index 237005d..66fc209 100644 --- a/lib/score.ts +++ b/lib/score.ts @@ -1,125 +1,868 @@ import type { ContributionTotals, + DiscussionNode, + IssueNode, PullRequestNode, + ReactionSummary, RepoNode, } from "@/types/github"; -import { PullRequestScoreDetail, RepoScoreDetail } from "@/types/score"; +import type { + CommunityContributionDetail, + PullRequestScoreDetail, + RepoScoreDetail, + ScoringExplanations, + ScoringSignals, +} from "@/types/score"; +import { + getLanguageDistribution, + getLanguageFactor, + getLanguageMatch, + getTopLanguages, + normalizeSelectedLanguages, +} from "@/lib/scoring/languageScoring"; + +const MS_PER_DAY = 86_400_000; +const FALLBACK_REFERENCE_DATE = "2026-01-01T00:00:00.000Z"; + +export function safeLog(value: number): number { + return Math.log(Math.max(0, value) + 1); +} + +export function roundScore(value: number): number { + return Number.isFinite(value) ? Math.round(value) : 0; +} + +export function normalizeScore(score: number, k: number): number { + const sanitizedScore = sanitizeNumber(score); + const sanitizedK = Math.max(0, sanitizeNumber(k)); + const denominator = sanitizedScore + sanitizedK; + + if (denominator <= 0) { + return 0; + } + + return (100 * sanitizedScore) / denominator; +} + +export function getDiminishingWeight(index: number): number { + const safeIndex = Math.max(0, index); + return 1 / (safeIndex + 1); +} -const LOG = Math.log; +export function getRepoRankWeight(index: number): number { + return index < 5 ? 1 : 0.1; +} + +function sanitizeNumber(value: number): number { + return Number.isFinite(value) ? value : 0; +} + +function parseDate(value?: string): Date | null { + if (!value) { + return null; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return parsed; +} + +function resolveReferenceDate(data: { + repos: RepoNode[]; + pullRequests: PullRequestNode[]; + referenceDate?: string; +}): Date { + const timestamps: number[] = []; + + const explicitReference = parseDate(data.referenceDate); + if (explicitReference) { + timestamps.push(explicitReference.getTime()); + } + for (const repo of data.repos) { + const parsed = parseDate(repo.pushedAt); + if (parsed) { + timestamps.push(parsed.getTime()); + } + } + + for (const pr of data.pullRequests) { + const parsed = parseDate(pr.repository.pushedAt); + if (parsed) { + timestamps.push(parsed.getTime()); + } + } + + if (timestamps.length === 0) { + return new Date(FALLBACK_REFERENCE_DATE); + } + + return new Date(Math.max(...timestamps)); +} + +function getDaysSince(dateValue: string, referenceDate: Date): number | null { + const date = parseDate(dateValue); + if (!date) { + return null; + } + + const diff = referenceDate.getTime() - date.getTime(); + return Math.max(0, diff / MS_PER_DAY); +} + +function getRepoActivityFactor( + pushedAt: string | undefined, + referenceDate: Date, +): number { + if (!pushedAt) { + return 0.8; + } + + const daysSincePush = getDaysSince(pushedAt, referenceDate); + if (daysSincePush === null) { + return 0.8; + } + + if (daysSincePush <= 90) { + return 1.2; + } + if (daysSincePush <= 365) { + return 1.0; + } + if (daysSincePush <= 730) { + return 0.7; + } + return 0.4; +} + +function getPullRequestRepoActivityFactor( + pushedAt: string | undefined, + referenceDate: Date, +): number { + if (!pushedAt) { + return 0.9; + } + + const daysSincePush = getDaysSince(pushedAt, referenceDate); + if (daysSincePush === null) { + return 0.9; + } + if (daysSincePush <= 90) { + return 1.1; + } + if (daysSincePush <= 365) { + return 1.0; + } + if (daysSincePush <= 730) { + return 0.85; + } + return 0.7; +} function calculateRepoScore( - repos: RepoNode[] + repos: RepoNode[], + referenceDate: Date, ): { total: number; details: RepoScoreDetail[] } { const details = repos.map((repo) => { + const baseRepoScore = + safeLog(repo.stargazerCount) * 5 + + safeLog(repo.forkCount) * 3 + + safeLog(repo.watchers.totalCount) * 2; + + let score = baseRepoScore; + + if (repo.isFork === true) { + score *= 0.2; + } + + score *= getRepoActivityFactor(repo.pushedAt, referenceDate); - const score = LOG(repo.stargazerCount + 1) * 5 + - LOG(repo.forkCount + 1) * 3 + - LOG(repo.watchers.totalCount + 1) * 2 - return { repo, score: Math.round(score) }; + return { repo, score: sanitizeNumber(score) }; }); details.sort((a, b) => b.score - a.score); const total = details.reduce((sum, { score }, index) => { - const weight = index < 5 ? 1 : 0.1; - return sum + score * weight; + return sum + score * getRepoRankWeight(index); }, 0); - return { total, details }; + return { total: sanitizeNumber(total), details }; } +type PRScoreResult = { + total: number; + details: PullRequestScoreDetail[]; + mergedExternalPRs: number; + ownRepoPRsIgnored: number; + unmergedPRsIgnored: number; + uniqueExternalPRRepos: number; +}; + function calculatePRScore( prs: PullRequestNode[], - username: string -): { total: number; details: PullRequestScoreDetail[] } { - const grouped: Record = {}; + username: string, + referenceDate: Date, +): PRScoreResult { + const grouped = new Map(); + const normalizedUsername = username.toLowerCase(); + + let mergedExternalPRs = 0; + let ownRepoPRsIgnored = 0; + let unmergedPRsIgnored = 0; for (const pr of prs) { const repoOwner = pr.repository.owner.login.toLowerCase(); - if (repoOwner === username.toLowerCase()) continue; // ignore own repos - if (!pr.merged) continue; // only merged PRs - const base = LOG(pr.repository.stargazerCount + 1) * 2; - const sizeFactor = LOG(pr.additions + pr.deletions + 1); - const score = base * sizeFactor; + if (!pr.merged) { + unmergedPRsIgnored += 1; + continue; + } + + if (repoOwner === normalizedUsername) { + ownRepoPRsIgnored += 1; + continue; + } + + const changedLines = Math.max(0, pr.additions) + Math.max(0, pr.deletions); + const base = safeLog(pr.repository.stargazerCount) * 2; + const sizeFactor = Math.min(safeLog(changedLines), 5); + + let score = base * sizeFactor; + + if (changedLines < 5) { + score *= 0.25; + } + + if (changedLines > 5000) { + score *= 0.6; + } + + score *= getPullRequestRepoActivityFactor(pr.repository.pushedAt, referenceDate); + score = sanitizeNumber(score); const repoKey = pr.repository.nameWithOwner; - grouped[repoKey] ||= []; - grouped[repoKey].push({ pr, score: Math.round(score) }); + const existingScores = grouped.get(repoKey) ?? []; + existingScores.push({ pr, score }); + grouped.set(repoKey, existingScores); + mergedExternalPRs += 1; } let total = 0; const allDetails: PullRequestScoreDetail[] = []; - for (const repoScores of Object.values(grouped)) { + for (const repoScores of grouped.values()) { repoScores.sort((a, b) => b.score - a.score); - const repoTotal = repoScores.reduce( - (sum, item, i) => sum + item.score * (1 / (i + 1)), - 0 - ); + + const repoTotal = repoScores.reduce((sum, item, index) => { + return sum + item.score * getDiminishingWeight(index); + }, 0); + total += repoTotal; allDetails.push(...repoScores); } allDetails.sort((a, b) => b.score - a.score); - return { total, details: allDetails }; + return { + total: sanitizeNumber(total), + details: allDetails, + mergedExternalPRs, + ownRepoPRsIgnored, + unmergedPRsIgnored, + uniqueExternalPRRepos: grouped.size, + }; +} + +function readReactionTotals( + reactions?: ReactionSummary, +): { positive: number; neutral: number; negative: number } { + if (!reactions) { + return { positive: 0, neutral: 0, negative: 0 }; + } + + const positive = + reactions.thumbsUp * 1 + + reactions.heart * 1.2 + + reactions.hooray * 1.2 + + reactions.rocket * 1.3; + const neutral = reactions.eyes * 0.4 + reactions.laugh * 0.2; + const negative = reactions.thumbsDown * 1.5 + reactions.confused * 1; + + return { + positive: sanitizeNumber(positive), + neutral: sanitizeNumber(neutral), + negative: sanitizeNumber(negative), + }; +} + +function calculateCommunityItemScore( + item: IssueNode | DiscussionNode, +): { score: number; reactionQuality: number; negativeRatio: number } { + const repoStars = Math.max(0, item.repository.stargazerCount); + const comments = Math.max(0, item.comments.totalCount); + const { positive, neutral, negative } = readReactionTotals(item.reactions); + + const reactionQuality = Math.max(0, positive + neutral - negative); + const reactionTotal = positive + neutral + negative; + const negativeRatio = reactionTotal > 0 ? negative / reactionTotal : 0; + + let score = + safeLog(repoStars) * + safeLog(Math.max(0, comments + reactionQuality)); + + if (comments === 0 && reactionQuality === 0) { + score *= 0.2; + } + + if (negativeRatio > 0.5) { + score *= 0.2; + } else if (negativeRatio > 0.3) { + score *= 0.6; + } + + return { + score: sanitizeNumber(score), + reactionQuality: sanitizeNumber(reactionQuality), + negativeRatio: sanitizeNumber(negativeRatio), + }; +} + +type CommunityScoreResult = { + total: number; + details: CommunityContributionDetail[]; + issuesAnalyzed: number; + externalIssuesCounted: number; + discussionsAnalyzed: number; + externalDiscussionsCounted: number; +}; + +function calculateContributionScore( + _contrib: ContributionTotals | undefined, + issues: IssueNode[], + discussions: DiscussionNode[], + username: string, +): CommunityScoreResult { + const normalizedUsername = username.toLowerCase(); + const details: CommunityContributionDetail[] = []; + + let externalIssuesCounted = 0; + let externalDiscussionsCounted = 0; + + for (const issue of issues) { + if (issue.repository.owner.login.toLowerCase() === normalizedUsername) { + continue; + } + + const { score } = calculateCommunityItemScore(issue); + details.push({ + type: "issue", + item: issue, + score, + }); + externalIssuesCounted += 1; + } + + for (const discussion of discussions) { + if ( + discussion.repository.owner.login.toLowerCase() === normalizedUsername + ) { + continue; + } + + const { score } = calculateCommunityItemScore(discussion); + details.push({ + type: "discussion", + item: discussion, + score, + }); + externalDiscussionsCounted += 1; + } + + details.sort((a, b) => b.score - a.score); + + const total = details.reduce((sum, detail, index) => { + return sum + detail.score * getDiminishingWeight(index); + }, 0); + + return { + total: sanitizeNumber(total), + details, + issuesAnalyzed: issues.length, + externalIssuesCounted, + discussionsAnalyzed: discussions.length, + externalDiscussionsCounted, + }; } -function calculateContributionScore(contrib: ContributionTotals): number { - return ( - contrib.totalCommitContributions * 0.5 + - contrib.totalPullRequestContributions * 2 + - contrib.totalIssueContributions * 0.3 +function hasLanguageData(languages: RepoNode["languages"] | undefined): boolean { + return Object.keys(getLanguageDistribution(languages)).length > 0; +} + +function calculateLanguageRepoScore( + repoDetails: RepoScoreDetail[], + selectedLanguages: string[], +): { + total: number; + details: Array<{ + repo: RepoNode; + score: number; + languageMatch: number; + }>; + reposWithLanguageData: number; + averageLanguageMatch: number; +} { + const details = repoDetails.map((item) => { + const languageMatch = getLanguageMatch(item.repo.languages, selectedLanguages); + const languageFactor = getLanguageFactor(languageMatch); + return { + repo: item.repo, + score: sanitizeNumber(item.score * languageFactor), + languageMatch, + }; + }); + + details.sort((a, b) => b.score - a.score); + + const total = details.reduce((sum, detail, index) => { + return sum + detail.score * getRepoRankWeight(index); + }, 0); + + const reposWithLanguageData = details.reduce((count, detail) => { + return count + (hasLanguageData(detail.repo.languages) ? 1 : 0); + }, 0); + + const averageLanguageMatch = + details.length > 0 + ? details.reduce((sum, detail) => sum + detail.languageMatch, 0) / details.length + : 0; + + return { + total: sanitizeNumber(total), + details, + reposWithLanguageData, + averageLanguageMatch: sanitizeNumber(averageLanguageMatch), + }; +} + +function calculateLanguagePRScore( + prDetails: PullRequestScoreDetail[], + selectedLanguages: string[], +): { + total: number; + details: Array<{ + pr: PullRequestNode; + score: number; + languageMatch: number; + }>; + prsWithLanguageData: number; + averageLanguageMatch: number; +} { + const grouped = new Map< + string, + Array<{ pr: PullRequestNode; score: number; languageMatch: number }> + >(); + + for (const item of prDetails) { + const languageMatch = getLanguageMatch( + item.pr.repository.languages, + selectedLanguages, + ); + const languageFactor = getLanguageFactor(languageMatch); + const score = sanitizeNumber(item.score * languageFactor); + const key = item.pr.repository.nameWithOwner; + const current = grouped.get(key) ?? []; + current.push({ pr: item.pr, score, languageMatch }); + grouped.set(key, current); + } + + let total = 0; + const details: Array<{ pr: PullRequestNode; score: number; languageMatch: number }> = []; + + for (const repoScores of grouped.values()) { + repoScores.sort((a, b) => b.score - a.score); + const repoTotal = repoScores.reduce((sum, item, index) => { + return sum + item.score * getDiminishingWeight(index); + }, 0); + total += repoTotal; + details.push(...repoScores); + } + + details.sort((a, b) => b.score - a.score); + + const prsWithLanguageData = details.reduce((count, detail) => { + return count + (hasLanguageData(detail.pr.repository.languages) ? 1 : 0); + }, 0); + + const averageLanguageMatch = + details.length > 0 + ? details.reduce((sum, detail) => sum + detail.languageMatch, 0) / details.length + : 0; + + return { + total: sanitizeNumber(total), + details, + prsWithLanguageData, + averageLanguageMatch: sanitizeNumber(averageLanguageMatch), + }; +} + +function calculateLanguageContributionScore( + communityDetails: CommunityContributionDetail[], + selectedLanguages: string[], +): number { + const allHaveNoLanguageData = communityDetails.every( + (detail) => !hasLanguageData(detail.item.repository.languages), ); + + if (allHaveNoLanguageData) { + return communityDetails.reduce((sum, detail, index) => { + return sum + detail.score * getDiminishingWeight(index); + }, 0); + } + + const adjusted = communityDetails + .map((detail) => { + const languageMatch = getLanguageMatch( + detail.item.repository.languages, + selectedLanguages, + ); + return sanitizeNumber(detail.score * getLanguageFactor(languageMatch)); + }) + .sort((a, b) => b - a); + + return adjusted.reduce((sum, score, index) => { + return sum + score * getDiminishingWeight(index); + }, 0); } +type TopRepo = { + name: string; + url?: string; + stars: number; + forks: number; + watchers: number; + score: number; +}; + +type TopPullRequest = { + repo: string; + title: string; + url?: string; + stars: number; + score: number; + additions: number; + deletions: number; +}; + +type TopCommunityContribution = { + type: "issue" | "discussion"; + title: string; + url?: string; + repo: string; + stars: number; + comments: number; + score: number; +}; + +type TopLanguageRepo = TopRepo & { + languageMatch: number; + topLanguages: { + name: string; + percentage: number; + }[]; +}; + +type TopLanguagePullRequest = TopPullRequest & { + languageMatch: number; + topLanguages: { + name: string; + percentage: number; + }[]; +}; + +type LanguageScores = { + selectedLanguages: string[]; + repoScore: number; + prScore: number; + contributionScore: number; + finalScore: number; + normalizedRepoScore: number; + normalizedPRScore: number; + normalizedContributionScore: number; + normalizedFinalScore: number; + topRepos: TopLanguageRepo[]; + topPullRequests: TopLanguagePullRequest[]; +}; + +export type CalculateUserScoreResult = { + username: string; + repoScore: number; + prScore: number; + contributionScore: number; + finalScore: number; + normalizedRepoScore: number; + normalizedPRScore: number; + normalizedContributionScore: number; + normalizedFinalScore: number; + scores: { + repoScore: number; + prScore: number; + contributionScore: number; + finalScore: number; + normalizedRepoScore: number; + normalizedPRScore: number; + normalizedContributionScore: number; + normalizedFinalScore: number; + }; + topRepos: TopRepo[]; + topPullRequests: TopPullRequest[]; + topCommunityContributions: TopCommunityContribution[]; + languageScores?: LanguageScores; + signals: ScoringSignals; + explanations: ScoringExplanations; +}; + +const scoringExplanations: ScoringExplanations = { + repo: [ + "Repository score is based on stars, forks, watchers, and activity.", + "Forked repositories are heavily reduced.", + "Top repositories contribute most to the repository score.", + ], + pr: [ + "Only merged pull requests are counted.", + "Pull requests to the user's own repositories are ignored.", + "Repeated pull requests to the same repository use diminishing returns.", + "Tiny PRs and huge generated PRs are reduced.", + ], + contribution: [ + "Contribution score is based on external issues and discussions only.", + "Commits and pull requests are excluded to avoid double-counting.", + "Negative reactions reduce issue and discussion impact.", + "Contribution score is capped so it cannot dominate the final score.", + ], + overall: [ + "Final score is weighted 45% repository impact, 45% pull request impact, and 10% community contribution impact.", + ], +}; + export function calculateUserScore( data: { repos: RepoNode[]; pullRequests: PullRequestNode[]; - contributions: ContributionTotals; + contributions?: ContributionTotals; + issues?: IssueNode[]; + discussions?: DiscussionNode[]; + referenceDate?: string; + selectedLanguages?: string[]; }, - username: string -): { - repoScore: number; - prScore: number; - contributionScore: number; - finalScore: number; - topRepos: { name: string; stars: number; forks: number; score: number }[]; - topPullRequests: { repo: string; stars: number; score: number }[]; -} { - const repoScore = calculateRepoScore(data.repos); - const prScore = calculatePRScore(data.pullRequests, username); - let contributionScore = calculateContributionScore(data.contributions); - contributionScore = Math.min(contributionScore, 0.3 * (repoScore.total + prScore.total)) + username: string, +): CalculateUserScoreResult { + const selectedLanguages = normalizeSelectedLanguages(data.selectedLanguages); + const hasSelectedLanguages = selectedLanguages.length > 0; + const referenceDate = resolveReferenceDate({ + repos: data.repos, + pullRequests: data.pullRequests, + referenceDate: data.referenceDate, + }); + + const repoScore = calculateRepoScore(data.repos, referenceDate); + const prScore = calculatePRScore(data.pullRequests, username, referenceDate); + const communityScore = calculateContributionScore( + data.contributions, + data.issues ?? [], + data.discussions ?? [], + username, + ); + + let contributionScore = communityScore.total; + contributionScore = Math.min( + contributionScore, + 0.3 * (repoScore.total + prScore.total), + ); + contributionScore = sanitizeNumber(contributionScore); const finalScore = - repoScore.total * 0.4 + prScore.total * 0.4 + contributionScore * 0.2; + repoScore.total * 0.45 + prScore.total * 0.45 + contributionScore * 0.1; + + const normalizedRepoScore = normalizeScore(repoScore.total, 100); + const normalizedPRScore = normalizeScore(prScore.total, 300); + const normalizedContributionScore = normalizeScore(contributionScore, 100); + const normalizedFinalScore = + normalizedRepoScore * 0.45 + + normalizedPRScore * 0.45 + + normalizedContributionScore * 0.1; + + let languageScores: LanguageScores | undefined; + let languageRepoSignals: Pick< + ScoringSignals, + "reposWithLanguageData" | "averageRepoLanguageMatch" + > = {}; + let languagePRSignals: Pick< + ScoringSignals, + "prsWithLanguageData" | "averagePRLanguageMatch" + > = {}; + + if (hasSelectedLanguages) { + const languageRepoScore = calculateLanguageRepoScore( + repoScore.details, + selectedLanguages, + ); + const languagePRScore = calculateLanguagePRScore( + prScore.details, + selectedLanguages, + ); + + let languageContributionScore = sanitizeNumber( + calculateLanguageContributionScore(communityScore.details, selectedLanguages), + ); + languageContributionScore = Math.min( + languageContributionScore, + 0.3 * (languageRepoScore.total + languagePRScore.total), + ); + languageContributionScore = sanitizeNumber(languageContributionScore); + + const languageFinalScore = + languageRepoScore.total * 0.45 + + languagePRScore.total * 0.45 + + languageContributionScore * 0.1; + + const normalizedLanguageRepoScore = normalizeScore(languageRepoScore.total, 100); + const normalizedLanguagePRScore = normalizeScore(languagePRScore.total, 300); + const normalizedLanguageContributionScore = normalizeScore( + languageContributionScore, + 100, + ); + const normalizedLanguageFinalScore = + normalizedLanguageRepoScore * 0.45 + + normalizedLanguagePRScore * 0.45 + + normalizedLanguageContributionScore * 0.1; + + languageScores = { + selectedLanguages, + repoScore: sanitizeNumber(languageRepoScore.total), + prScore: sanitizeNumber(languagePRScore.total), + contributionScore: languageContributionScore, + finalScore: sanitizeNumber(languageFinalScore), + normalizedRepoScore: sanitizeNumber(normalizedLanguageRepoScore), + normalizedPRScore: sanitizeNumber(normalizedLanguagePRScore), + normalizedContributionScore: sanitizeNumber(normalizedLanguageContributionScore), + normalizedFinalScore: sanitizeNumber(normalizedLanguageFinalScore), + topRepos: languageRepoScore.details.slice(0, 3).map((item) => ({ + name: item.repo.name, + url: item.repo.url, + stars: item.repo.stargazerCount, + forks: item.repo.forkCount, + watchers: item.repo.watchers.totalCount, + score: roundScore(item.score), + languageMatch: sanitizeNumber(item.languageMatch), + topLanguages: getTopLanguages(item.repo.languages, 3), + })), + topPullRequests: languagePRScore.details.slice(0, 3).map((item) => ({ + repo: item.pr.repository.nameWithOwner, + title: item.pr.title, + url: item.pr.url, + stars: item.pr.repository.stargazerCount, + score: roundScore(item.score), + additions: item.pr.additions, + deletions: item.pr.deletions, + languageMatch: sanitizeNumber(item.languageMatch), + topLanguages: getTopLanguages(item.pr.repository.languages, 3), + })), + }; + + languageRepoSignals = { + reposWithLanguageData: languageRepoScore.reposWithLanguageData, + averageRepoLanguageMatch: languageRepoScore.averageLanguageMatch, + }; + + languagePRSignals = { + prsWithLanguageData: languagePRScore.prsWithLanguageData, + averagePRLanguageMatch: languagePRScore.averageLanguageMatch, + }; + } + + const explanations: ScoringExplanations = { + ...scoringExplanations, + }; + + if (hasSelectedLanguages) { + explanations.language = [ + "Language-focused score is optional and does not replace the overall score.", + "Repository language match is calculated from GitHub repository language byte distribution.", + "Pull request language match uses the target repository language distribution as an approximation.", + "Non-matching repositories are softly reduced instead of fully ignored.", + "Repositories with missing language data use a neutral language factor.", + "Community contribution language matching uses repository language data when available.", + ]; + } return { - repoScore: repoScore.total, - prScore: prScore.total, + username, + repoScore: sanitizeNumber(repoScore.total), + prScore: sanitizeNumber(prScore.total), contributionScore, - finalScore, + finalScore: sanitizeNumber(finalScore), + normalizedRepoScore: sanitizeNumber(normalizedRepoScore), + normalizedPRScore: sanitizeNumber(normalizedPRScore), + normalizedContributionScore: sanitizeNumber(normalizedContributionScore), + normalizedFinalScore: sanitizeNumber(normalizedFinalScore), + scores: { + repoScore: sanitizeNumber(repoScore.total), + prScore: sanitizeNumber(prScore.total), + contributionScore, + finalScore: sanitizeNumber(finalScore), + normalizedRepoScore: sanitizeNumber(normalizedRepoScore), + normalizedPRScore: sanitizeNumber(normalizedPRScore), + normalizedContributionScore: sanitizeNumber(normalizedContributionScore), + normalizedFinalScore: sanitizeNumber(normalizedFinalScore), + }, topRepos: repoScore.details.slice(0, 3).map((item) => ({ name: item.repo.name, + url: item.repo.url, stars: item.repo.stargazerCount, forks: item.repo.forkCount, - score: item.score, watchers: item.repo.watchers.totalCount, + score: roundScore(item.score), })), topPullRequests: prScore.details.slice(0, 3).map((item) => ({ repo: item.pr.repository.nameWithOwner, title: item.pr.title, url: item.pr.url, stars: item.pr.repository.stargazerCount, - score: item.score, + score: roundScore(item.score), additions: item.pr.additions, deletions: item.pr.deletions, })), + topCommunityContributions: communityScore.details.slice(0, 3).map((item) => ({ + type: item.type, + title: item.item.title, + url: item.item.url, + repo: item.item.repository.nameWithOwner, + stars: item.item.repository.stargazerCount, + comments: item.item.comments.totalCount, + score: roundScore(item.score), + })), + languageScores, + signals: { + reposAnalyzed: data.repos.length, + pullRequestsAnalyzed: data.pullRequests.length, + mergedExternalPRs: prScore.mergedExternalPRs, + ownRepoPRsIgnored: prScore.ownRepoPRsIgnored, + unmergedPRsIgnored: prScore.unmergedPRsIgnored, + uniqueExternalPRRepos: prScore.uniqueExternalPRRepos, + issuesAnalyzed: communityScore.issuesAnalyzed, + externalIssuesCounted: communityScore.externalIssuesCounted, + discussionsAnalyzed: communityScore.discussionsAnalyzed, + externalDiscussionsCounted: communityScore.externalDiscussionsCounted, + ...(hasSelectedLanguages ? { selectedLanguages } : {}), + ...languageRepoSignals, + ...languagePRSignals, + }, + explanations, }; } diff --git a/lib/scoring/languageScoring.ts b/lib/scoring/languageScoring.ts new file mode 100644 index 0000000..a77be89 --- /dev/null +++ b/lib/scoring/languageScoring.ts @@ -0,0 +1,118 @@ +import type { RepoLanguages } from "@/types/github"; + +const MAX_SELECTED_LANGUAGES = 5; + +export function normalizeLanguageName(language: string): string { + return language.trim().toLowerCase(); +} + +export function normalizeSelectedLanguages(languages?: string[]): string[] { + if (!languages || languages.length === 0) { + return []; + } + + const unique: string[] = []; + const seen = new Set(); + + for (const language of languages) { + const normalized = normalizeLanguageName(language); + if (!normalized || seen.has(normalized)) { + continue; + } + unique.push(normalized); + seen.add(normalized); + + if (unique.length >= MAX_SELECTED_LANGUAGES) { + break; + } + } + + return unique; +} + +export function getLanguageDistribution( + languages?: RepoLanguages, +): Record { + const edges = languages?.edges ?? []; + if (edges.length === 0) { + return {}; + } + + let totalSize = 0; + for (const edge of edges) { + totalSize += Math.max(0, edge.size); + } + + if (totalSize <= 0) { + return {}; + } + + const distribution: Record = {}; + for (const edge of edges) { + const normalizedName = normalizeLanguageName(edge.node.name); + if (!normalizedName) { + continue; + } + const ratio = Math.max(0, edge.size) / totalSize; + distribution[normalizedName] = (distribution[normalizedName] ?? 0) + ratio; + } + + return distribution; +} + +export function getLanguageMatch( + languages: RepoLanguages | undefined, + selectedLanguages: string[], +): number { + if (selectedLanguages.length === 0) { + return 1; + } + + const distribution = getLanguageDistribution(languages); + const distributionKeys = Object.keys(distribution); + if (distributionKeys.length === 0) { + return 0.5; + } + + let match = 0; + for (const selectedLanguage of selectedLanguages) { + match += distribution[selectedLanguage] ?? 0; + } + + return Math.max(0, Math.min(1, match)); +} + +export function getLanguageFactor( + languageMatch: number, + minFactor = 0.25, +): number { + const boundedMatch = Math.max(0, Math.min(1, languageMatch)); + const boundedMinFactor = Math.max(0, Math.min(1, minFactor)); + return boundedMinFactor + (1 - boundedMinFactor) * boundedMatch; +} + +export function getTopLanguages( + languages?: RepoLanguages, + limit = 3, +): { name: string; percentage: number }[] { + const edges = [...(languages?.edges ?? [])] + .map((edge) => ({ + name: edge.node.name, + size: Math.max(0, edge.size), + })) + .sort((a, b) => b.size - a.size); + + if (edges.length === 0 || limit <= 0) { + return []; + } + + const totalSize = edges.reduce((sum, edge) => sum + edge.size, 0); + if (totalSize <= 0) { + return []; + } + + return edges.slice(0, limit).map((edge) => ({ + name: edge.name, + percentage: Math.round((edge.size / totalSize) * 100), + })); +} diff --git a/package.json b/package.json index 4bf67a3..c39830a 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint ." + "lint": "eslint .", + "test": "vitest", + "test:watch": "vitest --watch" }, "dependencies": { "@octokit/graphql": "^9.0.3", @@ -31,6 +33,7 @@ "eslint-config-next": "^16.2.2", "postcss": "^8.5.10", "tailwindcss": "^3.4.14", - "typescript": "^6.0.2" + "typescript": "^6.0.2", + "vitest": "^4.1.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 596c811..2750770 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: typescript: specifier: ^6.0.2 version: 6.0.2 + vitest: + specifier: ^4.1.5 + version: 4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(jiti@1.21.7)) packages: @@ -150,9 +153,15 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.9.2': resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@emnapi/runtime@1.9.2': resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} @@ -258,89 +267,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -384,6 +409,12 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@next/env@16.2.3': resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} @@ -407,24 +438,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.2.3': resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.2.3': resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.2.3': resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.2.3': resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} @@ -476,6 +511,9 @@ packages: '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@oxc-project/types@0.128.0': + resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -1166,15 +1204,119 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/binding-android-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + resolution: {integrity: sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + resolution: {integrity: sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + resolution: {integrity: sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + resolution: {integrity: sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + resolution: {integrity: sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + resolution: {integrity: sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + resolution: {integrity: sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + resolution: {integrity: sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-rc.18': + resolution: {integrity: sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1202,6 +1344,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1320,41 +1465,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1376,6 +1529,35 @@ packages: cpu: [x64] os: [win32] + '@vitest/expect@4.1.5': + resolution: {integrity: sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==} + + '@vitest/mocker@4.1.5': + resolution: {integrity: sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.5': + resolution: {integrity: sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==} + + '@vitest/runner@4.1.5': + resolution: {integrity: sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==} + + '@vitest/snapshot@4.1.5': + resolution: {integrity: sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==} + + '@vitest/spy@4.1.5': + resolution: {integrity: sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==} + + '@vitest/utils@4.1.5': + resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1446,6 +1628,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -1527,6 +1713,10 @@ packages: caniuse-lite@1.0.30001787: resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1710,6 +1900,9 @@ packages: resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1850,6 +2043,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1857,6 +2053,10 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-content-type-parse@3.0.0: resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==} @@ -2214,6 +2414,80 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2243,6 +2517,9 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2358,6 +2635,9 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2389,6 +2669,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2463,6 +2746,10 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2598,6 +2885,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.0.0-rc.18: + resolution: {integrity: sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2665,6 +2957,9 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2672,6 +2967,12 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2751,10 +3052,21 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2859,6 +3171,90 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + vite@8.0.11: + resolution: {integrity: sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.5: + resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.5 + '@vitest/browser-preview': 4.1.5 + '@vitest/browser-webdriverio': 4.1.5 + '@vitest/coverage-istanbul': 4.1.5 + '@vitest/coverage-v8': 4.1.5 + '@vitest/ui': 4.1.5 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -2880,6 +3276,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3006,12 +3407,23 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + '@emnapi/core@1.9.2': dependencies: '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 @@ -3219,6 +3631,13 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.1 + optional: true + '@next/env@16.2.3': {} '@next/eslint-plugin-next@16.2.3': @@ -3293,6 +3712,8 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 + '@oxc-project/types@0.128.0': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -4040,8 +4461,61 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@rolldown/binding-android-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-rc.18': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': + optional: true + + '@rolldown/pluginutils@1.0.0-rc.18': {} + '@rtsao/scc@1.1.0': {} + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -4051,6 +4525,11 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -4075,6 +4554,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} @@ -4243,6 +4724,47 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@vitest/expect@4.1.5': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.5(vite@8.0.11(@types/node@25.6.0)(jiti@1.21.7))': + dependencies: + '@vitest/spy': 4.1.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.11(@types/node@25.6.0)(jiti@1.21.7) + + '@vitest/pretty-format@4.1.5': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.5': + dependencies: + '@vitest/utils': 4.1.5 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + '@vitest/utils': 4.1.5 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.5': {} + + '@vitest/utils@4.1.5': + dependencies: + '@vitest/pretty-format': 4.1.5 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -4344,6 +4866,8 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} async-function@1.0.0: {} @@ -4417,6 +4941,8 @@ snapshots: caniuse-lite@1.0.30001787: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -4546,8 +5072,7 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - detect-libc@2.1.2: - optional: true + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -4654,6 +5179,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4882,10 +5409,16 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} eventemitter3@4.0.7: {} + expect-type@1.3.0: {} + fast-content-type-parse@3.0.0: {} fast-deep-equal@3.1.3: {} @@ -5237,6 +5770,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -5261,6 +5843,10 @@ snapshots: dependencies: react: 19.2.5 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} @@ -5378,6 +5964,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + obug@2.1.1: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5411,6 +5999,8 @@ snapshots: path-parse@1.0.7: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -5466,6 +6056,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.14: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} prop-types@15.8.1: @@ -5667,6 +6263,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.0.0-rc.18: + dependencies: + '@oxc-project/types': 0.128.0 + '@rolldown/pluginutils': 1.0.0-rc.18 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.18 + '@rolldown/binding-darwin-x64': 1.0.0-rc.18 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.18 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.18 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.18 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.18 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.18 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.18 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.18 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.18 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5784,10 +6401,16 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} stable-hash@0.0.5: {} + stackback@0.0.2: {} + + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -5910,11 +6533,17 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.1.2: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -6067,6 +6696,45 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + vite@8.0.11(@types/node@25.6.0)(jiti@1.21.7): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0-rc.18 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.0 + fsevents: 2.3.3 + jiti: 1.21.7 + + vitest@4.1.5(@types/node@25.6.0)(vite@8.0.11(@types/node@25.6.0)(jiti@1.21.7)): + dependencies: + '@vitest/expect': 4.1.5 + '@vitest/mocker': 4.1.5(vite@8.0.11(@types/node@25.6.0)(jiti@1.21.7)) + '@vitest/pretty-format': 4.1.5 + '@vitest/runner': 4.1.5 + '@vitest/snapshot': 4.1.5 + '@vitest/spy': 4.1.5 + '@vitest/utils': 4.1.5 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.11(@types/node@25.6.0)(jiti@1.21.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - msw + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -6112,6 +6780,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} yallist@3.1.1: {} diff --git a/public/screenshots/screenshot.png b/public/screenshots/screenshot.png index 3e79865..4f0939a 100644 Binary files a/public/screenshots/screenshot.png and b/public/screenshots/screenshot.png differ diff --git a/test/fixtures/github.ts b/test/fixtures/github.ts new file mode 100644 index 0000000..9be530d --- /dev/null +++ b/test/fixtures/github.ts @@ -0,0 +1,175 @@ +import type { + ContributionTotals, + DiscussionNode, + IssueNode, + PullRequestNode, + ReactionSummary, + RepoLanguages, + RepoNode, +} from "@/types/github"; + +export type UserScoreInput = { + repos: RepoNode[]; + pullRequests: PullRequestNode[]; + contributions: ContributionTotals; + issues?: IssueNode[]; + discussions?: DiscussionNode[]; + referenceDate?: string; + selectedLanguages?: string[]; +}; + +const defaultRepo: RepoNode = { + name: "repo", + nameWithOwner: "owner/repo", + url: "https://github.com/owner/repo", + isFork: false, + stargazerCount: 10, + forkCount: 2, + watchers: { totalCount: 1 }, + pushedAt: "2026-05-01T00:00:00.000Z", + languages: { + edges: [{ size: 1000, node: { name: "TypeScript" } }], + }, +}; + +const defaultPullRequest: PullRequestNode = { + merged: true, + additions: 100, + deletions: 20, + title: "Improve the developer comparison score", + url: "https://example.com/external-owner/repo/pull/1", + repository: { + nameWithOwner: "external-owner/repo", + url: "https://github.com/external-owner/repo", + stargazerCount: 10, + pushedAt: "2026-05-01T00:00:00.000Z", + owner: { login: "external-owner" }, + languages: { + edges: [{ size: 1000, node: { name: "TypeScript" } }], + }, + }, +}; + +const defaultContributions: ContributionTotals = { + totalCommitContributions: 0, + totalPullRequestContributions: 0, + totalIssueContributions: 0, +}; + +const defaultReactions: ReactionSummary = { + thumbsUp: 0, + thumbsDown: 0, + heart: 0, + hooray: 0, + rocket: 0, + eyes: 0, + confused: 0, + laugh: 0, +}; + +const defaultIssue: IssueNode = { + title: "Issue about improving docs", + url: "https://example.com/external-owner/repo/issues/1", + comments: { totalCount: 2 }, + reactions: defaultReactions, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 10, + owner: { login: "external-owner" }, + }, +}; + +const defaultDiscussion: DiscussionNode = { + title: "Discussion about roadmap", + url: "https://example.com/external-owner/repo/discussions/1", + comments: { totalCount: 2 }, + reactions: defaultReactions, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 10, + owner: { login: "external-owner" }, + }, +}; + +export function makeRepo(overrides: Partial = {}): RepoNode { + return { + ...defaultRepo, + ...overrides, + watchers: overrides.watchers ?? defaultRepo.watchers, + languages: overrides.languages ?? defaultRepo.languages, + }; +} + +export function makePullRequest( + overrides: Partial = {}, +): PullRequestNode { + const repository = overrides.repository + ? { + ...defaultPullRequest.repository, + ...overrides.repository, + languages: overrides.repository.languages ?? defaultPullRequest.repository.languages, + } + : defaultPullRequest.repository; + + return { + ...defaultPullRequest, + ...overrides, + repository, + }; +} + +export function makeRepoLanguages( + edges: Array<{ size: number; name: string }>, +): RepoLanguages { + return { + edges: edges.map((edge) => ({ + size: edge.size, + node: { name: edge.name }, + })), + }; +} + +export function makeIssue(overrides: Partial = {}): IssueNode { + return { + ...defaultIssue, + ...overrides, + comments: overrides.comments ?? defaultIssue.comments, + reactions: overrides.reactions ?? defaultIssue.reactions, + repository: overrides.repository ?? defaultIssue.repository, + }; +} + +export function makeDiscussion( + overrides: Partial = {}, +): DiscussionNode { + return { + ...defaultDiscussion, + ...overrides, + comments: overrides.comments ?? defaultDiscussion.comments, + reactions: overrides.reactions ?? defaultDiscussion.reactions, + repository: overrides.repository ?? defaultDiscussion.repository, + }; +} + +export function makeContributions( + overrides: Partial = {}, +): ContributionTotals { + return { + ...defaultContributions, + ...overrides, + }; +} + +export function makeUserScoreInput( + overrides: Partial = {}, +): UserScoreInput { + return { + repos: overrides.repos ?? [makeRepo()], + pullRequests: overrides.pullRequests ?? [makePullRequest()], + contributions: overrides.contributions ?? makeContributions(), + issues: overrides.issues ?? [], + discussions: overrides.discussions ?? [], + referenceDate: overrides.referenceDate, + selectedLanguages: overrides.selectedLanguages, + }; +} diff --git a/test/helpers/score.ts b/test/helpers/score.ts new file mode 100644 index 0000000..d604d53 --- /dev/null +++ b/test/helpers/score.ts @@ -0,0 +1,220 @@ +import type { + DiscussionNode, + IssueNode, + PullRequestNode, + ReactionSummary, + RepoNode, +} from "@/types/github"; + +const MS_PER_DAY = 86_400_000; +const DEFAULT_REFERENCE_DATE = new Date("2026-05-10T00:00:00.000Z"); + +export function safeLog(value: number): number { + return Math.log(Math.max(0, value) + 1); +} + +export function getDiminishingWeight(index: number): number { + return 1 / (index + 1); +} + +export function getRepoRankWeight(index: number): number { + return index < 5 ? 1 : 0.1; +} + +function parseDate(value?: string): Date | null { + if (!value) { + return null; + } + + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +function getDaysSince(pushedAt: string, referenceDate: Date): number | null { + const pushed = parseDate(pushedAt); + if (!pushed) { + return null; + } + + return Math.max(0, (referenceDate.getTime() - pushed.getTime()) / MS_PER_DAY); +} + +function getRepoActivityFactor( + pushedAt: string | undefined, + referenceDate: Date, +): number { + if (!pushedAt) { + return 0.8; + } + + const days = getDaysSince(pushedAt, referenceDate); + if (days === null) { + return 0.8; + } + + if (days <= 90) return 1.2; + if (days <= 365) return 1.0; + if (days <= 730) return 0.7; + return 0.4; +} + +function getPRActivityFactor( + pushedAt: string | undefined, + referenceDate: Date, +): number { + if (!pushedAt) { + return 0.9; + } + + const days = getDaysSince(pushedAt, referenceDate); + if (days === null) { + return 0.9; + } + + if (days <= 90) return 1.1; + if (days <= 365) return 1.0; + if (days <= 730) return 0.85; + return 0.7; +} + +export function expectedRepoScore( + repo: RepoNode, + referenceDate: Date = DEFAULT_REFERENCE_DATE, +): number { + let score = + safeLog(repo.stargazerCount) * 5 + + safeLog(repo.forkCount) * 3 + + safeLog(repo.watchers.totalCount) * 2; + + if (repo.isFork) { + score *= 0.2; + } + + score *= getRepoActivityFactor(repo.pushedAt, referenceDate); + + return score; +} + +export function expectedPRScore( + pr: PullRequestNode, + username: string, + referenceDate: Date = DEFAULT_REFERENCE_DATE, +): number { + if (!pr.merged) { + return 0; + } + + if (pr.repository.owner.login.toLowerCase() === username.toLowerCase()) { + return 0; + } + + const changedLines = Math.max(0, pr.additions) + Math.max(0, pr.deletions); + const base = safeLog(pr.repository.stargazerCount) * 2; + const sizeFactor = Math.min(safeLog(changedLines), 5); + let score = base * sizeFactor; + + if (changedLines < 5) { + score *= 0.25; + } + + if (changedLines > 5000) { + score *= 0.6; + } + + score *= getPRActivityFactor(pr.repository.pushedAt, referenceDate); + + return score; +} + +export function sortDescending(values: number[]): number[] { + return [...values].sort((left, right) => right - left); +} + +export function sumWithDiminishingReturns(scores: number[]): number { + return sortDescending(scores).reduce( + (sum, score, index) => sum + score * getDiminishingWeight(index), + 0, + ); +} + +export function sumRepoScores( + repos: RepoNode[], + referenceDate: Date = DEFAULT_REFERENCE_DATE, +): number { + const scores = sortDescending(repos.map((repo) => expectedRepoScore(repo, referenceDate))); + + return scores.reduce((sum, score, index) => { + return sum + score * getRepoRankWeight(index); + }, 0); +} + +export function sumPRScores( + prs: PullRequestNode[], + username: string, + referenceDate: Date = DEFAULT_REFERENCE_DATE, +): number { + const grouped = new Map(); + + for (const pr of prs) { + const score = expectedPRScore(pr, username, referenceDate); + if (score === 0) { + continue; + } + + const repoKey = pr.repository.nameWithOwner; + const current = grouped.get(repoKey) ?? []; + current.push(score); + grouped.set(repoKey, current); + } + + let total = 0; + for (const scores of grouped.values()) { + total += sumWithDiminishingReturns(scores); + } + + return total; +} + +function readReactionTotals( + reactions?: ReactionSummary, +): { positive: number; neutral: number; negative: number } { + if (!reactions) { + return { positive: 0, neutral: 0, negative: 0 }; + } + + return { + positive: + reactions.thumbsUp + + reactions.heart * 1.2 + + reactions.hooray * 1.2 + + reactions.rocket * 1.3, + neutral: reactions.eyes * 0.4 + reactions.laugh * 0.2, + negative: reactions.thumbsDown * 1.5 + reactions.confused, + }; +} + +export function expectedCommunityScore( + item: IssueNode | DiscussionNode, +): number { + const comments = Math.max(0, item.comments.totalCount); + const { positive, neutral, negative } = readReactionTotals(item.reactions); + const reactionQuality = Math.max(0, positive + neutral - negative); + const reactionTotal = positive + neutral + negative; + const negativeRatio = reactionTotal > 0 ? negative / reactionTotal : 0; + + let score = + safeLog(item.repository.stargazerCount) * + safeLog(comments + reactionQuality); + + if (comments === 0 && reactionQuality === 0) { + score *= 0.2; + } + + if (negativeRatio > 0.5) { + score *= 0.2; + } else if (negativeRatio > 0.3) { + score *= 0.6; + } + + return score; +} diff --git a/test/scoring/calculateUserScore.contribution.test.ts b/test/scoring/calculateUserScore.contribution.test.ts new file mode 100644 index 0000000..9a82755 --- /dev/null +++ b/test/scoring/calculateUserScore.contribution.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, test } from "vitest"; + +import { calculateUserScore } from "@/lib/score"; +import { + makeContributions, + makeDiscussion, + makeIssue, + makePullRequest, + makeRepo, + makeUserScoreInput, +} from "@/test/fixtures/github"; +import { expectedCommunityScore } from "@/test/helpers/score"; + +describe("calculateUserScore - contribution scoring", () => { + test("commits are not counted", () => { + const withCommitTotals = calculateUserScore( + makeUserScoreInput({ + repos: [], + pullRequests: [], + contributions: makeContributions({ + totalCommitContributions: 10_000, + totalPullRequestContributions: 0, + totalIssueContributions: 0, + }), + }), + "octocat", + ); + + expect(withCommitTotals.contributionScore).toBe(0); + }); + + test("PR contribution totals are not counted", () => { + const withPrTotals = calculateUserScore( + makeUserScoreInput({ + repos: [], + pullRequests: [], + contributions: makeContributions({ + totalCommitContributions: 0, + totalPullRequestContributions: 10_000, + totalIssueContributions: 0, + }), + }), + "octocat", + ); + + expect(withPrTotals.contributionScore).toBe(0); + }); + + test("issues in own repos are ignored", () => { + const ownIssue = makeIssue({ + repository: { + nameWithOwner: "octocat/repo", + stargazerCount: 100, + owner: { login: "octocat" }, + }, + }); + + const result = calculateUserScore( + makeUserScoreInput({ + repos: [], + pullRequests: [], + issues: [ownIssue], + }), + "OcToCaT", + ); + + expect(result.contributionScore).toBe(0); + expect(result.signals.externalIssuesCounted).toBe(0); + }); + + test("external issues are counted", () => { + const externalIssue = makeIssue({ + comments: { totalCount: 6 }, + reactions: { + thumbsUp: 3, + thumbsDown: 0, + heart: 1, + hooray: 1, + rocket: 0, + eyes: 1, + confused: 0, + laugh: 0, + }, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 120, + owner: { login: "external-owner" }, + }, + }); + + const result = calculateUserScore( + makeUserScoreInput({ + repos: [ + makeRepo({ + stargazerCount: 5000, + forkCount: 1000, + watchers: { totalCount: 500 }, + }), + ], + pullRequests: [], + issues: [externalIssue], + }), + "octocat", + ); + + expect(result.contributionScore).toBeCloseTo(expectedCommunityScore(externalIssue), 10); + expect(result.signals.externalIssuesCounted).toBe(1); + }); + + test("discussions are counted when provided", () => { + const discussion = makeDiscussion({ + comments: { totalCount: 4 }, + reactions: { + thumbsUp: 2, + thumbsDown: 0, + heart: 1, + hooray: 0, + rocket: 1, + eyes: 1, + confused: 0, + laugh: 0, + }, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 80, + owner: { login: "external-owner" }, + }, + }); + + const result = calculateUserScore( + makeUserScoreInput({ + repos: [ + makeRepo({ + stargazerCount: 5000, + forkCount: 1000, + watchers: { totalCount: 500 }, + }), + ], + pullRequests: [], + discussions: [discussion], + }), + "octocat", + ); + + expect(result.contributionScore).toBeGreaterThan(0); + expect(result.signals.externalDiscussionsCounted).toBe(1); + }); + + test("issues with negative reactions are penalized", () => { + const positiveIssue = makeIssue({ + comments: { totalCount: 2 }, + reactions: { + thumbsUp: 12, + thumbsDown: 0, + heart: 1, + hooray: 0, + rocket: 0, + eyes: 1, + confused: 0, + laugh: 0, + }, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 60, + owner: { login: "external-owner" }, + }, + }); + const negativeIssue = makeIssue({ + comments: { totalCount: 2 }, + reactions: { + thumbsUp: 1, + thumbsDown: 10, + heart: 0, + hooray: 0, + rocket: 0, + eyes: 0, + confused: 4, + laugh: 0, + }, + repository: positiveIssue.repository, + }); + + const positiveResult = calculateUserScore( + makeUserScoreInput({ + repos: [ + makeRepo({ + stargazerCount: 5000, + forkCount: 1000, + watchers: { totalCount: 500 }, + }), + ], + pullRequests: [], + issues: [positiveIssue], + }), + "octocat", + ); + const negativeResult = calculateUserScore( + makeUserScoreInput({ + repos: [ + makeRepo({ + stargazerCount: 5000, + forkCount: 1000, + watchers: { totalCount: 500 }, + }), + ], + pullRequests: [], + issues: [negativeIssue], + }), + "octocat", + ); + + expect(negativeResult.contributionScore).toBeLessThan(positiveResult.contributionScore); + }); + + test("issues with zero comments and reactions get reduced score", () => { + const emptySignalIssue = makeIssue({ + comments: { totalCount: 0 }, + reactions: { + thumbsUp: 0, + thumbsDown: 0, + heart: 0, + hooray: 0, + rocket: 0, + eyes: 0, + confused: 0, + laugh: 0, + }, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 500, + owner: { login: "external-owner" }, + }, + }); + const meaningfulIssue = makeIssue({ + comments: { totalCount: 5 }, + reactions: { + thumbsUp: 5, + thumbsDown: 0, + heart: 1, + hooray: 1, + rocket: 1, + eyes: 1, + confused: 0, + laugh: 0, + }, + repository: emptySignalIssue.repository, + }); + + const emptySignalResult = calculateUserScore( + makeUserScoreInput({ + repos: [ + makeRepo({ + stargazerCount: 5000, + forkCount: 1000, + watchers: { totalCount: 500 }, + }), + ], + pullRequests: [], + issues: [emptySignalIssue], + }), + "octocat", + ); + const meaningfulResult = calculateUserScore( + makeUserScoreInput({ + repos: [ + makeRepo({ + stargazerCount: 5000, + forkCount: 1000, + watchers: { totalCount: 500 }, + }), + ], + pullRequests: [], + issues: [meaningfulIssue], + }), + "octocat", + ); + + expect(emptySignalResult.contributionScore).toBeLessThan(meaningfulResult.contributionScore); + }); + + test("contribution score is capped at 30% of repo + PR scores", () => { + const result = calculateUserScore( + makeUserScoreInput({ + repos: [ + makeRepo({ + stargazerCount: 10, + forkCount: 2, + watchers: { totalCount: 1 }, + }), + ], + pullRequests: [ + makePullRequest({ + additions: 40, + deletions: 10, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 20, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + ], + issues: Array.from({ length: 10 }, (_, index) => + makeIssue({ + title: `Issue ${index + 1}`, + comments: { totalCount: 20 }, + reactions: { + thumbsUp: 10, + thumbsDown: 0, + heart: 3, + hooray: 2, + rocket: 2, + eyes: 2, + confused: 0, + laugh: 1, + }, + repository: { + nameWithOwner: `external-owner/repo-${index + 1}`, + stargazerCount: 500 + index * 20, + owner: { login: "external-owner" }, + }, + }), + ), + }), + "octocat", + ); + + expect(result.contributionScore).toBeCloseTo( + 0.3 * (result.repoScore + result.prScore), + 10, + ); + }); +}); diff --git a/test/scoring/calculateUserScore.language.test.ts b/test/scoring/calculateUserScore.language.test.ts new file mode 100644 index 0000000..e2f7b56 --- /dev/null +++ b/test/scoring/calculateUserScore.language.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, test } from "vitest"; + +import { calculateUserScore } from "@/lib/score"; +import { getLanguageFactor } from "@/lib/scoring/languageScoring"; +import { + makeContributions, + makePullRequest, + makeRepo, + makeRepoLanguages, + makeUserScoreInput, +} from "@/test/fixtures/github"; + +describe("calculateUserScore - language scoring", () => { + test("languageScores is undefined when selectedLanguages is empty", () => { + const result = calculateUserScore( + makeUserScoreInput({ + repos: [makeRepo()], + pullRequests: [], + selectedLanguages: [], + }), + "octocat", + ); + + expect(result.languageScores).toBeUndefined(); + }); + + test("languageScores exists when selectedLanguages is provided", () => { + const result = calculateUserScore( + makeUserScoreInput({ + repos: [makeRepo()], + pullRequests: [], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + expect(result.languageScores).toBeDefined(); + expect(result.languageScores?.selectedLanguages).toEqual(["typescript"]); + }); + + test("languageRepoScore equals normal repo score on full match", () => { + const repo = makeRepo({ + languages: makeRepoLanguages([{ size: 1000, name: "TypeScript" }]), + }); + const result = calculateUserScore( + makeUserScoreInput({ + repos: [repo], + pullRequests: [], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + expect(result.languageScores?.repoScore).toBeCloseTo(result.repoScore, 10); + }); + + test("languageRepoScore is reduced on partial language match", () => { + const repo = makeRepo({ + languages: makeRepoLanguages([ + { size: 700, name: "TypeScript" }, + { size: 300, name: "Go" }, + ]), + }); + const result = calculateUserScore( + makeUserScoreInput({ + repos: [repo], + pullRequests: [], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + expect(result.languageScores?.repoScore ?? 0).toBeLessThan(result.repoScore); + }); + + test("languageRepoScore uses minFactor when language does not match", () => { + const repo = makeRepo({ + languages: makeRepoLanguages([{ size: 1000, name: "Rust" }]), + }); + const result = calculateUserScore( + makeUserScoreInput({ + repos: [repo], + pullRequests: [], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + expect(result.languageScores?.repoScore).toBeCloseTo( + result.repoScore * getLanguageFactor(0), + 10, + ); + }); + + test("missing language data uses neutral factor", () => { + const repo = makeRepo({ + languages: makeRepoLanguages([]), + }); + const result = calculateUserScore( + makeUserScoreInput({ + repos: [repo], + pullRequests: [], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + expect(result.languageScores?.repoScore).toBeCloseTo( + result.repoScore * getLanguageFactor(0.5), + 10, + ); + }); + + test("topLanguageRepos includes languageMatch and topLanguages", () => { + const repo = makeRepo({ + languages: makeRepoLanguages([ + { size: 700, name: "TypeScript" }, + { size: 300, name: "JavaScript" }, + ]), + }); + const result = calculateUserScore( + makeUserScoreInput({ + repos: [repo], + pullRequests: [], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + expect(result.languageScores?.topRepos[0].languageMatch).toBeCloseTo(0.7, 10); + expect(result.languageScores?.topRepos[0].topLanguages).toEqual([ + { name: "TypeScript", percentage: 70 }, + { name: "JavaScript", percentage: 30 }, + ]); + }); + + test("languagePRScore equals normal PR score on full match", () => { + const pr = makePullRequest({ + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 50, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + languages: makeRepoLanguages([{ size: 1000, name: "TypeScript" }]), + }, + }); + const result = calculateUserScore( + makeUserScoreInput({ + repos: [], + pullRequests: [pr], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + expect(result.languageScores?.prScore).toBeCloseTo(result.prScore, 10); + }); + + test("languagePRScore uses target repository language match", () => { + const matching = makePullRequest({ + title: "matching", + repository: { + nameWithOwner: "external-owner/repo-a", + stargazerCount: 50, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + languages: makeRepoLanguages([{ size: 1000, name: "TypeScript" }]), + }, + }); + const nonMatching = makePullRequest({ + title: "non-matching", + repository: { + nameWithOwner: "external-owner/repo-b", + stargazerCount: 50, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + languages: makeRepoLanguages([{ size: 1000, name: "Rust" }]), + }, + }); + const result = calculateUserScore( + makeUserScoreInput({ + repos: [], + pullRequests: [matching, nonMatching], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + expect(result.languageScores?.topPullRequests[0].title).toBe("matching"); + }); + + test("own repo PRs and unmerged PRs remain ignored in language mode", () => { + const ownRepoPr = makePullRequest({ + repository: { + nameWithOwner: "octocat/repo", + stargazerCount: 100, + owner: { login: "octocat" }, + pushedAt: "2026-05-01T00:00:00.000Z", + languages: makeRepoLanguages([{ size: 1000, name: "TypeScript" }]), + }, + }); + const unmergedPr = makePullRequest({ + merged: false, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 100, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + languages: makeRepoLanguages([{ size: 1000, name: "TypeScript" }]), + }, + }); + + const result = calculateUserScore( + makeUserScoreInput({ + repos: [], + pullRequests: [ownRepoPr, unmergedPr], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + expect(result.prScore).toBe(0); + expect(result.languageScores?.prScore).toBe(0); + }); + + test("diminishing returns still apply after language adjustment", () => { + const pr1 = makePullRequest({ + additions: 200, + deletions: 50, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 90, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + languages: makeRepoLanguages([{ size: 1000, name: "TypeScript" }]), + }, + }); + const pr2 = makePullRequest({ + additions: 100, + deletions: 20, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 90, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + languages: makeRepoLanguages([{ size: 1000, name: "TypeScript" }]), + }, + }); + const result = calculateUserScore( + makeUserScoreInput({ + repos: [], + pullRequests: [pr1, pr2], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + const top = result.languageScores?.topPullRequests ?? []; + expect(top).toHaveLength(2); + expect((result.languageScores?.prScore ?? 0)).toBeLessThanOrEqual(result.prScore); + }); + + test("topLanguagePullRequests includes languageMatch and topLanguages", () => { + const pr = makePullRequest({ + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 50, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + languages: makeRepoLanguages([ + { size: 800, name: "TypeScript" }, + { size: 200, name: "JavaScript" }, + ]), + }, + }); + const result = calculateUserScore( + makeUserScoreInput({ + repos: [], + pullRequests: [pr], + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + expect(result.languageScores?.topPullRequests[0].languageMatch).toBeCloseTo(0.8, 10); + expect(result.languageScores?.topPullRequests[0].topLanguages).toEqual([ + { name: "TypeScript", percentage: 80 }, + { name: "JavaScript", percentage: 20 }, + ]); + }); + + test("languageFinalScore uses 45/45/10 weights and never NaN", () => { + const result = calculateUserScore( + makeUserScoreInput({ + repos: [ + makeRepo({ + stargazerCount: 80, + forkCount: 15, + watchers: { totalCount: 8 }, + languages: makeRepoLanguages([{ size: 1000, name: "TypeScript" }]), + }), + ], + pullRequests: [ + makePullRequest({ + additions: 160, + deletions: 40, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 70, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + languages: makeRepoLanguages([{ size: 1000, name: "TypeScript" }]), + }, + }), + ], + contributions: makeContributions(), + selectedLanguages: ["TypeScript"], + }), + "octocat", + ); + + const languageScores = result.languageScores; + expect(languageScores).toBeDefined(); + expect(languageScores?.finalScore).toBeCloseTo( + (languageScores?.repoScore ?? 0) * 0.45 + + (languageScores?.prScore ?? 0) * 0.45 + + (languageScores?.contributionScore ?? 0) * 0.1, + 10, + ); + expect(Number.isNaN(languageScores?.finalScore ?? 0)).toBe(false); + expect(Number.isFinite(languageScores?.finalScore ?? 0)).toBe(true); + }); +}); diff --git a/test/scoring/calculateUserScore.pr.test.ts b/test/scoring/calculateUserScore.pr.test.ts new file mode 100644 index 0000000..fa490e6 --- /dev/null +++ b/test/scoring/calculateUserScore.pr.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, test } from "vitest"; + +import { calculateUserScore } from "@/lib/score"; +import { makePullRequest, makeUserScoreInput } from "@/test/fixtures/github"; +import { expectedPRScore, sumPRScores, sumWithDiminishingReturns } from "@/test/helpers/score"; + +describe("calculateUserScore - pull request scoring", () => { + test("unmerged PRs are ignored", () => { + const result = calculateUserScore( + makeUserScoreInput({ + repos: [], + pullRequests: [makePullRequest({ merged: false })], + }), + "octocat", + ); + + expect(result.prScore).toBe(0); + expect(result.signals.unmergedPRsIgnored).toBe(1); + }); + + test("own repo PRs are ignored case-insensitively", () => { + const result = calculateUserScore( + makeUserScoreInput({ + repos: [], + pullRequests: [ + makePullRequest({ + repository: { + nameWithOwner: "TestUser/repo", + stargazerCount: 50, + owner: { login: "testuser" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + ], + }), + "TestUser", + ); + + expect(result.prScore).toBe(0); + expect(result.signals.ownRepoPRsIgnored).toBe(1); + }); + + test("merged external PRs are counted", () => { + const pr = makePullRequest({ + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 50, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }); + + const result = calculateUserScore( + makeUserScoreInput({ repos: [], pullRequests: [pr] }), + "octocat", + ); + + expect(result.prScore).toBeGreaterThan(0); + expect(result.signals.mergedExternalPRs).toBe(1); + }); + + test("repeated PRs to same repo use diminishing returns", () => { + const prs = [ + makePullRequest({ + additions: 250, + deletions: 80, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 80, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + makePullRequest({ + additions: 120, + deletions: 35, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 80, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + makePullRequest({ + additions: 30, + deletions: 10, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 80, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + ]; + + const result = calculateUserScore( + makeUserScoreInput({ repos: [], pullRequests: prs }), + "octocat", + ); + + const expectedScores = prs.map((pr) => expectedPRScore(pr, "octocat")); + expect(result.prScore).toBeCloseTo(sumWithDiminishingReturns(expectedScores), 10); + }); + + test("PRs to different repos do not share diminishing returns", () => { + const prs = [ + makePullRequest({ + additions: 180, + deletions: 40, + repository: { + nameWithOwner: "external-owner/repo-a", + stargazerCount: 60, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + makePullRequest({ + additions: 90, + deletions: 20, + repository: { + nameWithOwner: "external-owner/repo-a", + stargazerCount: 60, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + makePullRequest({ + additions: 220, + deletions: 60, + repository: { + nameWithOwner: "external-owner/repo-b", + stargazerCount: 60, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + makePullRequest({ + additions: 110, + deletions: 25, + repository: { + nameWithOwner: "external-owner/repo-b", + stargazerCount: 60, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + ]; + + const result = calculateUserScore( + makeUserScoreInput({ repos: [], pullRequests: prs }), + "octocat", + ); + + expect(result.prScore).toBeCloseTo(sumPRScores(prs, "octocat"), 10); + expect(result.signals.uniqueExternalPRRepos).toBe(2); + }); + + test("tiny PRs are penalized", () => { + const tinyPr = makePullRequest({ additions: 1, deletions: 1 }); + const normalPr = makePullRequest({ additions: 60, deletions: 20 }); + + const tiny = calculateUserScore( + makeUserScoreInput({ repos: [], pullRequests: [tinyPr] }), + "octocat", + ); + const normal = calculateUserScore( + makeUserScoreInput({ repos: [], pullRequests: [normalPr] }), + "octocat", + ); + + expect(tiny.prScore).toBeLessThan(normal.prScore); + }); + + test("huge PRs are penalized", () => { + const hugePr = makePullRequest({ additions: 8000, deletions: 2500 }); + const mediumPr = makePullRequest({ additions: 3000, deletions: 1000 }); + + const huge = calculateUserScore( + makeUserScoreInput({ repos: [], pullRequests: [hugePr] }), + "octocat", + ); + const medium = calculateUserScore( + makeUserScoreInput({ repos: [], pullRequests: [mediumPr] }), + "octocat", + ); + + expect(huge.prScore).toBeLessThan(medium.prScore); + }); + + test("zero-line PR does not produce NaN", () => { + const pr = makePullRequest({ additions: 0, deletions: 0 }); + + const result = calculateUserScore( + makeUserScoreInput({ repos: [], pullRequests: [pr] }), + "octocat", + ); + + expect(Number.isNaN(result.prScore)).toBe(false); + expect(Number.isFinite(result.prScore)).toBe(true); + }); + + test("topPullRequests returns top 3 sorted by score", () => { + const prs = [ + makePullRequest({ + title: "PR 1", + additions: 10, + deletions: 5, + repository: { + nameWithOwner: "external-owner/repo-1", + stargazerCount: 10, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + makePullRequest({ + title: "PR 2", + additions: 120, + deletions: 40, + repository: { + nameWithOwner: "external-owner/repo-2", + stargazerCount: 20, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + makePullRequest({ + title: "PR 3", + additions: 250, + deletions: 90, + repository: { + nameWithOwner: "external-owner/repo-3", + stargazerCount: 30, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + makePullRequest({ + title: "PR 4", + additions: 40, + deletions: 10, + repository: { + nameWithOwner: "external-owner/repo-4", + stargazerCount: 40, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + ]; + + const result = calculateUserScore( + makeUserScoreInput({ repos: [], pullRequests: prs }), + "octocat", + ); + + expect(result.topPullRequests).toHaveLength(3); + expect(result.topPullRequests[0].score).toBeGreaterThanOrEqual( + result.topPullRequests[1].score, + ); + expect(result.topPullRequests[1].score).toBeGreaterThanOrEqual( + result.topPullRequests[2].score, + ); + }); +}); diff --git a/test/scoring/calculateUserScore.repo.test.ts b/test/scoring/calculateUserScore.repo.test.ts new file mode 100644 index 0000000..5081e55 --- /dev/null +++ b/test/scoring/calculateUserScore.repo.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from "vitest"; + +import { calculateUserScore } from "@/lib/score"; +import { makeContributions, makeRepo, makeUserScoreInput } from "@/test/fixtures/github"; +import { expectedRepoScore, sumRepoScores } from "@/test/helpers/score"; + +describe("calculateUserScore - repository scoring", () => { + test("empty repos return a zero repository score", () => { + const result = calculateUserScore( + { + repos: [], + pullRequests: [], + contributions: makeContributions(), + }, + "octocat", + ); + + expect(result.repoScore).toBe(0); + expect(result.topRepos).toEqual([]); + expect(result.signals.reposAnalyzed).toBe(0); + }); + + test("repo with zero stars/forks/watchers returns zero", () => { + const repo = makeRepo({ + stargazerCount: 0, + forkCount: 0, + watchers: { totalCount: 0 }, + }); + + const result = calculateUserScore( + makeUserScoreInput({ repos: [repo], pullRequests: [] }), + "octocat", + ); + + expect(result.repoScore).toBe(0); + expect(Number.isNaN(result.repoScore)).toBe(false); + }); + + test("repo score uses stars, forks, and watchers as base signals", () => { + const lowSignalRepo = makeRepo({ + stargazerCount: 5, + forkCount: 1, + watchers: { totalCount: 1 }, + }); + const highSignalRepo = makeRepo({ + stargazerCount: 100, + forkCount: 20, + watchers: { totalCount: 10 }, + }); + + const low = calculateUserScore( + makeUserScoreInput({ repos: [lowSignalRepo], pullRequests: [] }), + "octocat", + ); + const high = calculateUserScore( + makeUserScoreInput({ repos: [highSignalRepo], pullRequests: [] }), + "octocat", + ); + + expect(high.repoScore).toBeGreaterThan(low.repoScore); + expect(high.repoScore).toBeCloseTo(expectedRepoScore(highSignalRepo), 10); + }); + + test("top 5 repos get full weight", () => { + const repos = [1, 2, 3, 4, 5].map((index) => + makeRepo({ + name: `repo-${index}`, + stargazerCount: 120 - index * 10, + forkCount: 25 - index * 2, + watchers: { totalCount: 15 - index }, + }), + ); + + const result = calculateUserScore( + makeUserScoreInput({ repos, pullRequests: [] }), + "octocat", + ); + + expect(result.repoScore).toBeCloseTo(sumRepoScores(repos), 10); + }); + + test("repos after top 5 get 0.1 rank weight", () => { + const repos = [1, 2, 3, 4, 5, 6, 7].map((index) => + makeRepo({ + name: `repo-${index}`, + stargazerCount: 150 - index * 12, + forkCount: 30 - index * 2, + watchers: { totalCount: 18 - index }, + }), + ); + + const result = calculateUserScore( + makeUserScoreInput({ repos, pullRequests: [] }), + "octocat", + ); + + expect(result.repoScore).toBeCloseTo(sumRepoScores(repos), 10); + }); + + test("forked repositories are heavily penalized", () => { + const original = makeRepo({ isFork: false }); + const forked = makeRepo({ isFork: true }); + + const originalResult = calculateUserScore( + makeUserScoreInput({ repos: [original], pullRequests: [] }), + "octocat", + ); + const forkedResult = calculateUserScore( + makeUserScoreInput({ repos: [forked], pullRequests: [] }), + "octocat", + ); + + expect(forkedResult.repoScore).toBeLessThan(originalResult.repoScore * 0.25); + }); + + test("inactive repositories are penalized", () => { + const activeRepo = makeRepo({ pushedAt: "2026-04-20T00:00:00.000Z" }); + const staleRepo = makeRepo({ pushedAt: "2020-01-01T00:00:00.000Z" }); + + const activeResult = calculateUserScore( + makeUserScoreInput({ + repos: [activeRepo], + pullRequests: [], + referenceDate: "2026-05-10T00:00:00.000Z", + }), + "octocat", + ); + const staleResult = calculateUserScore( + makeUserScoreInput({ + repos: [staleRepo], + pullRequests: [], + referenceDate: "2026-05-10T00:00:00.000Z", + }), + "octocat", + ); + + expect(staleResult.repoScore).toBeLessThan(activeResult.repoScore); + }); + + test("recently active repositories get a score boost", () => { + const recentRepo = makeRepo({ pushedAt: "2026-05-08T00:00:00.000Z" }); + const mediumRepo = makeRepo({ pushedAt: "2025-06-01T00:00:00.000Z" }); + + const recent = calculateUserScore( + makeUserScoreInput({ + repos: [recentRepo], + pullRequests: [], + referenceDate: "2026-05-10T00:00:00.000Z", + }), + "octocat", + ); + const medium = calculateUserScore( + makeUserScoreInput({ + repos: [mediumRepo], + pullRequests: [], + referenceDate: "2026-05-10T00:00:00.000Z", + }), + "octocat", + ); + + expect(recent.repoScore).toBeGreaterThan(medium.repoScore); + }); +}); diff --git a/test/scoring/calculateUserScore.scenario.test.ts b/test/scoring/calculateUserScore.scenario.test.ts new file mode 100644 index 0000000..6802953 --- /dev/null +++ b/test/scoring/calculateUserScore.scenario.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, test } from "vitest"; + +import { calculateUserScore } from "@/lib/score"; +import { + makeContributions, + makeIssue, + makePullRequest, + makeRepo, + makeUserScoreInput, +} from "@/test/fixtures/github"; + +describe("calculateUserScore - final score behavior", () => { + test("final score uses 45/45/10 weights", () => { + const result = calculateUserScore( + makeUserScoreInput({ + repos: [ + makeRepo({ + stargazerCount: 90, + forkCount: 20, + watchers: { totalCount: 10 }, + }), + ], + pullRequests: [ + makePullRequest({ + additions: 180, + deletions: 40, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 70, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + ], + issues: [ + makeIssue({ + comments: { totalCount: 6 }, + reactions: { + thumbsUp: 4, + thumbsDown: 0, + heart: 1, + hooray: 1, + rocket: 1, + eyes: 1, + confused: 0, + laugh: 0, + }, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 90, + owner: { login: "external-owner" }, + }, + }), + ], + }), + "octocat", + ); + + expect(result.finalScore).toBeCloseTo( + result.repoScore * 0.45 + + result.prScore * 0.45 + + result.contributionScore * 0.1, + 10, + ); + }); + + test("normalized scores are always between 0 and 100", () => { + const result = calculateUserScore( + makeUserScoreInput({ + repos: [ + makeRepo({ + stargazerCount: 200, + forkCount: 40, + watchers: { totalCount: 20 }, + }), + ], + pullRequests: [ + makePullRequest({ + additions: 2000, + deletions: 500, + repository: { + nameWithOwner: "external-owner/repo", + stargazerCount: 120, + owner: { login: "external-owner" }, + pushedAt: "2026-05-01T00:00:00.000Z", + }, + }), + ], + }), + "octocat", + ); + + expect(result.normalizedRepoScore).toBeGreaterThanOrEqual(0); + expect(result.normalizedRepoScore).toBeLessThanOrEqual(100); + expect(result.normalizedPRScore).toBeGreaterThanOrEqual(0); + expect(result.normalizedPRScore).toBeLessThanOrEqual(100); + expect(result.normalizedContributionScore).toBeGreaterThanOrEqual(0); + expect(result.normalizedContributionScore).toBeLessThanOrEqual(100); + expect(result.normalizedFinalScore).toBeGreaterThanOrEqual(0); + expect(result.normalizedFinalScore).toBeLessThanOrEqual(100); + }); + + test("empty user returns all zero values with no NaN", () => { + const result = calculateUserScore( + { + repos: [], + pullRequests: [], + contributions: makeContributions(), + }, + "ghost", + ); + + expect(result.repoScore).toBe(0); + expect(result.prScore).toBe(0); + expect(result.contributionScore).toBe(0); + expect(result.finalScore).toBe(0); + expect(result.normalizedRepoScore).toBe(0); + expect(result.normalizedPRScore).toBe(0); + expect(result.normalizedContributionScore).toBe(0); + expect(result.normalizedFinalScore).toBe(0); + expect(result.topRepos).toEqual([]); + expect(result.topPullRequests).toEqual([]); + expect(result.topCommunityContributions).toEqual([]); + expect(Number.isNaN(result.finalScore)).toBe(false); + }); +}); diff --git a/test/scoring/languageScoring.helpers.test.ts b/test/scoring/languageScoring.helpers.test.ts new file mode 100644 index 0000000..f3963af --- /dev/null +++ b/test/scoring/languageScoring.helpers.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "vitest"; +import { + getLanguageDistribution, + getLanguageFactor, + getLanguageMatch, + getTopLanguages, + normalizeSelectedLanguages, +} from "@/lib/scoring/languageScoring"; +import { makeRepoLanguages } from "@/test/fixtures/github"; + +describe("language scoring helpers", () => { + test("normalizeSelectedLanguages removes duplicates, trims, and lowercases", () => { + const normalized = normalizeSelectedLanguages([ + " TypeScript ", + "typescript", + "JAVASCRIPT", + " ", + "Python", + "Go", + "Rust", + "Java", + ]); + + expect(normalized).toEqual(["typescript", "javascript", "python", "go", "rust"]); + }); + + test("getLanguageDistribution returns percentages", () => { + const distribution = getLanguageDistribution( + makeRepoLanguages([ + { size: 700, name: "TypeScript" }, + { size: 200, name: "CSS" }, + { size: 100, name: "JavaScript" }, + ]), + ); + + expect(distribution.typescript).toBeCloseTo(0.7, 10); + expect(distribution.css).toBeCloseTo(0.2, 10); + expect(distribution.javascript).toBeCloseTo(0.1, 10); + }); + + test("getLanguageMatch returns the selected-language sum", () => { + const match = getLanguageMatch( + makeRepoLanguages([ + { size: 700, name: "TypeScript" }, + { size: 200, name: "CSS" }, + { size: 100, name: "JavaScript" }, + ]), + ["typescript", "javascript"], + ); + + expect(match).toBeCloseTo(0.8, 10); + }); + + test("getLanguageMatch returns 1 when selected languages are empty", () => { + expect(getLanguageMatch(undefined, [])).toBe(1); + }); + + test("getLanguageMatch returns 0.5 when repo language data is missing", () => { + expect(getLanguageMatch(undefined, ["typescript"])).toBe(0.5); + expect(getLanguageMatch(makeRepoLanguages([]), ["typescript"])).toBe(0.5); + }); + + test("getLanguageFactor applies soft penalty", () => { + expect(getLanguageFactor(1)).toBeCloseTo(1, 10); + expect(getLanguageFactor(0.7)).toBeCloseTo(0.775, 10); + expect(getLanguageFactor(0.3)).toBeCloseTo(0.475, 10); + expect(getLanguageFactor(0)).toBeCloseTo(0.25, 10); + }); + + test("getTopLanguages returns sorted rounded percentages", () => { + const top = getTopLanguages( + makeRepoLanguages([ + { size: 700, name: "TypeScript" }, + { size: 200, name: "CSS" }, + { size: 100, name: "JavaScript" }, + ]), + ); + + expect(top).toEqual([ + { name: "TypeScript", percentage: 70 }, + { name: "CSS", percentage: 20 }, + { name: "JavaScript", percentage: 10 }, + ]); + }); +}); diff --git a/types/api-response.ts b/types/api-response.ts index 708a11d..793f62f 100644 --- a/types/api-response.ts +++ b/types/api-response.ts @@ -3,5 +3,16 @@ import { UserResult } from "./user-result"; export type ApiResponse = { success: boolean; users?: UserResult[]; + winner?: { + username: string; + finalScoreDifference: number; + percentageDifference: number; + }; + languageWinner?: { + username: string; + finalScoreDifference: number; + percentageDifference: number; + selectedLanguages: string[]; + }; error?: string; -}; \ No newline at end of file +}; diff --git a/types/github.ts b/types/github.ts index ba79f85..640dcce 100644 --- a/types/github.ts +++ b/types/github.ts @@ -1,9 +1,59 @@ +export type ReactionSummary = { + thumbsUp: number; + thumbsDown: number; + heart: number; + hooray: number; + rocket: number; + eyes: number; + confused: number; + laugh: number; +}; + +export type RepoLanguageEdge = { + size: number; + node: { + name: string; + }; +}; + +export type RepoLanguages = { + edges: RepoLanguageEdge[]; +}; + +type CommunityRepositoryNode = { + nameWithOwner: string; + stargazerCount: number; + owner: { login: string }; + languages?: RepoLanguages; +}; + +export type IssueNode = { + title: string; + url?: string; + comments: { totalCount: number }; + reactions?: ReactionSummary; + repository: CommunityRepositoryNode; +}; + +export type DiscussionNode = { + title: string; + url?: string; + comments: { totalCount: number }; + reactions?: ReactionSummary; + repository: CommunityRepositoryNode; +}; + export type RepoNode = { name: string; + nameWithOwner?: string; + url?: string; + isFork?: boolean; stargazerCount: number; forkCount: number; watchers: { totalCount: number }; + pushedAt?: string; + languages?: RepoLanguages; }; export type PullRequestNode = { @@ -11,11 +61,14 @@ export type PullRequestNode = { additions: number; deletions: number; title: string; - url: string; + url?: string; repository: { nameWithOwner: string; + url?: string; stargazerCount: number; + pushedAt?: string; owner: { login: string }; + languages?: RepoLanguages; }; }; @@ -31,4 +84,6 @@ export type GitHubUserData = { repos: RepoNode[]; pullRequests: PullRequestNode[]; contributions: ContributionTotals; -}; \ No newline at end of file + issues?: IssueNode[]; + discussions?: DiscussionNode[]; +}; diff --git a/types/score.ts b/types/score.ts index a98a391..7100eb8 100644 --- a/types/score.ts +++ b/types/score.ts @@ -1,4 +1,9 @@ -import { PullRequestNode, RepoNode } from "./github"; +import { + DiscussionNode, + IssueNode, + PullRequestNode, + RepoNode, +} from "./github"; export type RepoScoreDetail = { repo: RepoNode; @@ -8,4 +13,36 @@ export type RepoScoreDetail = { export type PullRequestScoreDetail = { pr: PullRequestNode; score: number; -}; \ No newline at end of file +}; + +export type CommunityContributionDetail = { + type: "issue" | "discussion"; + item: IssueNode | DiscussionNode; + score: number; +}; + +export type ScoringSignals = { + reposAnalyzed: number; + pullRequestsAnalyzed: number; + mergedExternalPRs: number; + ownRepoPRsIgnored: number; + unmergedPRsIgnored: number; + uniqueExternalPRRepos: number; + issuesAnalyzed: number; + externalIssuesCounted: number; + discussionsAnalyzed: number; + externalDiscussionsCounted: number; + selectedLanguages?: string[]; + reposWithLanguageData?: number; + prsWithLanguageData?: number; + averageRepoLanguageMatch?: number; + averagePRLanguageMatch?: number; +}; + +export type ScoringExplanations = { + repo: string[]; + pr: string[]; + contribution: string[]; + overall: string[]; + language?: string[]; +}; diff --git a/types/user-result.ts b/types/user-result.ts index e10d651..39ac117 100644 --- a/types/user-result.ts +++ b/types/user-result.ts @@ -1,3 +1,5 @@ +import { ScoringExplanations, ScoringSignals } from "./score"; + export type UserResult = { username: string; name: string | null; @@ -6,8 +8,13 @@ export type UserResult = { prScore: number; contributionScore: number; finalScore: number; + normalizedRepoScore?: number; + normalizedPRScore?: number; + normalizedContributionScore?: number; + normalizedFinalScore?: number; topRepos: { name?: string; + url?: string; stars?: number; forks?: number; watchers?: number; @@ -22,5 +29,54 @@ export type UserResult = { deletions?: number; additions?: number; }[]; + topCommunityContributions?: { + type: "issue" | "discussion"; + title: string; + url?: string; + repo: string; + stars: number; + comments: number; + score: number; + }[]; + languageScores?: { + selectedLanguages: string[]; + repoScore: number; + prScore: number; + contributionScore: number; + finalScore: number; + normalizedRepoScore?: number; + normalizedPRScore?: number; + normalizedContributionScore?: number; + normalizedFinalScore?: number; + topRepos: { + name: string; + url?: string; + stars: number; + forks: number; + watchers: number; + score: number; + languageMatch: number; + topLanguages: { + name: string; + percentage: number; + }[]; + }[]; + topPullRequests: { + repo: string; + title: string; + url?: string; + stars: number; + score: number; + additions: number; + deletions: number; + languageMatch: number; + topLanguages: { + name: string; + percentage: number; + }[]; + }[]; + }; + signals?: ScoringSignals; + explanations?: ScoringExplanations; isWinner?: boolean; }; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fc79801 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + "@": resolve(rootDir, "."), + }, + }, + test: { + environment: "node", + }, +});