diff --git a/app/api/github/route.ts b/app/api/github/route.ts index b7d78fa..76af7ad 100644 --- a/app/api/github/route.ts +++ b/app/api/github/route.ts @@ -4,18 +4,22 @@ import { getFullDashboardData } from '@/lib/github'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const username = searchParams.get('username'); + const refresh = searchParams.get('refresh') === 'true'; if (!username) { return NextResponse.json({ error: 'Username is required' }, { status: 400 }); } try { - const data = await getFullDashboardData(username); + const data = await getFullDashboardData(username, { bypassCache: refresh }); + const cacheControl = refresh + ? 'no-cache, no-store, must-revalidate' + : 's-maxage=3600, stale-while-revalidate'; return NextResponse.json(data, { status: 200, headers: { - 'Cache-Control': 's-maxage=3600, stale-while-revalidate', + 'Cache-Control': cacheControl, }, }); } catch (error: unknown) { diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts index 16f6298..7786ce2 100644 --- a/app/api/streak/route.test.ts +++ b/app/api/streak/route.test.ts @@ -96,7 +96,7 @@ describe('GET /api/streak', () => { it('forwards the username to fetchGitHubContributions', async () => { await GET(makeRequest({ user: 'octocat' })); - expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat'); + expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat', { bypassCache: false }); }); it('embeds the username (uppercased) in the SVG title', async () => { @@ -134,6 +134,12 @@ describe('GET /api/streak', () => { expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate'); }); + it('passes bypassCache=true when refresh=true', async () => { + await GET(makeRequest({ user: 'octocat', refresh: 'true' })); + + expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat', { bypassCache: true }); + }); + it('keeps normal caching when refresh is "false"', async () => { // Only the exact string "true" disables caching. const response = await GET(makeRequest({ user: 'octocat', refresh: 'false' })); diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index 79d15c2..20a1db8 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -38,14 +38,15 @@ export async function GET(request: Request) { font, }; - const calendar = await fetchGitHubContributions(user); + const refresh = searchParams.get('refresh') === 'true'; + + const calendar = await fetchGitHubContributions(user, { bypassCache: refresh }); const stats = calculateStreak(calendar); const svg = generateSVG(stats, params, calendar); // 4. Calculate Cache Control (Reset at UTC Midnight) const secondsToMidnight = getSecondsUntilUTCMidnight(); - const refresh = searchParams.get('refresh') === 'true'; const cacheControl = refresh ? 'no-cache, no-store, must-revalidate' : `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`; diff --git a/lib/cache.ts b/lib/cache.ts new file mode 100644 index 0000000..7e3fbe7 --- /dev/null +++ b/lib/cache.ts @@ -0,0 +1,28 @@ +type CacheItem = { + value: T; + expiresAt: number; +}; + +export class TTLCache { + private store = new Map>(); + + get(key: string): T | null { + const hit = this.store.get(key); + if (!hit) return null; + + if (Date.now() > hit.expiresAt) { + this.store.delete(key); + return null; + } + + return hit.value; + } + + set(key: string, value: T, ttlMs: number): void { + this.store.set(key, { value, expiresAt: Date.now() + ttlMs }); + } + + clear(): void { + this.store.clear(); + } +} diff --git a/lib/github.test.ts b/lib/github.test.ts index 9aec1d0..10438dd 100644 --- a/lib/github.test.ts +++ b/lib/github.test.ts @@ -5,6 +5,8 @@ import { fetchUserProfile, fetchUserRepos, getFullDashboardData, + clearGitHubApiCacheForTests, + GITHUB_CACHE_TTL_MS, } from './github'; import type { ContributionCalendar } from '../types'; @@ -28,6 +30,14 @@ function mockResponse(body: unknown, status = 200): Response { }); } +beforeEach(() => { + clearGitHubApiCacheForTests(); +}); + +afterEach(() => { + clearGitHubApiCacheForTests(); +}); + describe('fetchGitHubContributions', () => { beforeEach(() => { vi.spyOn(global, 'fetch'); @@ -160,9 +170,11 @@ describe('fetchUserRepos', () => { afterEach(() => vi.restoreAllMocks()); it('returns repos data on success', async () => { - vi.mocked(fetch).mockResolvedValue(mockResponse([{ name: 'repo1' }])); + vi.mocked(fetch).mockResolvedValue( + mockResponse([{ stargazers_count: 1, language: 'TypeScript' }]) + ); const result = await fetchUserRepos('octocat'); - expect(result[0].name).toBe('repo1'); + expect(result[0].stargazers_count).toBe(1); }); it('throws status code error on failure', async () => { @@ -247,3 +259,67 @@ describe('getFullDashboardData', () => { await expect(getFullDashboardData('octocat')).rejects.toThrow('An unknown error occurred'); }); }); + +describe('GitHub API cache behavior', () => { + beforeEach(() => { + clearGitHubApiCacheForTests(); + vi.spyOn(global, 'fetch'); + vi.useRealTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + clearGitHubApiCacheForTests(); + }); + + it('cache hit: second contributions call uses cached value', async () => { + vi.mocked(fetch).mockResolvedValue( + mockResponse({ + data: { + user: { contributionsCollection: { contributionCalendar: mockCalendar } }, + }, + }) + ); + + await fetchGitHubContributions('octocat'); + await fetchGitHubContributions('octocat'); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('refresh bypass: bypassCache=true forces a fresh fetch', async () => { + vi.mocked(fetch).mockImplementation(async () => + mockResponse({ + data: { + user: { contributionsCollection: { contributionCalendar: mockCalendar } }, + }, + }) + ); + + await fetchGitHubContributions('octocat'); + await fetchGitHubContributions('octocat', { bypassCache: true }); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('cache expiry: expired entry triggers a new fetch', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + vi.mocked(fetch).mockImplementation(async () => + mockResponse({ + data: { + user: { contributionsCollection: { contributionCalendar: mockCalendar } }, + }, + }) + ); + + await fetchGitHubContributions('octocat'); + + vi.setSystemTime(Date.now() + GITHUB_CACHE_TTL_MS + 1); + await fetchGitHubContributions('octocat'); + + expect(fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/github.ts b/lib/github.ts index f0d4db7..cc593db 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -2,6 +2,7 @@ import type { ContributionCalendar } from '../types'; import { calculateStreak } from './calculate'; +import { TTLCache } from './cache'; interface GitHubRepo { stargazers_count: number; @@ -22,12 +23,55 @@ type GitHubContributionResponse = { errors?: Array<{ message: string }>; }; +type FetchOptions = { + bypassCache?: boolean; +}; + +export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000; + +interface GitHubUserProfile { + login: string; + name: string | null; + avatar_url: string; + public_repos: number; + followers: number; + following: number; + created_at: string; + bio: string | null; + location: string | null; + plan?: { name?: string } | null; +} + +const contributionsCache = new TTLCache(); +const profileCache = new TTLCache(); +const reposCache = new TTLCache(); + +function cacheKey(kind: 'contributions' | 'profile' | 'repos', username: string): string { + return `${kind}:${username.toLowerCase()}`; +} + +export function clearGitHubApiCacheForTests(): void { + contributionsCache.clear(); + profileCache.clear(); + reposCache.clear(); +} + const getHeaders = () => ({ Authorization: `bearer ${process.env.GITHUB_PAT || process.env.GITHUB_TOKEN}`, 'Content-Type': 'application/json', }); -export async function fetchGitHubContributions(username: string): Promise { +export async function fetchGitHubContributions( + username: string, + options: FetchOptions = {} +): Promise { + const key = cacheKey('contributions', username); + + if (!options.bypassCache) { + const cached = contributionsCache.get(key); + if (cached) return cached; + } + const query = ` query($login: String!) { user(login: $login) { @@ -51,7 +95,7 @@ export async function fetchGitHubContributions(username: string): Promise { + const key = cacheKey('profile', username); + + if (!options.bypassCache) { + const cached = profileCache.get(key); + if (cached) return cached; + } + const res = await fetch(`${GITHUB_REST_URL}/users/${username}`, { headers: getHeaders(), cache: 'no-store', @@ -83,10 +143,26 @@ export async function fetchUserProfile(username: string) { throw new Error(`GitHub REST API error: ${res.status}`); } - return res.json(); + const profile = (await res.json()) as GitHubUserProfile; + + if (!options.bypassCache) { + profileCache.set(key, profile, GITHUB_CACHE_TTL_MS); + } + + return profile; } -export async function fetchUserRepos(username: string) { +export async function fetchUserRepos( + username: string, + options: FetchOptions = {} +): Promise { + const key = cacheKey('repos', username); + + if (!options.bypassCache) { + const cached = reposCache.get(key); + if (cached) return cached; + } + const res = await fetch(`${GITHUB_REST_URL}/users/${username}/repos?per_page=100&sort=pushed`, { headers: getHeaders(), cache: 'no-store', @@ -96,15 +172,21 @@ export async function fetchUserRepos(username: string) { throw new Error(`GitHub REST API error: ${res.status}`); } - return res.json(); + const repos = (await res.json()) as GitHubRepo[]; + + if (!options.bypassCache) { + reposCache.set(key, repos, GITHUB_CACHE_TTL_MS); + } + + return repos; } -export async function getFullDashboardData(username: string) { +export async function getFullDashboardData(username: string, options: FetchOptions = {}) { try { const [profileData, reposData, calendarData] = await Promise.all([ - fetchUserProfile(username), - fetchUserRepos(username), - fetchGitHubContributions(username), + fetchUserProfile(username, options), + fetchUserRepos(username, options), + fetchGitHubContributions(username, options), ]); // Pre-compute streak + stars early so developerScore can use them