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
8 changes: 6 additions & 2 deletions app/api/github/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion app/api/streak/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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' }));
Expand Down
5 changes: 3 additions & 2 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
28 changes: 28 additions & 0 deletions lib/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
type CacheItem<T> = {
value: T;
expiresAt: number;
};

export class TTLCache<T> {
private store = new Map<string, CacheItem<T>>();

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();
}
}
80 changes: 78 additions & 2 deletions lib/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
fetchUserProfile,
fetchUserRepos,
getFullDashboardData,
clearGitHubApiCacheForTests,
GITHUB_CACHE_TTL_MS,
} from './github';
import type { ContributionCalendar } from '../types';

Expand All @@ -28,6 +30,14 @@ function mockResponse(body: unknown, status = 200): Response {
});
}

beforeEach(() => {
clearGitHubApiCacheForTests();
});

afterEach(() => {
clearGitHubApiCacheForTests();
});

describe('fetchGitHubContributions', () => {
beforeEach(() => {
vi.spyOn(global, 'fetch');
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
104 changes: 93 additions & 11 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type { ContributionCalendar } from '../types';
import { calculateStreak } from './calculate';
import { TTLCache } from './cache';

interface GitHubRepo {
stargazers_count: number;
Expand All @@ -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<ContributionCalendar>();
const profileCache = new TTLCache<GitHubUserProfile>();
const reposCache = new TTLCache<GitHubRepo[]>();

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<ContributionCalendar> {
export async function fetchGitHubContributions(
username: string,
options: FetchOptions = {}
): Promise<ContributionCalendar> {
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) {
Expand All @@ -51,7 +95,7 @@ export async function fetchGitHubContributions(username: string): Promise<Contri
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ query, variables: { login: username } }),
cache: 'no-store', // Cache handled at the API route
cache: 'no-store', // Cache handled by our in-memory layer + API route headers
});

if (!res.ok) {
Expand All @@ -69,10 +113,26 @@ export async function fetchGitHubContributions(username: string): Promise<Contri
throw new Error(`GitHub user "${username}" not found`);
}

return data.data.user.contributionsCollection.contributionCalendar;
const calendar = data.data.user.contributionsCollection.contributionCalendar;

if (!options.bypassCache) {
contributionsCache.set(key, calendar, GITHUB_CACHE_TTL_MS);
}

return calendar;
}

export async function fetchUserProfile(username: string) {
export async function fetchUserProfile(
username: string,
options: FetchOptions = {}
): Promise<GitHubUserProfile> {
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',
Expand All @@ -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<GitHubRepo[]> {
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',
Expand All @@ -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
Expand Down
Loading