Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ export async function GET(request: Request) {

try {
const result = await getLeaderboardResult(country);
return NextResponse.json({ success: true, ...result });
return NextResponse.json(
{ success: true, ...result },
{
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
},
},
);
} catch (err) {
console.error("Leaderboard DB query failed:", err);

Expand Down
9 changes: 8 additions & 1 deletion src/app/api/user/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ user

try {
const { user, location } = await getUserProfile(trimmed, selectedLanguages);
return NextResponse.json({ success: true, user, location });
return NextResponse.json(
{ success: true, user, location },
{
headers: {
"Cache-Control": "public, s-maxage=1800, stale-while-revalidate=86400",
},
},
);
} catch (error: unknown) {
console.error("User profile fetch error:", error);
return formatApiErrorResponse(error);
Expand Down
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export const metadata: Metadata = {
export default async function RootLayout({ children }: { children: ReactNode }) {
const cookieStore = await cookies();
const headerStore = await headers();
const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value;
const cookieLocale = cookieStore.get(LOCALE_COOKIE)?.value || headerStore.get("x-locale");
const initialLocale = isSupportedLocale(cookieLocale)
? cookieLocale
: parseAcceptLanguage(headerStore.get("accept-language"), supportedLocales, DEFAULT_LOCALE);
Expand Down
7 changes: 6 additions & 1 deletion src/app/leaderboard/[country]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
};
}

export const revalidate = 3600; // Cache on Vercel Edge CDN for 1 hour via ISR
export const dynamicParams = true;

export async function generateStaticParams() {
return countries.map((country) => ({ country: country.slug }));
// Pre-render the top 20 most visited countries at build time.
// Other countries are generated on-demand upon first visit and cached by ISR.
return countries.slice(0, 20).map((country) => ({ country: country.slug }));
}

export default async function CountryLeaderboardPage({ params }: Props) {
Expand Down
5 changes: 4 additions & 1 deletion src/app/scoring-methodology/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { JsonLd } from "@/components/seo/json-ld";
import { ScoringMethodologyPageClient } from "@/features/scoring";
Expand Down Expand Up @@ -68,7 +69,9 @@ export default function ScoringMethodologyPage() {
return (
<>
<JsonLd data={faqSchema} />
<ScoringMethodologyPageClient />
<Suspense fallback={<div className="min-h-screen" />}>
<ScoringMethodologyPageClient />
</Suspense>
</>
);
}
3 changes: 3 additions & 0 deletions src/app/user/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type CountryInfo = {

const countries = countriesData as CountryInfo[];

export const revalidate = 1800; // Cache user profile on Edge CDN for 30 minutes via ISR
export const dynamicParams = true;

type Props = {
params: Promise<{ username: string }>;
searchParams?: Promise<{ country?: string }>;
Expand Down
83 changes: 32 additions & 51 deletions src/features/comparison/services/compare-service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { getUserData } from "@/lib/github";
import { calculateUserScore, normalizeSelectedLanguages } from "@/features/scoring";
import { persistUserScores } from "@/features/developer/services";
import { normalizeSelectedLanguages } from "@/features/scoring";
import { getUserProfile, UserFetchError } from "@/features/developer/services";
import {
DEFAULT_LOCALE,
LOCALE_COOKIE,
isSupportedLocale,
parseAcceptLanguage,
type Locale,
} from "@/lib/i18n/core";
import type { GitHubUserData } from "@/lib/github";
import type { ComparedUserResult, CompareInsights, CompareWinner, LanguageWinner } from "../types";

export class CompareUserFetchError extends Error {
Expand Down Expand Up @@ -257,53 +255,36 @@ export async function compareUsers(
usernames: string[],
selectedLanguages: string[],
): Promise<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);
}

const score = calculateUserScore(
{
...data,
selectedLanguages,
},
const results: ComparedUserResult[] = [];
for (const username of usernames) {
try {
const { user } = await getUserProfile(username, selectedLanguages);
results.push({
username: user.username,
name: user.name,
avatarUrl: user.avatarUrl,
repoScore: user.repoScore,
prScore: user.prScore,
contributionScore: user.contributionScore,
finalScore: user.finalScore,
normalizedRepoScore: user.normalizedRepoScore ?? user.repoScore,
normalizedPRScore: user.normalizedPRScore ?? user.prScore,
normalizedContributionScore: user.normalizedContributionScore ?? user.contributionScore,
normalizedFinalScore: user.normalizedFinalScore ?? user.finalScore,
topRepos: user.topRepos as ComparedUserResult["topRepos"],
topPullRequests: user.topPullRequests as ComparedUserResult["topPullRequests"],
topCommunityContributions: (user.topCommunityContributions ??
[]) as ComparedUserResult["topCommunityContributions"],
languageScores: user.languageScores as ComparedUserResult["languageScores"],
signals: user.signals,
explanations: user.explanations,
} as unknown as ComparedUserResult);
} catch (error: unknown) {
throw new CompareUserFetchError(
username,
error instanceof UserFetchError ? error.causeError : error,
);

// Fire-and-forget: detect country & persist canonical scores into DB
void persistUserScores({
data,
score,
selectedLanguages,
});

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,
};
}),
);
}
}
return results;
}
1 change: 1 addition & 0 deletions src/features/developer/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./user-service";
export * from "./user-persistence";
export * from "./profile-cache";
74 changes: 74 additions & 0 deletions src/features/developer/services/profile-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { createCacheStore, getCacheConfigFromEnv, type CacheStore } from "@/lib/cache";
import type { UserProfileResponse } from "../types";

