diff --git a/app/api/compare/route.ts b/app/api/compare/route.ts index b4bdb6f..c2e2168 100644 --- a/app/api/compare/route.ts +++ b/app/api/compare/route.ts @@ -77,12 +77,12 @@ function calculateWinner(users: ComparedUserResult[]): { winner?: { username: string; finalScoreDifference: number; - percentageDifference: number; + percentageDifference: number | null; }; languageWinner?: { username: string; finalScoreDifference: number; - percentageDifference: number; + percentageDifference: number | null; selectedLanguages: string[]; }; } { @@ -95,28 +95,29 @@ function calculateWinner(users: ComparedUserResult[]): { 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 overallPercentage = calculatePercentageDifference( + overallDifference, + overallLoser.finalScore, + ); const result: { winner: { username: string; finalScoreDifference: number; - percentageDifference: number; + percentageDifference: number | null; }; languageWinner?: { username: string; finalScoreDifference: number; - percentageDifference: number; + percentageDifference: number | null; selectedLanguages: string[]; }; } = { winner: { username: overallWinner.username, finalScoreDifference: Math.round(overallDifference), - percentageDifference: Math.round(overallPercentage), + percentageDifference: + overallPercentage === null ? null : Math.round(overallPercentage), }, }; @@ -132,15 +133,16 @@ function calculateWinner(users: ComparedUserResult[]): { const languageDifference = Math.abs( winnerLanguageScores.finalScore - loserLanguageScores.finalScore, ); - const languagePercentage = - loserLanguageScores.finalScore > 0 - ? (languageDifference / loserLanguageScores.finalScore) * 100 - : 0; + const languagePercentage = calculatePercentageDifference( + languageDifference, + loserLanguageScores.finalScore, + ); result.languageWinner = { username: languageWinner.username, finalScoreDifference: Math.round(languageDifference), - percentageDifference: Math.round(languagePercentage), + percentageDifference: + languagePercentage === null ? null : Math.round(languagePercentage), selectedLanguages: winnerLanguageScores.selectedLanguages, }; } @@ -148,6 +150,17 @@ function calculateWinner(users: ComparedUserResult[]): { return result; } +function calculatePercentageDifference( + difference: number, + baseline: number, +): number | null { + if (baseline <= 0) { + return difference > 0 ? null : 0; + } + + return (difference / baseline) * 100; +} + function createComparisonInsights( users: ComparedUserResult[], locale: Locale, diff --git a/components/home-page-client.tsx b/components/home-page-client.tsx index 7bcf2fb..59c5917 100644 --- a/components/home-page-client.tsx +++ b/components/home-page-client.tsx @@ -26,7 +26,7 @@ type ComparisonData = { languageWinner?: { username: string; finalScoreDifference: number; - percentageDifference: number; + percentageDifference: number | null; selectedLanguages: string[]; }; insights?: CompareInsights; diff --git a/components/result-dashboard.tsx b/components/result-dashboard.tsx index 937ff26..7a98f59 100644 --- a/components/result-dashboard.tsx +++ b/components/result-dashboard.tsx @@ -21,7 +21,7 @@ type Props = { languageWinner?: { username: string; finalScoreDifference: number; - percentageDifference: number; + percentageDifference: number | null; selectedLanguages: string[]; }; insights?: CompareInsights; @@ -42,6 +42,17 @@ function getGithubProfileUrl(username: string): string { return `https://github.com/${username}`; } +function getPercentageDifference( + winnerScore: number, + loserScore: number, +): number | null { + if (loserScore <= 0) { + return winnerScore > loserScore ? null : 0; + } + + return Math.round(((winnerScore - loserScore) / loserScore) * 100); +} + export function ResultDashboard({ user1, user2, @@ -88,13 +99,20 @@ export function ResultDashboard({ : winnerFromScores; const loserUser = overallWinnerUser === user1 ? user2 : overallWinnerUser === user2 ? user1 : null; - const winnerDiffPct = - overallWinnerUser && loserUser && loserUser.finalScore > 0 - ? Math.round( - ((overallWinnerUser.finalScore - loserUser.finalScore) / loserUser.finalScore) * - 100, - ) + const winnerDiffPct = winner + ? winner.percentageDifference + : overallWinnerUser && loserUser + ? getPercentageDifference(overallWinnerUser.finalScore, loserUser.finalScore) : 0; + const winnerDiffPoints = + winner?.finalScoreDifference ?? + (overallWinnerUser && loserUser + ? Math.round(Math.abs(overallWinnerUser.finalScore - loserUser.finalScore)) + : 0); + const winnerLeadValue = + winnerDiffPct === null + ? t("results.pointsLead", { points: winnerDiffPoints }) + : `${winnerDiffPct}%`; const handleCopy = async () => { try { @@ -210,7 +228,7 @@ export function ResultDashboard({ ); }; - const renderSignalStats = (user: UserResult) => { + const renderSignalStats = (user: UserResult, idx: number) => { if (!user.signals) return null; const signalEntries: Array<{ label: string; value: string | number }> = [ @@ -258,7 +276,7 @@ export function ResultDashboard({ ]; return ( - +

{t("banner.leadby")}

-

{winnerDiffPct}%

+

{winnerLeadValue}

) : ( @@ -377,7 +395,12 @@ export function ResultDashboard({ {t("language.focus")}: {languageWinner.selectedLanguages.join(", ")}

- {t("banner.leadby")} {languageWinner.percentageDifference}% + {t("banner.leadby")}{" "} + {languageWinner.percentageDifference === null + ? t("results.pointsLead", { + points: languageWinner.finalScoreDifference, + }) + : `${languageWinner.percentageDifference}%`}

{t("language.winnerNote")}

@@ -449,8 +472,8 @@ export function ResultDashboard({ {t("signals.title")}
- {renderSignalStats(user1)} - {renderSignalStats(user2)} + {renderSignalStats(user1, 1)} + {renderSignalStats(user2, 2)}
diff --git a/components/top-list.tsx b/components/top-list.tsx index 84952e1..1d5e35b 100644 --- a/components/top-list.tsx +++ b/components/top-list.tsx @@ -206,8 +206,8 @@ export function TopList({ userResults, selectedLanguages = [] }: Props) { return (
- {userResults.map((user) => ( - + {userResults.map((user, idx) => ( + {t("topwork.titleForUser", { username: user.name || user.username })} diff --git a/locales/ar.json b/locales/ar.json index 7312fda..9431ed5 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -170,6 +170,7 @@ "results.copied": "تم النسخ!", "results.copy": "نسخ النتيجة", "results.copyAria": "نسخ نتائج المقارنة إلى الحافظة", + "results.pointsLead": "+{points} نقطة", "score.rawLabel": "الدرجة الخام: {value}", "results.scoreVersion": "إصدار التقييم", "section.language": "النتيجة اللغوية", diff --git a/locales/en.json b/locales/en.json index 7edc97b..4a07471 100644 --- a/locales/en.json +++ b/locales/en.json @@ -170,6 +170,7 @@ "results.copied": "Copied!", "results.copy": "Copy Result", "results.copyAria": "Copy comparison results to clipboard", + "results.pointsLead": "+{points} points", "score.rawLabel": "Raw: {value}", "results.scoreVersion": "Score version", "section.language": "Language-focused Score", diff --git a/test/api/compare.route.test.ts b/test/api/compare.route.test.ts index 5314170..d3c0d1e 100644 --- a/test/api/compare.route.test.ts +++ b/test/api/compare.route.test.ts @@ -51,7 +51,26 @@ function makeUser(login: string, name: string) { }; } -function makeScore(finalScore: number) { +function makeLanguageScores(finalScore: number) { + return { + selectedLanguages: ["TypeScript"], + repoScore: 0, + prScore: 0, + contributionScore: 0, + finalScore, + normalizedRepoScore: 0, + normalizedPRScore: 0, + normalizedContributionScore: 0, + normalizedFinalScore: 0, + topRepos: [], + topPullRequests: [], + }; +} + +function makeScore( + finalScore: number, + languageScores?: ReturnType, +) { return { repoScore: 10, prScore: 20, @@ -64,7 +83,7 @@ function makeScore(finalScore: number) { topRepos: [], topPullRequests: [], topCommunityContributions: [], - languageScores: undefined, + languageScores, signals: { reposAnalyzed: 1, pullRequestsAnalyzed: 1, @@ -188,6 +207,77 @@ describe("GET /api/compare", () => { expect(body.winner?.username).toBe("user-a"); }); + test("returns null percentage when the losing final score is zero", async () => { + mocks.getUserData.mockResolvedValueOnce({ + data: makeUser("user-a", "User A"), + metrics: { duration: 0, errors: [] }, + }); + mocks.getUserData.mockResolvedValueOnce({ + data: makeUser("user-b", "User B"), + metrics: { duration: 0, errors: [] }, + }); + + mocks.calculateUserScore.mockReturnValueOnce(makeScore(20)); + mocks.calculateUserScore.mockReturnValueOnce(makeScore(0)); + + const response = await GET( + makeRequest({ + username: ["user-a", "user-b"], + }), + ); + const body = (await response.json()) as { + winner?: { + username: string; + finalScoreDifference: number; + percentageDifference: number | null; + }; + }; + + expect(response.status).toBe(200); + expect(body.winner).toEqual({ + username: "user-a", + finalScoreDifference: 20, + percentageDifference: null, + }); + }); + + test("returns null language winner percentage when language loser score is zero", async () => { + mocks.getUserData.mockResolvedValueOnce({ + data: makeUser("user-a", "User A"), + metrics: { duration: 0, errors: [] }, + }); + mocks.getUserData.mockResolvedValueOnce({ + data: makeUser("user-b", "User B"), + metrics: { duration: 0, errors: [] }, + }); + + mocks.calculateUserScore.mockReturnValueOnce(makeScore(20, makeLanguageScores(12))); + mocks.calculateUserScore.mockReturnValueOnce(makeScore(10, makeLanguageScores(0))); + + const response = await GET( + makeRequest({ + username: ["user-a", "user-b"], + selectedLanguage: "TypeScript", + }), + ); + const body = (await response.json()) as { + languageWinner?: { + username: string; + finalScoreDifference: number; + percentageDifference: number | null; + selectedLanguages: string[]; + }; + }; + + expect(response.status).toBe(200); + expect(body.languageWinner).toEqual({ + username: "user-a", + finalScoreDifference: 12, + percentageDifference: null, + selectedLanguages: ["TypeScript"], + }); + }); + test("returns targeted username for not-found errors", async () => { mocks.getUserData.mockRejectedValueOnce(new Error("User not found")); diff --git a/types/api-response.ts b/types/api-response.ts index 2b5fcaa..56f1c87 100644 --- a/types/api-response.ts +++ b/types/api-response.ts @@ -3,7 +3,7 @@ import { UserResult } from "./user-result"; export type CompareWinner = { username: string; finalScoreDifference: number; - percentageDifference: number; + percentageDifference: number | null; }; export type SafeApiError = { @@ -48,7 +48,7 @@ export type ApiResponse = { languageWinner?: { username: string; finalScoreDifference: number; - percentageDifference: number; + percentageDifference: number | null; selectedLanguages: string[]; }; insights?: CompareInsights;