diff --git a/src/app/api/metrics/achievement-progress/route.ts b/src/app/api/metrics/achievement-progress/route.ts new file mode 100644 index 00000000..7dd2d615 --- /dev/null +++ b/src/app/api/metrics/achievement-progress/route.ts @@ -0,0 +1,129 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { GitHubAuthError, githubAuthErrorResponse } from "@/lib/github-fetch"; +import { + isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { + buildLockedAchievementProgress, + type AchievementProgressInfo, +} from "@/lib/achievement-progress"; + +export const dynamic = "force-dynamic"; + +// --- GraphQL query ----------------------------------------------------------- + +/** + * Single round-trip that fetches the two metrics used as proxies: + * - Total merged pull requests the viewer has opened + * - Total discussion comments marked as accepted answers by the viewer + */ +const ACHIEVEMENT_PROGRESS_QUERY = ` + query AchievementProgress { + viewer { + pullRequests(states: [MERGED]) { + totalCount + } + repositoryDiscussionComments(onlyAnswers: true) { + totalCount + } + } + } +`; + +interface AchievementProgressQueryResult { + data?: { + viewer?: { + pullRequests?: { totalCount?: number | null } | null; + repositoryDiscussionComments?: { totalCount?: number | null } | null; + } | null; + }; + errors?: Array<{ message?: string }>; +} + +// --- Data fetcher ------------------------------------------------------------ + +async function fetchAchievementMetrics( + token: string, + userId: string, + bypass: boolean +): Promise<{ mergedPRs: number; acceptedAnswers: number }> { + const key = metricsCacheKey(userId, "achievement-progress"); + + return withMetricsCache( + { + bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS["achievement-progress"], + }, + async () => { + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ query: ACHIEVEMENT_PROGRESS_QUERY }), + cache: "no-store", + }); + + if (!response.ok) { + if (response.status === 401) throw new GitHubAuthError(); + throw new Error(`GitHub GraphQL error: ${response.status}`); + } + + const json = (await response.json()) as AchievementProgressQueryResult; + + if (json.errors?.length) { + throw new Error(json.errors[0]?.message ?? "GraphQL error"); + } + + const viewer = json.data?.viewer; + return { + mergedPRs: viewer?.pullRequests?.totalCount ?? 0, + acceptedAnswers: viewer?.repositoryDiscussionComments?.totalCount ?? 0, + }; + } + ); +} + +// --- Route handler ----------------------------------------------------------- + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + + if (!session?.accessToken || !session.githubId || !session.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const bypass = isMetricsCacheBypassed(req); + + let metrics: { mergedPRs: number; acceptedAnswers: number } | null; + try { + metrics = await fetchAchievementMetrics(session.accessToken, user.id, bypass); + } catch (err) { + if (err instanceof GitHubAuthError) { + return githubAuthErrorResponse(); + } + console.error("[achievement-progress] fetch error", err); + // Return graceful degradation instead of hard error. + metrics = null; + } + + const progress: AchievementProgressInfo[] = buildLockedAchievementProgress( + metrics, + new Set() + ); + + return Response.json(progress); +} diff --git a/src/components/GitHubAchievementProgress.tsx b/src/components/GitHubAchievementProgress.tsx new file mode 100644 index 00000000..65bc4da6 --- /dev/null +++ b/src/components/GitHubAchievementProgress.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { AchievementProgressInfo } from "@/lib/achievement-progress"; + +type FetchState = + | { status: "idle" } + | { status: "loading" } + | { status: "error"; message: string } + | { status: "success"; items: AchievementProgressInfo[] }; + +function ProgressBar({ percent }: { percent: number }) { + const clamped = Math.min(100, Math.max(0, percent)); + return ( +
+
+
+ ); +} + +function AchievementCard({ item }: { item: AchievementProgressInfo }) { + return ( +
+
+ + {item.title} + + {item.dataAvailable && item.nextMilestone && ( + + {item.nextMilestone.tier} + + )} +
+ + {item.dataAvailable ? ( + <> + +

+ {item.progressDescription ?? ""} +

+ + ) : ( +

+ Progress unavailable +

+ )} +
+ ); +} + +function SkeletonCard() { + return ( +