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",
+ },
+});