diff --git a/src/app/api/metrics/consistency-score/route.ts b/src/app/api/metrics/consistency-score/route.ts new file mode 100644 index 00000000..150bba06 --- /dev/null +++ b/src/app/api/metrics/consistency-score/route.ts @@ -0,0 +1,233 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; +import { GITHUB_API } from "@/lib/github"; +import { + isMetricsCacheBypassed, + METRICS_CACHE_TTL_SECONDS, + metricsCacheKey, + withMetricsCache, +} from "@/lib/metrics-cache"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { calculateConsistencyScore } from "@/lib/consistency-score"; + +export const dynamic = "force-dynamic"; + +const LOOKBACK_DAYS = 365; + +async function fetchActiveDates( + githubLogin: string, + token: string, + cacheContext: { bypass: boolean; userId: string }, + timeZone = "UTC", +): Promise> { + const key = metricsCacheKey(cacheContext.userId, "streak", { githubLogin }); + + const dates = await withMetricsCache( + { + bypass: cacheContext.bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS.streak, + }, + async () => { + const since = new Date(); + since.setDate(since.getDate() - LOOKBACK_DAYS); + const sinceStr = since.toISOString().slice(0, 10); + + const activeDates = new Set(); + let page = 1; + + while (true) { + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${sinceStr}&per_page=100&page=${page}&sort=author-date&order=desc`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + }, + cache: "no-store", + }, + ); + + if (!searchRes.ok) { + throw new Error("GitHub API error"); + } + + const data = (await searchRes.json()) as { + items: Array<{ commit: { author: { date: string } } }>; + }; + + for (const item of data.items) { + const commitDate = new Date(item.commit.author.date); + const parts = new Intl.DateTimeFormat("en", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(commitDate); + const year = parts.find((p) => p.type === "year")?.value; + const month = parts.find((p) => p.type === "month")?.value; + const day = parts.find((p) => p.type === "day")?.value; + if (year && month && day) { + activeDates.add(`${year}-${month}-${day}`); + } + } + + if (data.items.length < 100 || page >= 10) break; + page += 1; + } + + return Array.from(activeDates); + }, + ); + + return new Set(dates); +} + +async function getConsistencyScoreForDates( + activeDates: Set, + timeZone: string, + cacheContext: { bypass: boolean; userId: string; accountKey: string }, +) { + const key = `metrics:${cacheContext.userId}:consistency-score:${cacheContext.accountKey}`; + + return withMetricsCache( + { + bypass: cacheContext.bypass, + key, + ttlSeconds: METRICS_CACHE_TTL_SECONDS.streak, + }, + async () => calculateConsistencyScore(activeDates, timeZone), + ); +} + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !session.githubLogin || !session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const accountId = req.nextUrl.searchParams.get("accountId"); + const bypass = isMetricsCacheBypassed(req); + + const userRow = await resolveAppUser(session.githubId, session.githubLogin); + const appUserId = userRow?.id ?? null; + + if (accountId && !appUserId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + let timeZone = "UTC"; + if (appUserId) { + const { data: tzRow } = await supabaseAdmin + .from("users") + .select("timezone") + .eq("id", appUserId) + .single(); + if (tzRow?.timezone) timeZone = tzRow.timezone; + } + + if (!accountId) { + try { + const activeDates = await fetchActiveDates( + session.githubLogin, + session.accessToken, + { bypass, userId: session.githubId }, + timeZone, + ); + const result = await getConsistencyScoreForDates(activeDates, timeZone, { + bypass, + userId: session.githubId, + accountKey: "default", + }); + return Response.json(result); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } + } + + if (!appUserId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (accountId === "combined") { + const accounts = await getAllAccounts( + { + token: session.accessToken, + githubId: session.githubId, + githubLogin: session.githubLogin, + }, + appUserId, + ); + + const dateResults = await Promise.allSettled( + accounts.map((account) => + fetchActiveDates( + account.githubLogin, + account.token, + { bypass, userId: account.githubId }, + timeZone, + ), + ), + ); + + const unifiedDates = new Set(); + for (const result of dateResults) { + if (result.status === "fulfilled") { + result.value.forEach((date) => unifiedDates.add(date)); + } + } + + const scoreData = await getConsistencyScoreForDates(unifiedDates, timeZone, { + bypass, + userId: appUserId, + accountKey: "combined", + }); + + return Response.json(scoreData); + } + + let resolvedToken = session.accessToken; + let resolvedLogin = session.githubLogin; + + if (accountId !== session.githubId) { + const accountToken = await getAccountToken(appUserId, accountId); + + if (!accountToken) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + + const { data: accountRow } = await supabaseAdmin + .from("user_github_accounts") + .select("github_login") + .eq("user_id", appUserId) + .eq("github_id", accountId) + .single(); + + if (!accountRow?.github_login) { + return Response.json({ error: "Account not found" }, { status: 404 }); + } + + resolvedToken = accountToken; + resolvedLogin = accountRow.github_login; + } + + try { + const activeDates = await fetchActiveDates( + resolvedLogin, + resolvedToken, + { bypass, userId: accountId }, + timeZone, + ); + const result = await getConsistencyScoreForDates(activeDates, timeZone, { + bypass, + userId: accountId, + accountKey: accountId, + }); + return Response.json(result); + } catch { + return Response.json({ error: "GitHub API error" }, { status: 502 }); + } +} diff --git a/src/components/ConsistencyScoreWidget.tsx b/src/components/ConsistencyScoreWidget.tsx new file mode 100644 index 00000000..921c2b23 --- /dev/null +++ b/src/components/ConsistencyScoreWidget.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import SectionHeader from "@/components/SectionHeader"; +import { useAccount } from "@/components/AccountContext"; +import { + isRecentlyActiveFromScore, + type ConsistencyScoreResult, +} from "@/lib/consistency-score"; + +const GRADE_COLORS: Record = { + S: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", + A: "bg-blue-500/15 text-blue-400 border-blue-500/30", + B: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30", + C: "bg-orange-500/15 text-orange-400 border-orange-500/30", + D: "bg-red-500/15 text-red-400 border-red-500/30", +}; + +const GRADE_RING_COLORS: Record = { + S: "var(--success, #10b981)", + A: "#3b82f6", + B: "#eab308", + C: "#f97316", + D: "var(--destructive, #ef4444)", +}; + +function CircularProgress({ + score, + grade, +}: { + score: number; + grade: ConsistencyScoreResult["grade"]; +}) { + const radius = 52; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (score / 100) * circumference; + const strokeColor = GRADE_RING_COLORS[grade]; + + return ( +
+ +
+ {score} + / 100 +
+
+ ); +} + +function ConsistencyScoreSkeleton() { + return ( +
+