diff --git a/src/app/api/compare/route.ts b/src/app/api/compare/route.ts index b1c11cd..6615dbb 100644 --- a/src/app/api/compare/route.ts +++ b/src/app/api/compare/route.ts @@ -1,43 +1,14 @@ import { NextResponse } from "next/server"; import { - CompareUserFetchError, calculateWinner, compareUsers, createComparisonInsights, - parseSelectedLanguagesFromSearchParams, resolveLocale, } from "@/features/comparison/services"; -import { toSafeApiError } from "@/lib/github"; -import type { ClientSafeError, SafeApiError } from "@/types/api"; +import { formatApiErrorResponse, parseSelectedLanguagesFromSearchParams } from "@/lib/api"; export const runtime = "nodejs"; -function toApiErrorStatus(code: ReturnType["code"]): number { - switch (code) { - case "RATE_LIMITED": - case "TEMPORARY_THROTTLE": - return 429; - case "GITHUB_TIMEOUT": - case "GITHUB_RESOURCE_LIMIT": - case "GITHUB_AUTH": - return code === "GITHUB_AUTH" ? 401 : 503; - case "GITHUB_NOT_FOUND": - return 404; - case "NETWORK": - return 503; - default: - return 500; - } -} - -function toClientSafeError(error: SafeApiError): ClientSafeError { - return { - code: error.code, - message: error.message, - targetUsernames: error.targetUsernames, - }; -} - export async function GET(request: Request) { const { searchParams } = new URL(request.url); const usernames = searchParams @@ -61,40 +32,6 @@ export async function GET(request: Request) { return NextResponse.json({ success: true, users, ...winnerData, insights }); } catch (error: unknown) { console.error("GitHub score error:", error); - - let safeError: SafeApiError; - - if (error instanceof CompareUserFetchError) { - const mappedCause = toSafeApiError(error.causeError); - if ( - mappedCause.code === "GITHUB_NOT_FOUND" || - (error.causeError instanceof Error && error.causeError.message === "User not found") - ) { - safeError = { - code: "GITHUB_NOT_FOUND", - message: "GitHub user not found", - targetUsernames: [error.username], - rateLimit: mappedCause.rateLimit, - }; - } else { - safeError = mappedCause; - } - } else { - safeError = - error instanceof Error && error.message === "User not found" - ? { code: "GITHUB_NOT_FOUND", message: "GitHub user not found" } - : toSafeApiError(error); - } - - const clientSafeError = toClientSafeError(safeError); - - return NextResponse.json( - { - success: false, - error: clientSafeError.message, - errorDetails: clientSafeError, - }, - { status: toApiErrorStatus(safeError.code) }, - ); + return formatApiErrorResponse(error); } } diff --git a/src/app/api/user/[username]/route.ts b/src/app/api/user/[username]/route.ts index 888dc6d..4a04a84 100644 --- a/src/app/api/user/[username]/route.ts +++ b/src/app/api/user/[username]/route.ts @@ -1,51 +1,9 @@ import { NextResponse } from "next/server"; -import { getUserProfile, UserFetchError } from "@/features/developer/services"; -import { normalizeSelectedLanguages } from "@/features/scoring"; -import { toSafeApiError } from "@/lib/github"; -import type { SafeApiError } from "@/types/api"; +import { getUserProfile } from "@/features/developer/services"; +import { formatApiErrorResponse, parseSelectedLanguagesFromSearchParams } from "@/lib/api"; export const runtime = "nodejs"; -type ClientSafeError = Pick; - -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 toClientSafeError(error: SafeApiError): ClientSafeError { - return { - code: error.code, - message: error.message, - targetUsernames: error.targetUsernames, - }; -} - -function toApiErrorStatus(code: ReturnType["code"]): number { - switch (code) { - case "RATE_LIMITED": - case "TEMPORARY_THROTTLE": - return 429; - case "GITHUB_TIMEOUT": - case "GITHUB_RESOURCE_LIMIT": - case "GITHUB_AUTH": - return code === "GITHUB_AUTH" ? 401 : 503; - case "GITHUB_NOT_FOUND": - return 404; - case "NETWORK": - return 503; - case "UNKNOWN": - default: - return 500; - } -} - export async function GET(request: Request, { params }: { params: Promise<{ username: string }> }) { const { username } = await params; const trimmed = username?.trim(); @@ -65,40 +23,6 @@ export async function GET(request: Request, { params }: { params: Promise<{ user return NextResponse.json({ success: true, user, location }); } catch (error: unknown) { console.error("User profile fetch error:", error); - - let safeError: SafeApiError; - - if (error instanceof UserFetchError) { - const mappedCause = toSafeApiError(error.causeError); - if ( - mappedCause.code === "GITHUB_NOT_FOUND" || - (error.causeError instanceof Error && error.causeError.message === "User not found") - ) { - safeError = { - code: "GITHUB_NOT_FOUND", - message: "GitHub user not found", - targetUsernames: [error.username], - rateLimit: mappedCause.rateLimit, - }; - } else { - safeError = mappedCause; - } - } else { - safeError = - error instanceof Error && error.message === "User not found" - ? { code: "GITHUB_NOT_FOUND", message: "GitHub user not found" } - : toSafeApiError(error); - } - - const clientSafeError = toClientSafeError(safeError); - - return NextResponse.json( - { - success: false, - error: clientSafeError.message, - errorDetails: clientSafeError, - }, - { status: toApiErrorStatus(safeError.code) }, - ); + return formatApiErrorResponse(error); } } diff --git a/src/app/user/[username]/page.tsx b/src/app/user/[username]/page.tsx index b78f5b0..a371828 100644 --- a/src/app/user/[username]/page.tsx +++ b/src/app/user/[username]/page.tsx @@ -1,3 +1,4 @@ +import { cache } from "react"; import type { Metadata } from "next"; import { JsonLd } from "@/components/seo/json-ld"; import { UserProfileClient, UserNotFoundCard } from "@/features/developer"; @@ -9,6 +10,10 @@ import { toAbsoluteUrl } from "@/lib/seo"; import countriesData from "@/data/countries.json"; import { detectCountry } from "@/lib/geo"; +const getCachedUserProfile = cache(async (username: string) => { + return getUserProfile(username); +}); + type CountryInfo = { slug: string; title: string; @@ -27,7 +32,7 @@ export async function generateMetadata({ params }: Props): Promise { let displayName = cleanUsername; try { - const { user } = await getUserProfile(cleanUsername); + const { user } = await getCachedUserProfile(cleanUsername); displayName = user.name?.trim() || cleanUsername; } catch { // Fallback if user cannot be fetched during metadata generation @@ -96,7 +101,7 @@ export default async function UserProfilePage({ params, searchParams }: Props) { let fetchErrorMessage: string | null = null; try { - profileData = await getUserProfile(cleanUsername); + profileData = await getCachedUserProfile(cleanUsername); } catch (err: unknown) { fetchErrorMessage = err instanceof Error ? err.message : "Failed to load user profile"; } diff --git a/src/features/comparison/components/home-page-client.tsx b/src/features/comparison/components/home-page-client.tsx index 554bdd6..ae04cad 100644 --- a/src/features/comparison/components/home-page-client.tsx +++ b/src/features/comparison/components/home-page-client.tsx @@ -1,455 +1,40 @@ "use client"; -import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useMemo } from "react"; import Image from "next/image"; import { CompareForm } from "./compare-form"; import { ResultDashboard } from "./result-dashboard"; import { DashboardSkeleton } from "@/components/layout/skeletons"; -import type { UserResult } from "@/features/developer"; import { BrandLogo } from "@/components/layout/brand-logo"; import { AppHeader } from "@/components/layout/app-header"; import { AppFooter } from "@/components/layout/app-footer"; import { useTranslation } from "@/components/providers/language-provider"; -import type { SafeApiError } from "@/types/api"; -import type { CompareInsights, CompareWinner, ComparisonResponse } from "../types"; import { cn } from "@/utils/cn"; -import { - createComparisonQuery, - createComparisonRequest, - isComparisonFetchDuplicate, - reconcileComparisonData, - sanitizeSelectedLanguages, -} from "../services/compare-request"; - -type ComparisonData = { - user1: UserResult; - user2: UserResult; - winner?: CompareWinner; - languageWinner?: { - username: string; - finalScoreDifference: number; - percentageDifference: number | null; - selectedLanguages: string[]; - }; - insights?: CompareInsights; - scoreVersion?: string; -}; - -type CompareOptions = { - selectedLanguages: string[]; - updateUrl?: boolean; -}; - -type UsernameErrors = { - username1: string | null; - username2: string | null; -}; - -const EXIT_ANIMATION_MS = 240; - -function normalizeUsers(body: ComparisonResponse): { user1: UserResult; user2: UserResult } | null { - if (body.users && body.users.length >= 2) { - return { user1: body.users[0], user2: body.users[1] }; - } - - return null; -} - -function parseUsernamesFromSearchParams(searchParams: { - getAll: (name: string) => string[]; - get: (name: string) => string | null; -}): [string, string] { - const repeated = searchParams - .getAll("username") - .map((u) => u.trim()) - .filter(Boolean); - const u1 = - repeated[0] || searchParams.get("username1")?.trim() || searchParams.get("user1")?.trim() || ""; - const u2 = - repeated[1] || searchParams.get("username2")?.trim() || searchParams.get("user2")?.trim() || ""; - return [u1, u2]; -} +import { useComparisonController } from "../hooks"; export function HomePageClient() { const { t } = useTranslation(); - const router = useRouter(); - const searchParams = useSearchParams(); - const [initialUsername1, initialUsername2] = parseUsernamesFromSearchParams(searchParams); - const initialSelectedLanguages = sanitizeSelectedLanguages( - searchParams.getAll("selectedLanguage"), - ); - const [loading, setLoading] = useState(false); - const [generalError, setGeneralError] = useState(null); - const [usernameErrors, setUsernameErrors] = useState({ - username1: null, - username2: null, - }); - const [username1, setUsername1] = useState(initialUsername1); - const [username2, setUsername2] = useState(initialUsername2); - const [selectedLanguages, setSelectedLanguages] = useState(initialSelectedLanguages); - const [data, setData] = useState(null); - const [displayData, setDisplayData] = useState(null); - const [disableDuplicateFetch, setDisableDuplicateFetch] = useState(false); - const lastFetchedKeyRef = useRef(null); - const inFlightFetchKeyRef = useRef(null); - const inFlightPromiseRef = useRef | null>(null); - const latestRequestRef = useRef( - createComparisonRequest(initialUsername1, initialUsername2, initialSelectedLanguages), - ); - const hideTimerRef = useRef(null); - - const localizeErrorMessage = (message?: string, details?: SafeApiError) => { - if (details) { - switch (details.code) { - case "RATE_LIMITED": - return t("error.rateLimited", { - seconds: details.retryAfterSeconds ?? 60, - }); - case "TEMPORARY_THROTTLE": - return t("error.tempThrottle", { - seconds: details.retryAfterSeconds ?? 60, - }); - case "GITHUB_TIMEOUT": - return t("error.timeout"); - case "GITHUB_RESOURCE_LIMIT": - return t("error.resourceLimit"); - case "GITHUB_AUTH": - return t("error.missingToken"); - case "GITHUB_NOT_FOUND": - return t("error.userNotFound"); - case "NETWORK": - return t("error.fetchFailed"); - default: - break; - } - } - - switch (message) { - case "provide exactly two username params": - return t("error.missingUsername"); - case "GitHub user not found": - return t("error.userNotFound"); - case "Failed to calculate score": - return t("error.calculateFailed"); - case "Comparison failed": - return t("error.comparisonFailed"); - case "Failed to fetch": - return t("error.fetchFailed"); - case "Missing GITHUB_TOKEN": - return t("error.missingToken"); - default: - return t("error.generic"); - } - }; - - const createNotFoundFieldMessage = (username: string): string => { - const localizedPrefix = t("error.userNotFound"); - return `${localizedPrefix}: ${username}`; - }; - - const resetErrors = () => { - setGeneralError(null); - setUsernameErrors({ - username1: null, - username2: null, - }); - }; - - const applyApiError = (requestUser1: string, requestUser2: string, body: ComparisonResponse) => { - const details = body.errorDetails; - const localizedMessage = localizeErrorMessage(body.error, details); - - if (details?.code === "GITHUB_NOT_FOUND" && details.targetUsernames?.length) { - const requestedUsernames = [ - { - key: "username1" as const, - value: requestUser1, - }, - { - key: "username2" as const, - value: requestUser2, - }, - ]; - - const nextErrors: UsernameErrors = { username1: null, username2: null }; - - for (const targetUsername of details.targetUsernames) { - const normalizedTarget = targetUsername.trim().toLowerCase(); - const match = requestedUsernames.find( - (entry) => entry.value.trim().toLowerCase() === normalizedTarget, - ); - - if (match) { - nextErrors[match.key] = createNotFoundFieldMessage(match.value); - } - } - - if (nextErrors.username1 || nextErrors.username2) { - setUsernameErrors(nextErrors); - setGeneralError(null); - return; - } - } - - setUsernameErrors({ - username1: null, - username2: null, - }); - setGeneralError(localizedMessage); - }; - - const handleCompare = async (u1: string, u2: string, options: CompareOptions) => { - const request = createComparisonRequest(u1, u2, options.selectedLanguages); - latestRequestRef.current = request; - const fetchKey = request.fetchKey; - - if (inFlightFetchKeyRef.current === fetchKey && inFlightPromiseRef.current) { - return inFlightPromiseRef.current; - } - - // If we've already fetched this exact comparison and have the data, skip. - if (lastFetchedKeyRef.current === fetchKey && data) { - const reconciled = reconcileComparisonData(data, fetchKey, request); - if (reconciled) { - setData(reconciled); - setDisplayData(reconciled); - } - return Promise.resolve(); - } - - lastFetchedKeyRef.current = fetchKey; - - // update duplicate fetch state for current form values - const currentFetchKey = createComparisonRequest( - username1, - username2, - selectedLanguages, - ).fetchKey; - setDisableDuplicateFetch( - isComparisonFetchDuplicate( - currentFetchKey, - lastFetchedKeyRef.current, - inFlightFetchKeyRef.current, - Boolean(data), - ), - ); - - const requestPromise = (async () => { - if (options.updateUrl !== false) { - router.push(`/?${createComparisonQuery(request)}`, { scroll: false }); - } - - setLoading(true); - resetErrors(); - - try { - const res = await fetch(`/api/compare?${createComparisonQuery(request)}`); - - const body: ComparisonResponse = await res.json(); - if (!res.ok) { - if (latestRequestRef.current.fetchKey !== fetchKey) { - return; - } - setData(null); - applyApiError(latestRequestRef.current.user1, latestRequestRef.current.user2, body); - return; - } - const users = normalizeUsers(body); - - if (!body.success || !users) { - if (latestRequestRef.current.fetchKey !== fetchKey) return; - setData(null); - applyApiError(latestRequestRef.current.user1, latestRequestRef.current.user2, body); - return; - } - - const winnerUsername = - body.winner?.username ?? - (users.user1.finalScore > users.user2.finalScore - ? users.user1.username - : users.user2.finalScore > users.user1.finalScore - ? users.user2.username - : undefined); - - const nextData: ComparisonData = { - user1: { ...users.user1, isWinner: winnerUsername === users.user1.username }, - user2: { ...users.user2, isWinner: winnerUsername === users.user2.username }, - winner: body.winner, - languageWinner: body.languageWinner, - insights: body.insights, - scoreVersion: body.scoreVersion, - }; - - const reconciled = reconcileComparisonData(nextData, fetchKey, latestRequestRef.current); - if (!reconciled) { - if (latestRequestRef.current.fetchKey === fetchKey) { - setData(null); - setGeneralError(t("error.generic")); - } - return; - } - - setData(reconciled); - setDisplayData(reconciled); - } catch (err: unknown) { - if (latestRequestRef.current.fetchKey !== fetchKey) { - return; - } - setData(null); - setUsernameErrors({ - username1: null, - username2: null, - }); - setGeneralError(localizeErrorMessage(err instanceof Error ? err.message : undefined)); - } finally { - if (inFlightFetchKeyRef.current === fetchKey) { - inFlightFetchKeyRef.current = null; - inFlightPromiseRef.current = null; - setLoading(false); - } - } - })(); - - inFlightFetchKeyRef.current = fetchKey; - inFlightPromiseRef.current = requestPromise; - - // mark duplicate fetch disabled while request is in-flight - setDisableDuplicateFetch( - isComparisonFetchDuplicate( - currentFetchKey, - lastFetchedKeyRef.current, - inFlightFetchKeyRef.current, - Boolean(data), - ), - ); - - return requestPromise; - }; - - const syncToUrl = useEffectEvent((u1: string, u2: string, languages: string[]) => { - setUsername1(u1); - setUsername2(u2); - setSelectedLanguages(languages); - - if (!u1 || !u2) { - latestRequestRef.current = createComparisonRequest(u1, u2, languages); - lastFetchedKeyRef.current = null; - setData(null); - resetErrors(); - setDisableDuplicateFetch(false); - return; - } - - void handleCompare(u1, u2, { - selectedLanguages: languages, - updateUrl: false, - }); - }); - - useEffect(() => { - const [u1, u2] = parseUsernamesFromSearchParams(searchParams); - const urlLanguages = sanitizeSelectedLanguages(searchParams.getAll("selectedLanguage")); - queueMicrotask(() => { - syncToUrl(u1, u2, urlLanguages); - }); - }, [searchParams]); - - useEffect(() => { - if (hideTimerRef.current !== null) { - window.clearTimeout(hideTimerRef.current); - hideTimerRef.current = null; - } - - if (data) { - return; - } - - if (loading || !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 { + username1, + username2, + selectedLanguages, + handleUsername1Change, + handleUsername2Change, + setSelectedLanguages, + handleCompare, + reset, + swapUsers, + loading, + data, + displayData, + disableDuplicateFetch, + isRefreshing, + isExiting, + generalError, + usernameErrors, + } = useComparisonController(); const skeleton = useMemo(() => , []); - const isRefreshing = loading && Boolean(displayData); - const isExiting = !loading && !data && Boolean(displayData); - - useEffect(() => { - const currentFetchKey = createComparisonRequest( - username1, - username2, - selectedLanguages, - ).fetchKey; - - const lastKey = lastFetchedKeyRef.current; - const inFlightKey = inFlightFetchKeyRef.current; - - const disabled = isComparisonFetchDuplicate( - currentFetchKey, - lastKey, - inFlightKey, - Boolean(data), - ); - setDisableDuplicateFetch(disabled); - }, [username1, username2, selectedLanguages, data, loading]); - - const handleUsername1Change = (value: string) => { - setUsername1(value); - if (usernameErrors.username1) { - setUsernameErrors((current) => ({ ...current, username1: null })); - } - }; - - const handleUsername2Change = (value: string) => { - setUsername2(value); - if (usernameErrors.username2) { - setUsernameErrors((current) => ({ ...current, username2: null })); - } - }; - - const reset = () => { - setLoading(false); - setData(null); - resetErrors(); - inFlightFetchKeyRef.current = null; - inFlightPromiseRef.current = null; - latestRequestRef.current = createComparisonRequest("", "", []); - setDisableDuplicateFetch(false); - setUsername1(""); - setUsername2(""); - setSelectedLanguages([]); - router.push("/", { scroll: false }); - }; - - const swapUsers = () => { - const nextUsername1 = username2; - const nextUsername2 = username1; - const nextRequest = createComparisonRequest(nextUsername1, nextUsername2, selectedLanguages); - latestRequestRef.current = nextRequest; - - setUsername1(nextUsername1); - setUsername2(nextUsername2); - router.push(`/?${createComparisonQuery(nextRequest)}`, { scroll: false }); - - setData((current) => - current ? reconcileComparisonData(current, nextRequest.fetchKey, nextRequest) : current, - ); - setDisplayData((current) => - current ? reconcileComparisonData(current, nextRequest.fetchKey, nextRequest) : current, - ); - }; return (
diff --git a/src/features/comparison/components/result-dashboard.tsx b/src/features/comparison/components/result-dashboard.tsx index 4d8e05d..7572dac 100644 --- a/src/features/comparison/components/result-dashboard.tsx +++ b/src/features/comparison/components/result-dashboard.tsx @@ -1,10 +1,11 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import Link from "next/link"; import type { Route } from "next"; import { Check, Copy, ExternalLink, Trophy } from "lucide-react"; import { useSearchParams } from "next/navigation"; +import { useClipboardCopy } from "@/hooks"; import { Avatar } from "@/components/layout/avatar"; import { ComparisonChart } from "./comparison-chart"; import { TopList } from "./top-list"; @@ -61,7 +62,7 @@ export function ResultDashboard({ }: Props) { const { t } = useTranslation(); const searchParams = useSearchParams(); - const [copied, setCopied] = useState(false); + const { copied, copy } = useClipboardCopy(); const methodologyHref = useMemo(() => { const query = searchParams.toString(); return query ? `/scoring-methodology?${query}` : "/scoring-methodology"; @@ -109,27 +110,21 @@ export function ResultDashboard({ ? t("results.pointsLead", { points: winnerDiffPoints }) : `${winnerDiffPct}%`; - const handleCopy = async () => { - try { - await navigator.clipboard.writeText( - JSON.stringify( - { - user1, - user2, - winner, - languageWinner, - insights, - scoreVersion, - }, - null, - 2, - ), - ); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - setCopied(false); - } + const handleCopy = () => { + copy( + JSON.stringify( + { + user1, + user2, + winner, + languageWinner, + insights, + scoreVersion, + }, + null, + 2, + ), + ); }; const renderScoreGroup = (user: UserResult) => { diff --git a/src/features/comparison/hooks/index.ts b/src/features/comparison/hooks/index.ts new file mode 100644 index 0000000..ced7e6e --- /dev/null +++ b/src/features/comparison/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-comparison-controller"; diff --git a/src/features/comparison/hooks/use-comparison-controller.ts b/src/features/comparison/hooks/use-comparison-controller.ts new file mode 100644 index 0000000..f265a5a --- /dev/null +++ b/src/features/comparison/hooks/use-comparison-controller.ts @@ -0,0 +1,468 @@ +"use client"; + +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslation } from "@/components/providers/language-provider"; +import type { UserResult } from "@/features/developer"; +import type { SafeApiError } from "@/types/api"; +import type { CompareInsights, CompareWinner, ComparisonResponse } from "../types"; +import { + createComparisonQuery, + createComparisonRequest, + isComparisonFetchDuplicate, + reconcileComparisonData, + sanitizeSelectedLanguages, +} from "../services/compare-request"; + +export type ComparisonData = { + user1: UserResult; + user2: UserResult; + winner?: CompareWinner; + languageWinner?: { + username: string; + finalScoreDifference: number; + percentageDifference: number | null; + selectedLanguages: string[]; + }; + insights?: CompareInsights; + scoreVersion?: string; +}; + +export type CompareOptions = { + selectedLanguages: string[]; + updateUrl?: boolean; +}; + +export type UsernameErrors = { + username1: string | null; + username2: string | null; +}; + +const EXIT_ANIMATION_MS = 240; + +function normalizeUsers(body: ComparisonResponse): { user1: UserResult; user2: UserResult } | null { + if (body.users && body.users.length >= 2) { + return { user1: body.users[0], user2: body.users[1] }; + } + + return null; +} + +function parseUsernamesFromSearchParams(searchParams: { + getAll: (name: string) => string[]; + get: (name: string) => string | null; +}): [string, string] { + const repeated = searchParams + .getAll("username") + .map((u) => u.trim()) + .filter(Boolean); + const u1 = + repeated[0] || searchParams.get("username1")?.trim() || searchParams.get("user1")?.trim() || ""; + const u2 = + repeated[1] || searchParams.get("username2")?.trim() || searchParams.get("user2")?.trim() || ""; + return [u1, u2]; +} + +export function useComparisonController() { + const { t } = useTranslation(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const [initialUsername1, initialUsername2] = parseUsernamesFromSearchParams(searchParams); + const initialSelectedLanguages = sanitizeSelectedLanguages( + searchParams.getAll("selectedLanguage"), + ); + + const [loading, setLoading] = useState(false); + const [generalError, setGeneralError] = useState(null); + const [usernameErrors, setUsernameErrors] = useState({ + username1: null, + username2: null, + }); + + const [username1, setUsername1] = useState(initialUsername1); + const [username2, setUsername2] = useState(initialUsername2); + const [selectedLanguages, setSelectedLanguages] = useState(initialSelectedLanguages); + const [data, setData] = useState(null); + const [displayData, setDisplayData] = useState(null); + const [disableDuplicateFetch, setDisableDuplicateFetch] = useState(false); + + const lastFetchedKeyRef = useRef(null); + const inFlightFetchKeyRef = useRef(null); + const inFlightPromiseRef = useRef | null>(null); + const latestRequestRef = useRef( + createComparisonRequest(initialUsername1, initialUsername2, initialSelectedLanguages), + ); + const hideTimerRef = useRef(null); + + const localizeErrorMessage = (message?: string, details?: SafeApiError) => { + if (details) { + switch (details.code) { + case "RATE_LIMITED": + return t("error.rateLimited", { + seconds: details.retryAfterSeconds ?? 60, + }); + case "TEMPORARY_THROTTLE": + return t("error.tempThrottle", { + seconds: details.retryAfterSeconds ?? 60, + }); + case "GITHUB_TIMEOUT": + return t("error.timeout"); + case "GITHUB_RESOURCE_LIMIT": + return t("error.resourceLimit"); + case "GITHUB_AUTH": + return t("error.missingToken"); + case "GITHUB_NOT_FOUND": + return t("error.userNotFound"); + case "NETWORK": + return t("error.fetchFailed"); + default: + break; + } + } + + switch (message) { + case "provide exactly two username params": + return t("error.missingUsername"); + case "GitHub user not found": + return t("error.userNotFound"); + case "Failed to calculate score": + return t("error.calculateFailed"); + case "Comparison failed": + return t("error.comparisonFailed"); + case "Failed to fetch": + return t("error.fetchFailed"); + case "Missing GITHUB_TOKEN": + return t("error.missingToken"); + default: + return t("error.generic"); + } + }; + + const createNotFoundFieldMessage = (username: string): string => { + const localizedPrefix = t("error.userNotFound"); + return `${localizedPrefix}: ${username}`; + }; + + const resetErrors = () => { + setGeneralError(null); + setUsernameErrors({ + username1: null, + username2: null, + }); + }; + + const applyApiError = (requestUser1: string, requestUser2: string, body: ComparisonResponse) => { + const details = body.errorDetails; + const localizedMessage = localizeErrorMessage(body.error, details); + + if (details?.code === "GITHUB_NOT_FOUND" && details.targetUsernames?.length) { + const requestedUsernames = [ + { + key: "username1" as const, + value: requestUser1, + }, + { + key: "username2" as const, + value: requestUser2, + }, + ]; + + const nextErrors: UsernameErrors = { username1: null, username2: null }; + + for (const targetUsername of details.targetUsernames) { + const normalizedTarget = targetUsername.trim().toLowerCase(); + const match = requestedUsernames.find( + (entry) => entry.value.trim().toLowerCase() === normalizedTarget, + ); + + if (match) { + nextErrors[match.key] = createNotFoundFieldMessage(match.value); + } + } + + if (nextErrors.username1 || nextErrors.username2) { + setUsernameErrors(nextErrors); + setGeneralError(null); + return; + } + } + + setUsernameErrors({ + username1: null, + username2: null, + }); + setGeneralError(localizedMessage); + }; + + const handleCompare = async (u1: string, u2: string, options: CompareOptions) => { + const request = createComparisonRequest(u1, u2, options.selectedLanguages); + latestRequestRef.current = request; + const fetchKey = request.fetchKey; + + if (inFlightFetchKeyRef.current === fetchKey && inFlightPromiseRef.current) { + return inFlightPromiseRef.current; + } + + // If we've already fetched this exact comparison and have the data, skip. + if (lastFetchedKeyRef.current === fetchKey && data) { + const reconciled = reconcileComparisonData(data, fetchKey, request); + if (reconciled) { + setData(reconciled); + setDisplayData(reconciled); + } + return Promise.resolve(); + } + + lastFetchedKeyRef.current = fetchKey; + + // update duplicate fetch state for current form values + const currentFetchKey = createComparisonRequest( + username1, + username2, + selectedLanguages, + ).fetchKey; + setDisableDuplicateFetch( + isComparisonFetchDuplicate( + currentFetchKey, + lastFetchedKeyRef.current, + inFlightFetchKeyRef.current, + Boolean(data), + ), + ); + + const requestPromise = (async () => { + if (options.updateUrl !== false) { + router.push(`/?${createComparisonQuery(request)}`, { scroll: false }); + } + + setLoading(true); + resetErrors(); + + try { + const res = await fetch(`/api/compare?${createComparisonQuery(request)}`); + + const body: ComparisonResponse = await res.json(); + if (!res.ok) { + if (latestRequestRef.current.fetchKey !== fetchKey) { + return; + } + setData(null); + applyApiError(latestRequestRef.current.user1, latestRequestRef.current.user2, body); + return; + } + const users = normalizeUsers(body); + + if (!body.success || !users) { + if (latestRequestRef.current.fetchKey !== fetchKey) return; + setData(null); + applyApiError(latestRequestRef.current.user1, latestRequestRef.current.user2, body); + return; + } + + const winnerUsername = + body.winner?.username ?? + (users.user1.finalScore > users.user2.finalScore + ? users.user1.username + : users.user2.finalScore > users.user1.finalScore + ? users.user2.username + : undefined); + + const nextData: ComparisonData = { + user1: { ...users.user1, isWinner: winnerUsername === users.user1.username }, + user2: { ...users.user2, isWinner: winnerUsername === users.user2.username }, + winner: body.winner, + languageWinner: body.languageWinner, + insights: body.insights, + scoreVersion: body.scoreVersion, + }; + + const reconciled = reconcileComparisonData(nextData, fetchKey, latestRequestRef.current); + if (!reconciled) { + if (latestRequestRef.current.fetchKey === fetchKey) { + setData(null); + setGeneralError(t("error.generic")); + } + return; + } + + setData(reconciled); + setDisplayData(reconciled); + } catch (err: unknown) { + if (latestRequestRef.current.fetchKey !== fetchKey) { + return; + } + setData(null); + setUsernameErrors({ + username1: null, + username2: null, + }); + setGeneralError(localizeErrorMessage(err instanceof Error ? err.message : undefined)); + } finally { + if (inFlightFetchKeyRef.current === fetchKey) { + inFlightFetchKeyRef.current = null; + inFlightPromiseRef.current = null; + setLoading(false); + } + } + })(); + + inFlightFetchKeyRef.current = fetchKey; + inFlightPromiseRef.current = requestPromise; + + // mark duplicate fetch disabled while request is in-flight + setDisableDuplicateFetch( + isComparisonFetchDuplicate( + currentFetchKey, + lastFetchedKeyRef.current, + inFlightFetchKeyRef.current, + Boolean(data), + ), + ); + + return requestPromise; + }; + + const syncToUrl = useEffectEvent((u1: string, u2: string, languages: string[]) => { + setUsername1(u1); + setUsername2(u2); + setSelectedLanguages(languages); + + if (!u1 || !u2) { + latestRequestRef.current = createComparisonRequest(u1, u2, languages); + lastFetchedKeyRef.current = null; + setData(null); + resetErrors(); + setDisableDuplicateFetch(false); + return; + } + + void handleCompare(u1, u2, { + selectedLanguages: languages, + updateUrl: false, + }); + }); + + useEffect(() => { + const [u1, u2] = parseUsernamesFromSearchParams(searchParams); + const urlLanguages = sanitizeSelectedLanguages(searchParams.getAll("selectedLanguage")); + queueMicrotask(() => { + syncToUrl(u1, u2, urlLanguages); + }); + }, [searchParams]); + + useEffect(() => { + if (hideTimerRef.current !== null) { + window.clearTimeout(hideTimerRef.current); + hideTimerRef.current = null; + } + + if (data) { + return; + } + + if (loading || !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 isRefreshing = loading && Boolean(displayData); + const isExiting = !loading && !data && Boolean(displayData); + + useEffect(() => { + const currentFetchKey = createComparisonRequest( + username1, + username2, + selectedLanguages, + ).fetchKey; + + const lastKey = lastFetchedKeyRef.current; + const inFlightKey = inFlightFetchKeyRef.current; + + const disabled = isComparisonFetchDuplicate( + currentFetchKey, + lastKey, + inFlightKey, + Boolean(data), + ); + setDisableDuplicateFetch(disabled); + }, [username1, username2, selectedLanguages, data, loading]); + + const handleUsername1Change = (value: string) => { + setUsername1(value); + if (usernameErrors.username1) { + setUsernameErrors((current) => ({ ...current, username1: null })); + } + }; + + const handleUsername2Change = (value: string) => { + setUsername2(value); + if (usernameErrors.username2) { + setUsernameErrors((current) => ({ ...current, username2: null })); + } + }; + + const reset = () => { + setLoading(false); + setData(null); + resetErrors(); + inFlightFetchKeyRef.current = null; + inFlightPromiseRef.current = null; + latestRequestRef.current = createComparisonRequest("", "", []); + setDisableDuplicateFetch(false); + setUsername1(""); + setUsername2(""); + setSelectedLanguages([]); + router.push("/", { scroll: false }); + }; + + const swapUsers = () => { + const nextUsername1 = username2; + const nextUsername2 = username1; + const nextRequest = createComparisonRequest(nextUsername1, nextUsername2, selectedLanguages); + latestRequestRef.current = nextRequest; + + setUsername1(nextUsername1); + setUsername2(nextUsername2); + router.push(`/?${createComparisonQuery(nextRequest)}`, { scroll: false }); + + setData((current) => + current ? reconcileComparisonData(current, nextRequest.fetchKey, nextRequest) : current, + ); + setDisplayData((current) => + current ? reconcileComparisonData(current, nextRequest.fetchKey, nextRequest) : current, + ); + }; + + return { + username1, + username2, + selectedLanguages, + handleUsername1Change, + handleUsername2Change, + setSelectedLanguages, + handleCompare, + reset, + swapUsers, + loading, + data, + displayData, + disableDuplicateFetch, + isRefreshing, + isExiting, + generalError, + usernameErrors, + }; +} diff --git a/src/features/comparison/index.ts b/src/features/comparison/index.ts index c50b2a0..5d92934 100644 --- a/src/features/comparison/index.ts +++ b/src/features/comparison/index.ts @@ -1,3 +1,4 @@ export * from "./types"; export * from "./components"; export * from "./services/compare-request"; +export * from "./hooks"; diff --git a/src/features/comparison/services/compare-service.ts b/src/features/comparison/services/compare-service.ts index e536e5f..22cb86a 100644 --- a/src/features/comparison/services/compare-service.ts +++ b/src/features/comparison/services/compare-service.ts @@ -1,8 +1,6 @@ import { getUserData } from "@/lib/github"; import { calculateUserScore, normalizeSelectedLanguages } from "@/features/scoring"; -import { getDatabaseStore } from "@/lib/db"; -import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; -import { detectCountry } from "@/lib/geo"; +import { persistUserScores } from "@/features/developer/services"; import { DEFAULT_LOCALE, LOCALE_COOKIE, @@ -259,88 +257,53 @@ export async function compareUsers( usernames: string[], selectedLanguages: string[], ): Promise { - const results: ComparedUserResult[] = []; + return Promise.all( + usernames.map(async (username) => { + let data: GitHubUserData; + try { + const { data: userData } = await getUserData(username, { + cacheInRedis: true, + withMetrics: true, + }); + data = userData; + } catch (error: unknown) { + throw new CompareUserFetchError(username, error); + } - for (const username of usernames) { - let data: Awaited; - try { - const { data: userData, metrics } = await getUserData(username, { - cacheInRedis: true, - withMetrics: true, - }); - data = userData; - console.log(metrics); - } catch (error: unknown) { - throw new CompareUserFetchError(username, error); - } + const score = calculateUserScore( + { + ...data, + selectedLanguages, + }, + username, + ); - const score = calculateUserScore( - { - ...data, + // Fire-and-forget: detect country & persist canonical scores into DB + void persistUserScores({ + data, + score, selectedLanguages, - }, - username, - ); - - results.push({ - username: data.login, - 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, - }); - - // ── Fire-and-forget: detect country & upsert into DB ────────────── - const country = detectCountry(data.location); - if (country && process.env.DATABASE_URL?.trim()) { - const staleDays = parseInt(process.env.GITHUB_USER_STALE_DAYS ?? "14", 10); - const dbScore = selectedLanguages.length > 0 ? calculateUserScore(data, data.login) : score; - - try { - const db = getDatabaseStore(); - db.upsertUser({ - username: data.login, - name: data.name, - avatarUrl: data.avatarUrl, - location: data.location, - country, - rawData: data, - scores: dbScore, - repoScore: Math.round(dbScore.repoScore), - prScore: Math.round(dbScore.prScore), - contributionScore: Math.round(dbScore.contributionScore), - finalScore: Math.round(dbScore.finalScore), - staleDays, - }) - .then(() => { - // Invalidate Redis cache for this country - const cacheConfig = getCacheConfigFromEnv(); - const cacheStore = createCacheStore(cacheConfig); - if (cacheStore.enabled && cacheStore.del) { - const key = `${cacheConfig.namespace}:leaderboard:${country.trim().toLowerCase()}`; - cacheStore.del(key).catch(() => {}); - } - }) - .catch((err: unknown) => { - console.warn("Failed to upsert user from compare:", err); - }); - } catch { - // Ignore DB connection errors in environments without DB - } - } - } + }); - return results; + return { + username: data.login, + 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, + }; + }), + ); } diff --git a/src/features/comparison/tests/compare-request.test.ts b/src/features/comparison/tests/compare-request.test.ts index da4f972..c9c2eeb 100644 --- a/src/features/comparison/tests/compare-request.test.ts +++ b/src/features/comparison/tests/compare-request.test.ts @@ -120,10 +120,15 @@ describe("comparison response reconciliation", () => { }); test("binds asynchronous completion to the latest presentation ref", () => { - const source = readFileSync( - resolve(process.cwd(), "src", "features", "comparison", "components", "home-page-client.tsx"), - "utf8", + const hookPath = resolve( + process.cwd(), + "src", + "features", + "comparison", + "hooks", + "use-comparison-controller.ts", ); + const source = readFileSync(hookPath, "utf8"); expect(source).toMatch( /reconcileComparisonData\(\s*nextData,\s*fetchKey,\s*latestRequestRef\.current/, diff --git a/src/features/developer/components/user-profile-client.tsx b/src/features/developer/components/user-profile-client.tsx index f6eded4..d351df4 100644 --- a/src/features/developer/components/user-profile-client.tsx +++ b/src/features/developer/components/user-profile-client.tsx @@ -1,9 +1,9 @@ "use client"; -import { useState } from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import type { Route } from "next"; +import { useClipboardCopy } from "@/hooks"; import { ArrowLeft, Check, @@ -48,7 +48,7 @@ type Props = { export function UserProfileClient({ user, location, countryParam }: Props) { const { t } = useTranslation(); const searchParams = useSearchParams(); - const [copied, setCopied] = useState(false); + const { copied, copy } = useClipboardCopy(); const displayName = user.name?.trim() || user.username; const githubUrl = `https://github.com/${user.username}`; @@ -75,15 +75,7 @@ export function UserProfileClient({ user, location, countryParam }: Props) { const flagSlug = activeCountryInfo?.slug || detectedSlug; const flagCode = flagSlug ? getCountryCode(flagSlug) : null; - const handleCopyLink = async () => { - try { - await navigator.clipboard.writeText(window.location.href); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - setCopied(false); - } - }; + const handleCopyLink = () => copy(window.location.href); // Signal stats entries for transparency const signalEntries = user.signals diff --git a/src/features/developer/services/index.ts b/src/features/developer/services/index.ts index a850ca1..3c1fdfc 100644 --- a/src/features/developer/services/index.ts +++ b/src/features/developer/services/index.ts @@ -1 +1,2 @@ export * from "./user-service"; +export * from "./user-persistence"; diff --git a/src/features/developer/services/user-persistence.ts b/src/features/developer/services/user-persistence.ts new file mode 100644 index 0000000..bc22579 --- /dev/null +++ b/src/features/developer/services/user-persistence.ts @@ -0,0 +1,59 @@ +import { calculateUserScore } from "@/features/scoring"; +import { getDatabaseStore } from "@/lib/db"; +import { detectCountry } from "@/lib/geo"; +import type { GitHubUserData } from "@/lib/github"; +import type { CalculateUserScoreResult } from "@/features/scoring/services"; + +export type PersistUserOptions = { + data: GitHubUserData; + score?: CalculateUserScoreResult; + selectedLanguages?: string[]; + explicitCountry?: string | null; + staleDays?: number; +}; + +/** + * Canonical service function to persist user score data to PostgreSQL. + * + * Ensures that if selectedLanguages is provided, canonical unfiltered score + * is always computed and persisted into the database without busting + * the country leaderboard cache. + */ +export async function persistUserScores({ + data, + score, + selectedLanguages = [], + explicitCountry, + staleDays, +}: PersistUserOptions): Promise { + if (!process.env.DATABASE_URL?.trim()) { + return; + } + + const country = explicitCountry !== undefined ? explicitCountry : detectCountry(data.location); + const resolvedStaleDays = staleDays ?? parseInt(process.env.GITHUB_USER_STALE_DAYS ?? "14", 10); + + // Compute canonical unfiltered score if languages were selected or score was omitted + const canonicalScore = + selectedLanguages.length > 0 || !score ? calculateUserScore(data, data.login) : score; + + try { + const db = getDatabaseStore(); + await db.upsertUser({ + username: data.login, + name: data.name, + avatarUrl: data.avatarUrl, + location: data.location, + country, + rawData: data, + scores: canonicalScore, + repoScore: Math.round(canonicalScore.repoScore), + prScore: Math.round(canonicalScore.prScore), + contributionScore: Math.round(canonicalScore.contributionScore), + finalScore: Math.round(canonicalScore.finalScore), + staleDays: resolvedStaleDays, + }); + } catch (err: unknown) { + console.warn(`Failed to persist user score for ${data.login}:`, err); + } +} diff --git a/src/features/developer/services/user-service.ts b/src/features/developer/services/user-service.ts index d265045..43caaed 100644 --- a/src/features/developer/services/user-service.ts +++ b/src/features/developer/services/user-service.ts @@ -1,8 +1,6 @@ import { getUserData } from "@/lib/github"; import { calculateUserScore } from "@/features/scoring"; -import { getDatabaseStore } from "@/lib/db"; -import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; -import { detectCountry } from "@/lib/geo"; +import { persistUserScores } from "./user-persistence"; import type { UserProfileResponse, UserResult } from "../types"; import type { GitHubUserData } from "@/lib/github"; @@ -67,44 +65,12 @@ export async function getUserProfile( scoreVersion: process.env.DEVIMPACT_VERSION || undefined, }; - // Fire-and-forget: detect country & upsert into DB if configured - const country = detectCountry(data.location); - if (country && process.env.DATABASE_URL?.trim()) { - const staleDays = parseInt(process.env.GITHUB_USER_STALE_DAYS ?? "14", 10); - const dbScore = - selectedLanguages.length > 0 ? calculateUserScore(data, normalizedUsername) : score; - - try { - const db = getDatabaseStore(); - db.upsertUser({ - username: data.login, - name: data.name, - avatarUrl: data.avatarUrl, - location: data.location, - country, - rawData: data, - scores: dbScore, - repoScore: Math.round(dbScore.repoScore), - prScore: Math.round(dbScore.prScore), - contributionScore: Math.round(dbScore.contributionScore), - finalScore: Math.round(dbScore.finalScore), - staleDays, - }) - .then(() => { - const cacheConfig = getCacheConfigFromEnv(); - const cacheStore = createCacheStore(cacheConfig); - if (cacheStore.enabled && cacheStore.del) { - const key = `${cacheConfig.namespace}:leaderboard:${country.trim().toLowerCase()}`; - cacheStore.del(key).catch(() => {}); - } - }) - .catch((err: unknown) => { - console.warn("Failed to upsert user from user profile:", err); - }); - } catch { - // Ignore in environments without DB - } - } + // Fire-and-forget: detect country & persist canonical scores into DB + void persistUserScores({ + data, + score, + selectedLanguages, + }); return { user, diff --git a/src/features/leaderboard/services/calculate-leaderboard.ts b/src/features/leaderboard/services/calculate-leaderboard.ts index 51c0510..d1a2aa4 100644 --- a/src/features/leaderboard/services/calculate-leaderboard.ts +++ b/src/features/leaderboard/services/calculate-leaderboard.ts @@ -1,9 +1,9 @@ import yaml from "js-yaml"; import { getUserData } from "@/lib/github"; import { calculateUserScore } from "@/features/scoring"; +import { persistUserScores } from "@/features/developer/services"; import { createCacheStore, getCacheConfigFromEnv } from "@/lib/cache"; import { getDatabaseStore, type DatabaseStore } from "@/lib/db"; -import { detectCountry } from "@/lib/geo"; import type { CalculateLeaderboardResponse, LeaderboardMeta, @@ -119,20 +119,10 @@ export async function seedNewUsers( }); fetchMetrics.push(metrics); const score = calculateUserScore(data, user.login); - const countryDetected = detectCountry(data.location); - - await db.upsertUser({ - username: data.login, - name: data.name, - avatarUrl: data.avatarUrl, - location: data.location, - country: countryDetected, - rawData: data, - scores: score, - repoScore: Math.round(score.repoScore), - prScore: Math.round(score.prScore), - contributionScore: Math.round(score.contributionScore), - finalScore: Math.round(score.finalScore), + + await persistUserScores({ + data, + score, staleDays, }); @@ -173,20 +163,10 @@ export async function refreshStaleUsers( }); fetchMetrics.push(metrics); const score = calculateUserScore(data, row.username); - const countryDetected = detectCountry(data.location); - - await db.upsertUser({ - username: data.login, - name: data.name, - avatarUrl: data.avatarUrl, - location: data.location, - country: countryDetected, - rawData: data, - scores: score, - repoScore: Math.round(score.repoScore), - prScore: Math.round(score.prScore), - contributionScore: Math.round(score.contributionScore), - finalScore: Math.round(score.finalScore), + + await persistUserScores({ + data, + score, staleDays, }); diff --git a/src/features/leaderboard/services/leaderboard-service.ts b/src/features/leaderboard/services/leaderboard-service.ts index 525c5e4..4c3645c 100644 --- a/src/features/leaderboard/services/leaderboard-service.ts +++ b/src/features/leaderboard/services/leaderboard-service.ts @@ -51,7 +51,6 @@ export async function getLeaderboardResult(country: string): Promise 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, + }; +} diff --git a/src/features/scoring/services/pr-scoring.ts b/src/features/scoring/services/pr-scoring.ts new file mode 100644 index 0000000..26ac4b3 --- /dev/null +++ b/src/features/scoring/services/pr-scoring.ts @@ -0,0 +1,173 @@ +import type { PullRequestNode } from "@/lib/github"; +import type { PullRequestScoreDetail } from "../types"; +import { getLanguageFactor, getLanguageMatch } from "./language-scoring"; +import { hasLanguageData } from "./repo-scoring"; +import { getDaysSince, getDiminishingWeight, safeLog, sanitizeNumber } from "./scoring-helpers"; + +export type PRScoreResult = { + total: number; + details: PullRequestScoreDetail[]; + mergedExternalPRs: number; + ownRepoPRsIgnored: number; + unmergedPRsIgnored: number; + uniqueExternalPRRepos: number; +}; + +export 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; +} + +export function calculatePRScore( + prs: PullRequestNode[], + 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 (!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; + 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 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; + allDetails.push(...repoScores); + } + + allDetails.sort((a, b) => b.score - a.score); + + return { + total: sanitizeNumber(total), + details: allDetails, + mergedExternalPRs, + ownRepoPRsIgnored, + unmergedPRsIgnored, + uniqueExternalPRRepos: grouped.size, + }; +} + +export 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), + }; +} diff --git a/src/features/scoring/services/repo-scoring.ts b/src/features/scoring/services/repo-scoring.ts new file mode 100644 index 0000000..e40081d --- /dev/null +++ b/src/features/scoring/services/repo-scoring.ts @@ -0,0 +1,106 @@ +import type { RepoNode } from "@/lib/github"; +import type { RepoScoreDetail } from "../types"; +import { getLanguageDistribution, getLanguageFactor, getLanguageMatch } from "./language-scoring"; +import { getDaysSince, getRepoRankWeight, safeLog, sanitizeNumber } from "./scoring-helpers"; + +export 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; +} + +export function calculateRepoScore( + 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); + + return { repo, score: sanitizeNumber(score) }; + }); + + details.sort((a, b) => b.score - a.score); + + const total = details.reduce((sum, { score }, index) => { + return sum + score * getRepoRankWeight(index); + }, 0); + + return { total: sanitizeNumber(total), details }; +} + +export function hasLanguageData(languages: RepoNode["languages"] | undefined): boolean { + return Object.keys(getLanguageDistribution(languages)).length > 0; +} + +export 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), + }; +} diff --git a/src/features/scoring/services/score-engine.ts b/src/features/scoring/services/score-engine.ts index 2961397..7742347 100644 --- a/src/features/scoring/services/score-engine.ts +++ b/src/features/scoring/services/score-engine.ts @@ -1,458 +1,30 @@ import type { DiscussionNode, IssueNode, PullRequestNode, RepoNode } from "@/lib/github"; -import type { - CommunityContributionDetail, - PullRequestScoreDetail, - RepoScoreDetail, - ScoringExplanations, - ScoringSignals, -} from "../types"; +import type { ScoringExplanations, ScoringSignals } from "../types"; +import { getTopLanguages, normalizeSelectedLanguages } from "./language-scoring"; import { - getLanguageDistribution, - getLanguageFactor, - getLanguageMatch, - getTopLanguages, - normalizeSelectedLanguages, -} from "./language-scoring"; - -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); -} - -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[], - 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); - - return { repo, score: sanitizeNumber(score) }; - }); - - details.sort((a, b) => b.score - a.score); - - const total = details.reduce((sum, { score }, index) => { - return sum + score * getRepoRankWeight(index); - }, 0); - - 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, - 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 (!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; - 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 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; - allDetails.push(...repoScores); - } - - allDetails.sort((a, b) => b.score - a.score); - - return { - total: sanitizeNumber(total), - details: allDetails, - mergedExternalPRs, - ownRepoPRsIgnored, - unmergedPRsIgnored, - uniqueExternalPRRepos: grouped.size, - }; -} - -function calculateCommunityItemScore(item: IssueNode | DiscussionNode): number { - const repoStars = Math.max(0, item.repository.stargazerCount); - const comments = Math.max(0, item.comments.totalCount); - let score = safeLog(repoStars) * safeLog(comments); - - if (comments === 0) { - score *= 0.2; - } - - return sanitizeNumber(score); -} - -type CommunityScoreResult = { - total: number; - details: CommunityContributionDetail[]; - issuesAnalyzed: number; - externalIssuesCounted: number; - discussionsAnalyzed: number; - externalDiscussionsCounted: number; -}; - -function calculateContributionScore( - 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 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), - }; -} - -type TopRepo = { + BASE_SCORING_EXPLANATIONS, + COMMUNITY_CAP_RATIO, + LANGUAGE_SCORING_EXPLANATIONS, + SCORE_NORMALIZATION_K, + SCORING_WEIGHTS, +} from "./scoring-constants"; +import { + normalizeScore, + resolveReferenceDate, + roundScore, + sanitizeNumber, +} from "./scoring-helpers"; +import { calculateLanguageRepoScore, calculateRepoScore } from "./repo-scoring"; +import { calculateLanguagePRScore, calculatePRScore } from "./pr-scoring"; +import { calculateContributionScore } from "./community-scoring"; + +export * from "./scoring-constants"; +export * from "./scoring-helpers"; +export * from "./repo-scoring"; +export * from "./pr-scoring"; +export * from "./community-scoring"; + +export type TopRepo = { name: string; url?: string; stars: number; @@ -465,7 +37,7 @@ type TopRepo = { }[]; }; -type TopPullRequest = { +export type TopPullRequest = { repo: string; title: string; url?: string; @@ -479,7 +51,7 @@ type TopPullRequest = { }[]; }; -type TopCommunityContribution = { +export type TopCommunityContribution = { type: "issue" | "discussion"; title: string; url?: string; @@ -489,7 +61,7 @@ type TopCommunityContribution = { score: number; }; -type TopLanguageRepo = TopRepo & { +export type TopLanguageRepo = TopRepo & { languageMatch: number; topLanguages: { name: string; @@ -497,7 +69,7 @@ type TopLanguageRepo = TopRepo & { }[]; }; -type TopLanguagePullRequest = TopPullRequest & { +export type TopLanguagePullRequest = TopPullRequest & { languageMatch: number; topLanguages: { name: string; @@ -505,7 +77,7 @@ type TopLanguagePullRequest = TopPullRequest & { }[]; }; -type LanguageScores = { +export type LanguageScores = { selectedLanguages: string[]; repoScore: number; prScore: number; @@ -547,29 +119,6 @@ export type CalculateUserScoreResult = { 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.", - "Issue and discussion impact is based on repository visibility and discussion activity.", - "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[]; @@ -599,16 +148,27 @@ export function calculateUserScore( ); let contributionScore = communityScore.total; - contributionScore = Math.min(contributionScore, 0.3 * (repoScore.total + prScore.total)); + contributionScore = Math.min( + contributionScore, + COMMUNITY_CAP_RATIO * (repoScore.total + prScore.total), + ); contributionScore = sanitizeNumber(contributionScore); - const finalScore = repoScore.total * 0.45 + prScore.total * 0.45 + contributionScore * 0.1; + const finalScore = + repoScore.total * SCORING_WEIGHTS.repo + + prScore.total * SCORING_WEIGHTS.pr + + contributionScore * SCORING_WEIGHTS.contribution; - const normalizedRepoScore = normalizeScore(repoScore.total, 100); - const normalizedPRScore = normalizeScore(prScore.total, 300); - const normalizedContributionScore = normalizeScore(contributionScore, 100); + const normalizedRepoScore = normalizeScore(repoScore.total, SCORE_NORMALIZATION_K.repo); + const normalizedPRScore = normalizeScore(prScore.total, SCORE_NORMALIZATION_K.pr); + const normalizedContributionScore = normalizeScore( + contributionScore, + SCORE_NORMALIZATION_K.contribution, + ); const normalizedFinalScore = - normalizedRepoScore * 0.45 + normalizedPRScore * 0.45 + normalizedContributionScore * 0.1; + normalizedRepoScore * SCORING_WEIGHTS.repo + + normalizedPRScore * SCORING_WEIGHTS.pr + + normalizedContributionScore * SCORING_WEIGHTS.contribution; let languageScores: LanguageScores | undefined; let languageRepoSignals: Pick< @@ -625,22 +185,31 @@ export function calculateUserScore( let languageContributionScore = contributionScore; languageContributionScore = Math.min( languageContributionScore, - 0.3 * (languageRepoScore.total + languagePRScore.total), + COMMUNITY_CAP_RATIO * (languageRepoScore.total + languagePRScore.total), ); languageContributionScore = sanitizeNumber(languageContributionScore); const languageFinalScore = - languageRepoScore.total * 0.45 + - languagePRScore.total * 0.45 + - languageContributionScore * 0.1; + languageRepoScore.total * SCORING_WEIGHTS.repo + + languagePRScore.total * SCORING_WEIGHTS.pr + + languageContributionScore * SCORING_WEIGHTS.contribution; - const normalizedLanguageRepoScore = normalizeScore(languageRepoScore.total, 100); - const normalizedLanguagePRScore = normalizeScore(languagePRScore.total, 300); - const normalizedLanguageContributionScore = normalizeScore(languageContributionScore, 100); + const normalizedLanguageRepoScore = normalizeScore( + languageRepoScore.total, + SCORE_NORMALIZATION_K.repo, + ); + const normalizedLanguagePRScore = normalizeScore( + languagePRScore.total, + SCORE_NORMALIZATION_K.pr, + ); + const normalizedLanguageContributionScore = normalizeScore( + languageContributionScore, + SCORE_NORMALIZATION_K.contribution, + ); const normalizedLanguageFinalScore = - normalizedLanguageRepoScore * 0.45 + - normalizedLanguagePRScore * 0.45 + - normalizedLanguageContributionScore * 0.1; + normalizedLanguageRepoScore * SCORING_WEIGHTS.repo + + normalizedLanguagePRScore * SCORING_WEIGHTS.pr + + normalizedLanguageContributionScore * SCORING_WEIGHTS.contribution; languageScores = { selectedLanguages, @@ -687,20 +256,10 @@ export function calculateUserScore( } const explanations: ScoringExplanations = { - ...scoringExplanations, + ...BASE_SCORING_EXPLANATIONS, + ...(hasSelectedLanguages ? { language: LANGUAGE_SCORING_EXPLANATIONS } : {}), }; - 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.", - "Language matching is applied to repositories and pull requests only.", - ]; - } - return { username, repoScore: sanitizeNumber(repoScore.total), diff --git a/src/features/scoring/services/scoring-constants.ts b/src/features/scoring/services/scoring-constants.ts new file mode 100644 index 0000000..ec647c5 --- /dev/null +++ b/src/features/scoring/services/scoring-constants.ts @@ -0,0 +1,49 @@ +import type { ScoringExplanations } from "../types"; + +export const SCORING_WEIGHTS = { + repo: 0.45, + pr: 0.45, + contribution: 0.1, +} as const; + +export const SCORE_NORMALIZATION_K = { + repo: 100, + pr: 300, + contribution: 100, +} as const; + +export const COMMUNITY_CAP_RATIO = 0.3; +export const MS_PER_DAY = 86_400_000; +export const FALLBACK_REFERENCE_DATE = "2026-01-01T00:00:00.000Z"; + +export const BASE_SCORING_EXPLANATIONS: 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.", + "Issue and discussion impact is based on repository visibility and discussion activity.", + "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 const LANGUAGE_SCORING_EXPLANATIONS: string[] = [ + "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.", + "Language matching is applied to repositories and pull requests only.", +]; diff --git a/src/features/scoring/services/scoring-helpers.ts b/src/features/scoring/services/scoring-helpers.ts new file mode 100644 index 0000000..55a9d27 --- /dev/null +++ b/src/features/scoring/services/scoring-helpers.ts @@ -0,0 +1,91 @@ +import type { PullRequestNode, RepoNode } from "@/lib/github"; +import { FALLBACK_REFERENCE_DATE, MS_PER_DAY } from "./scoring-constants"; + +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 sanitizeNumber(value: number): number { + return Number.isFinite(value) ? 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); +} + +export function getRepoRankWeight(index: number): number { + return index < 5 ? 1 : 0.1; +} + +export function parseDate(value?: string): Date | null { + if (!value) { + return null; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return parsed; +} + +export 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)); +} + +export 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); +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..27308be --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-clipboard-copy"; diff --git a/src/hooks/use-clipboard-copy.ts b/src/hooks/use-clipboard-copy.ts new file mode 100644 index 0000000..73bb112 --- /dev/null +++ b/src/hooks/use-clipboard-copy.ts @@ -0,0 +1,44 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface UseClipboardCopyOptions { + timeoutMs?: number; +} + +export function useClipboardCopy(options: UseClipboardCopyOptions = {}) { + const { timeoutMs = 2000 } = options; + const [copied, setCopied] = useState(false); + const timerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + const copy = useCallback( + async (text: string): Promise => { + try { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + await navigator.clipboard.writeText(text); + setCopied(true); + timerRef.current = setTimeout(() => { + setCopied(false); + timerRef.current = null; + }, timeoutMs); + return true; + } catch { + setCopied(false); + return false; + } + }, + [timeoutMs], + ); + + return { copied, copy }; +} diff --git a/src/lib/api/api-helpers.ts b/src/lib/api/api-helpers.ts new file mode 100644 index 0000000..bb34a53 --- /dev/null +++ b/src/lib/api/api-helpers.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server"; +import { normalizeSelectedLanguages } from "@/features/scoring"; +import { toSafeApiError } from "@/lib/github"; +import type { ClientSafeError, SafeApiError } from "@/types/api"; + +export function toApiErrorStatus(code: ReturnType["code"]): number { + switch (code) { + case "RATE_LIMITED": + case "TEMPORARY_THROTTLE": + return 429; + case "GITHUB_TIMEOUT": + case "GITHUB_RESOURCE_LIMIT": + case "GITHUB_AUTH": + return code === "GITHUB_AUTH" ? 401 : 503; + case "GITHUB_NOT_FOUND": + return 404; + case "NETWORK": + return 503; + case "UNKNOWN": + default: + return 500; + } +} + +export function toClientSafeError(error: SafeApiError): ClientSafeError { + return { + code: error.code, + message: error.message, + targetUsernames: error.targetUsernames, + }; +} + +export 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 ?? [])]); +} + +/** + * Standardized API error response handler for route handlers. + * Translates UserFetchError, CompareUserFetchError, and generic exceptions + * into structured ClientSafeError responses with proper HTTP status codes. + */ +export function formatApiErrorResponse(error: unknown): NextResponse { + let safeError: SafeApiError; + + const isFetchError = + error !== null && + typeof error === "object" && + "causeError" in error && + "username" in error && + typeof (error as { username: unknown }).username === "string"; + + if (isFetchError) { + const fetchErr = error as { username: string; causeError: unknown }; + const mappedCause = toSafeApiError(fetchErr.causeError); + if ( + mappedCause.code === "GITHUB_NOT_FOUND" || + (fetchErr.causeError instanceof Error && fetchErr.causeError.message === "User not found") + ) { + safeError = { + code: "GITHUB_NOT_FOUND", + message: "GitHub user not found", + targetUsernames: [fetchErr.username], + rateLimit: mappedCause.rateLimit, + }; + } else { + safeError = mappedCause; + } + } else { + safeError = + error instanceof Error && error.message === "User not found" + ? { code: "GITHUB_NOT_FOUND", message: "GitHub user not found" } + : toSafeApiError(error); + } + + const clientSafeError = toClientSafeError(safeError); + + return NextResponse.json( + { + success: false, + error: clientSafeError.message, + errorDetails: clientSafeError, + }, + { status: toApiErrorStatus(safeError.code) }, + ); +} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 0000000..1613dbc --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1 @@ +export * from "./api-helpers"; diff --git a/src/lib/db/db-store.ts b/src/lib/db/db-store.ts index ab68d82..ba7c12f 100644 --- a/src/lib/db/db-store.ts +++ b/src/lib/db/db-store.ts @@ -1,16 +1,18 @@ import { Pool, PoolConfig } from "pg"; import countries from "@/data/countries.json"; +import type { GitHubUserData } from "@/lib/github"; +import type { CalculateUserScoreResult } from "@/features/scoring/services"; // ─── Types ───────────────────────────────────────────────────────────── -export type GitHubUserRow = { +export type GitHubUserRow = { username: string; name: string | null; avatar_url: string; location: string | null; country: string | null; - raw_data: unknown; - scores: unknown; + raw_data: TRaw; + scores: TScores; repo_score: number; pr_score: number; contribution_score: number; @@ -21,14 +23,14 @@ export type GitHubUserRow = { updated_at: Date; }; -export type UpsertUserParams = { +export type UpsertUserParams = { username: string; name: string | null; avatarUrl: string; location: string | null; country: string | null; - rawData: unknown; - scores: unknown; + rawData: TRaw; + scores: TScores; repoScore: number; prScore: number; contributionScore: number; diff --git a/src/lib/github/github-client.ts b/src/lib/github/github-client.ts index 42cbea9..05e53b8 100644 --- a/src/lib/github/github-client.ts +++ b/src/lib/github/github-client.ts @@ -821,33 +821,6 @@ export async function getUserData( normalizedUsername, ); - // Upsert into PostgreSQL - try { - const { getDatabaseStore: getDb } = await import("@/lib/db"); - const { calculateUserScore: calcScore } = - await import("@/features/scoring/services/score-engine"); - - const db = getDb(); - const score = calcScore(fresh, normalizedUsername); - - await db.upsertUser({ - username: fresh.login, - name: fresh.name, - avatarUrl: fresh.avatarUrl, - location: fresh.location, - country: null, - rawData: fresh, - scores: score, - repoScore: Math.round(score.repoScore), - prScore: Math.round(score.prScore), - contributionScore: Math.round(score.contributionScore), - finalScore: Math.round(score.finalScore), - staleDays, - }); - } catch { - // Non-fatal: DB write failure - } - // 4. Handle Redis cache if (cacheStoreSingleton.enabled && cacheStoreSingleton.del) { const cacheKey = buildUserCacheKey(normalizedUsername); diff --git a/src/lib/i18n/provider-hook.ts b/src/lib/i18n/provider-hook.ts index 72b4434..53dc6fe 100644 --- a/src/lib/i18n/provider-hook.ts +++ b/src/lib/i18n/provider-hook.ts @@ -84,7 +84,7 @@ export function useI18nProvider(initialLocale: Locale = DEFAULT_LOCALE) { } if (!params) return template; return Object.keys(params).reduce( - (acc, k) => acc.replace(`{${k}}`, String(params[k])), + (acc, k) => acc.split(`{${k}}`).join(String(params[k])), template, ); },