let cacheStore: CacheStore | undefined;

function getStore(): CacheStore {
if (!cacheStore) {
cacheStore = createCacheStore(getCacheConfigFromEnv());
}
return cacheStore;
}

export function buildProfileCacheKey(username: string, selectedLanguages: string[] = []): string {
const config = getCacheConfigFromEnv();
const normalizedUser = username.trim().toLowerCase();
if (!selectedLanguages || selectedLanguages.length === 0) {
return `${config.namespace}:profile:${normalizedUser}`;
}
const langKey = selectedLanguages
.map((l) => l.trim().toLowerCase())
.filter(Boolean)
.sort()
.join(",");
return `${config.namespace}:profile:${normalizedUser}:${langKey}`;
}

export async function getCachedProfile(
username: string,
selectedLanguages: string[] = [],
): Promise<UserProfileResponse | null> {
const store = getStore();
if (!store.enabled) {
return null;
}
try {
const key = buildProfileCacheKey(username, selectedLanguages);
const cached = await store.get<UserProfileResponse>(key);
return cached ?? null;
} catch {
return null;
}
}

export async function setCachedProfile(
username: string,
profile: UserProfileResponse,
selectedLanguages: string[] = [],
ttlSeconds?: number,
): Promise<void> {
const store = getStore();
if (!store.enabled) {
return;
}
try {
const key = buildProfileCacheKey(username, selectedLanguages);
const ttl = ttlSeconds ?? getCacheConfigFromEnv().ttlSeconds;
await store.set(key, profile, ttl);
} catch {
// Non-fatal
}
}

export async function invalidateProfileCache(username: string): Promise<void> {
const store = getStore();
if (!store.enabled || !store.del) {
return;
}
try {
const key = buildProfileCacheKey(username, []);
await store.del(key);
} catch {
// Non-fatal
}
}
76 changes: 71 additions & 5 deletions src/features/developer/services/user-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { getUserData } from "@/lib/github";
import { calculateUserScore } from "@/features/scoring";
import type { CalculateUserScoreResult } from "@/features/scoring/services";
import { persistUserScores } from "./user-persistence";
import { getCachedProfile, setCachedProfile } from "./profile-cache";
import type { UserProfileResponse, UserResult } from "../types";
import type { GitHubUserData } from "@/lib/github";

Expand All @@ -25,6 +27,65 @@ export async function getUserProfile(
throw new Error("Username is required");
}

// ── 1. Check Redis Profile Cache (Tier 1: Pre-computed Profile) ──
try {
const cached = await getCachedProfile(normalizedUsername, selectedLanguages);
if (cached) {
return cached;
}
} catch {
// Non-fatal: continue to DB / fresh calculation
}

// ── 2. Check PostgreSQL Pre-calculated Scores (Tier 2 for Canonical) ──
const isCanonical = selectedLanguages.length === 0;
if (isCanonical) {
try {
const { getDatabaseStore } = await import("@/lib/db");
const db = getDatabaseStore();
if (typeof db.getUserProfile === "function") {
const row = await db.getUserProfile(normalizedUsername);

if (row && row.scores && row.stale_after > new Date()) {
const scores = row.scores as CalculateUserScoreResult;
const profile: UserProfileResponse = {
user: {
username: row.username,
name: row.name,
avatarUrl: row.avatar_url,
repoScore: row.repo_score,
prScore: row.pr_score,
contributionScore: row.contribution_score,
finalScore: row.final_score,
normalizedRepoScore: Math.round(scores.normalizedRepoScore ?? scores.repoScore),
normalizedPRScore: Math.round(scores.normalizedPRScore ?? scores.prScore),
normalizedContributionScore: Math.round(
scores.normalizedContributionScore ?? scores.contributionScore,
),
normalizedFinalScore: Math.round(scores.normalizedFinalScore ?? scores.finalScore),
topRepos: scores.topRepos ?? [],
topPullRequests: scores.topPullRequests ?? [],
topCommunityContributions: scores.topCommunityContributions ?? [],
languageScores: scores.languageScores,
signals: scores.signals,
explanations: scores.explanations,
scoreVersion: process.env.DEVIMPACT_VERSION || undefined,
},
location: row.location ?? null,
};

// Warm Redis profile cache
void setCachedProfile(normalizedUsername, profile, selectedLanguages);

return profile;
}
}
} catch {
// Non-fatal: fall through to full calculation
}
}

// ── 3. Fallback: Fetch raw GitHub data & compute score ──────────────
let data: GitHubUserData;
try {
const { data: userData } = await getUserData(normalizedUsername, {
Expand Down Expand Up @@ -65,15 +126,20 @@ export async function getUserProfile(
scoreVersion: process.env.DEVIMPACT_VERSION || undefined,
};

// Fire-and-forget: detect country & persist canonical scores into DB
const response: UserProfileResponse = {
user,
location: data.location ?? null,
};

// Warm Redis profile cache with the computed profile
void setCachedProfile(normalizedUsername, response, selectedLanguages);

// Persist canonical scores into DB
void persistUserScores({
data,
score,
selectedLanguages,
});

return {
user,
location: data.location ?? null,
};
return response;
}
Loading
Loading