diff --git a/app/api/compare/route.ts b/app/api/compare/route.ts index 2221076..afeb6f8 100644 --- a/app/api/compare/route.ts +++ b/app/api/compare/route.ts @@ -3,7 +3,7 @@ import { fetchGitHubUserData } from "../../../lib/github"; import { calculateUserScore } from "../../../lib/score"; import { normalizeSelectedLanguages } from "@/lib/scoring/languageScoring"; import { toSafeApiError } from "@/lib/github-graphql-client"; -import type { CompareInsights } from "@/types/api-response"; +import type { CompareInsights, SafeApiError } from "@/types/api-response"; import { DEFAULT_LOCALE, LOCALE_COOKIE, @@ -14,6 +14,18 @@ import { export const runtime = "nodejs"; +class CompareUserFetchError extends Error { + readonly username: string; + readonly causeError: unknown; + + constructor(username: string, causeError: unknown) { + super(`Failed to fetch GitHub data for ${username}`); + this.name = "CompareUserFetchError"; + this.username = username; + this.causeError = causeError; + } +} + type ComparedUserResult = { username: string; name: string | null; @@ -36,6 +48,8 @@ type ComparedUserResult = { explanations: ReturnType["explanations"]; }; +type ClientSafeError = Pick; + function parseSelectedLanguagesFromSearchParams( searchParams: URLSearchParams, ): string[] { @@ -292,7 +306,13 @@ async function compareUsers( const results: ComparedUserResult[] = []; for (const username of usernames) { - const data = await fetchGitHubUserData(username); + let data: Awaited>; + try { + data = await fetchGitHubUserData(username); + } catch (error: unknown) { + throw new CompareUserFetchError(username, error); + } + const score = calculateUserScore( { ...data, @@ -341,6 +361,14 @@ function toApiErrorStatus(code: ReturnType["code"]): numb } } +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 @@ -364,16 +392,39 @@ export async function GET(request: Request) { return NextResponse.json({ success: true, users, ...winnerData, insights }); } catch (error: unknown) { console.error("GitHub score error:", error); - const safeError = - error instanceof Error && error.message === "User not found" - ? { code: "GITHUB_NOT_FOUND" as const, message: "GitHub user not found" } - : toSafeApiError(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: safeError.message, - errorDetails: safeError, + error: clientSafeError.message, + errorDetails: clientSafeError, }, { status: toApiErrorStatus(safeError.code) }, ); diff --git a/components/compare-form.tsx b/components/compare-form.tsx index eaaddb3..60ee6f9 100644 --- a/components/compare-form.tsx +++ b/components/compare-form.tsx @@ -9,7 +9,6 @@ import { CardHeader, CardTitle, } from "./ui/card"; -import { Alert, AlertDescription } from "./ui/alert"; import { useTranslation } from "./language-provider"; import { cn } from "@/lib/utils"; @@ -46,7 +45,8 @@ type CompareFormProps = { loading?: boolean; reset?: () => void; swapUsers?: () => void; - error?: string | null; + username1Error?: string | null; + username2Error?: string | null; }; export function CompareForm({ @@ -61,7 +61,8 @@ export function CompareForm({ loading, swapUsers, reset, - error, + username1Error, + username2Error, }: CompareFormProps) { const { t } = useTranslation(); const firstInputRef = useRef(null); @@ -131,7 +132,14 @@ export function CompareForm({ value={username1} onChange={(e) => setUsername1(e.target.value)} aria-label={t("form.username1.label")} + aria-invalid={Boolean(username1Error)} + aria-describedby={username1Error ? "username1-error" : undefined} /> + {username1Error ? ( +

+ {username1Error} +

+ ) : null}
@@ -250,12 +265,6 @@ export function CompareForm({ - - {error ? ( - - {error} - - ) : null} diff --git a/components/home-page-client.tsx b/components/home-page-client.tsx index cc5205f..4130086 100644 --- a/components/home-page-client.tsx +++ b/components/home-page-client.tsx @@ -2,6 +2,7 @@ import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import Image from "next/image"; import { CompareForm } from "../components/compare-form"; import { ResultDashboard } from "../components/result-dashboard"; import { DashboardSkeleton } from "../components/skeletons"; @@ -37,6 +38,11 @@ type CompareOptions = { updateUrl?: boolean; }; +type UsernameErrors = { + username1: string | null; + username2: string | null; +}; + const EXIT_ANIMATION_MS = 240; function sanitizeSelectedLanguages(languages: string[]): string[] { @@ -78,7 +84,11 @@ export function HomePageClient() { searchParams.getAll("selectedLanguage"), ); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + 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( @@ -135,6 +145,66 @@ export function HomePageClient() { } }; + 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: ApiResponse, + ) => { + 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 createFetchKey = ( u1: string, u2: string, @@ -172,7 +242,7 @@ export function HomePageClient() { } setLoading(true); - setError(null); + resetErrors(); try { const requestParams = new URLSearchParams(); @@ -187,14 +257,14 @@ export function HomePageClient() { const body: ApiResponse = await res.json(); if (!res.ok) { setData(null); - setError(localizeErrorMessage(body.error, body.errorDetails)); + applyApiError(u1, u2, body); return; } const users = normalizeUsers(body); if (!body.success || !users) { setData(null); - setError(localizeErrorMessage(body.error || "Comparison failed", body.errorDetails)); + applyApiError(u1, u2, body); return; } @@ -219,7 +289,11 @@ export function HomePageClient() { setDisplayData(nextData); } catch (err: unknown) { setData(null); - setError(localizeErrorMessage(err instanceof Error ? err.message : undefined)); + setUsernameErrors({ + username1: null, + username2: null, + }); + setGeneralError(localizeErrorMessage(err instanceof Error ? err.message : undefined)); } finally { if (inFlightFetchKeyRef.current === fetchKey) { inFlightFetchKeyRef.current = null; @@ -244,7 +318,7 @@ export function HomePageClient() { if (!u1 || !u2) { lastFetchedKeyRef.current = null; setData(null); - setError(null); + resetErrors(); return; } @@ -308,10 +382,24 @@ export function HomePageClient() { const isRefreshing = loading && Boolean(displayData); const isExiting = !loading && !data && Boolean(displayData); + 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); - setError(null); + resetErrors(); inFlightFetchKeyRef.current = null; inFlightPromiseRef.current = null; setUsername1(""); @@ -344,15 +432,16 @@ export function HomePageClient() { username1={username1} username2={username2} selectedLanguages={selectedLanguages} - setUsername1={setUsername1} - setUsername2={setUsername2} + setUsername1={handleUsername1Change} + setUsername2={handleUsername2Change} setSelectedLanguages={setSelectedLanguages} onSubmit={handleCompare} loading={loading} reset={reset} swapUsers={swapUsers} hasData={Boolean(data)} - error={error} + username1Error={usernameErrors.username1} + username2Error={usernameErrors.username2} />
@@ -391,13 +480,34 @@ export function HomePageClient() {
) : null} - {!loading && !error && !displayData ? ( + {!loading && !generalError && !displayData ? (

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

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

) : null} + + {!loading && generalError && !displayData ? ( +
+
+ +
+

+ {t("error.comparisonFailed")} +

+

+ {generalError} +

+
+ ) : null} diff --git a/public/error-state.svg b/public/error-state.svg new file mode 100644 index 0000000..42e4a98 --- /dev/null +++ b/public/error-state.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/test/api/compare.route.test.ts b/test/api/compare.route.test.ts index 48eaed3..eb616ee 100644 --- a/test/api/compare.route.test.ts +++ b/test/api/compare.route.test.ts @@ -99,13 +99,14 @@ describe("GET /api/compare", () => { const body = (await response.json()) as { success: boolean; error?: string; - errorDetails?: { code?: string; retryAfterSeconds?: number }; + errorDetails?: { code?: string; retryAfterSeconds?: number; rateLimit?: unknown }; }; expect(response.status).toBe(429); expect(body.success).toBe(false); expect(body.errorDetails?.code).toBe("RATE_LIMITED"); - expect(body.errorDetails?.retryAfterSeconds).toBe(60); + expect(body.errorDetails?.retryAfterSeconds).toBeUndefined(); + expect(body.errorDetails?.rateLimit).toBeUndefined(); }); test("returns success payload when both users are processed", async () => { @@ -155,4 +156,23 @@ describe("GET /api/compare", () => { expect(body.users).toHaveLength(2); expect(body.winner?.username).toBe("user-a"); }); + + test("returns targeted username for not-found errors", async () => { + mocks.fetchGitHubUserData.mockRejectedValueOnce(new Error("User not found")); + + const response = await GET( + makeRequest({ + username: ["missing-user", "valid-user"], + }), + ); + const body = (await response.json()) as { + success: boolean; + errorDetails?: { code?: string; targetUsernames?: string[] }; + }; + + expect(response.status).toBe(404); + expect(body.success).toBe(false); + expect(body.errorDetails?.code).toBe("GITHUB_NOT_FOUND"); + expect(body.errorDetails?.targetUsernames).toEqual(["missing-user"]); + }); }); diff --git a/types/api-response.ts b/types/api-response.ts index 86ed506..2b5fcaa 100644 --- a/types/api-response.ts +++ b/types/api-response.ts @@ -18,6 +18,7 @@ export type SafeApiError = { | "UNKNOWN"; message: string; retryAfterSeconds?: number; + targetUsernames?: string[]; rateLimit?: { limit?: number; remaining?: number;