Skip to content

Commit 487e59d

Browse files
fix: resolve OG image always showing zero stats (JhaSourav07#257)
* fix: resolve OG image always showing zero stats fix: resolve OG image always showing zero stats style: fix prettier formatting in stats route fix: move ImageResponse JSX outside try/catch to satisfy eslint rule fix: restore Cache-Control headers and emoji icons in OG image chore: trigger CI after conflict resolution * fix: resolve OG image always showing zero stats
1 parent 5363800 commit 487e59d

4 files changed

Lines changed: 246 additions & 36 deletions

File tree

app/api/og/route.tsx

Lines changed: 38 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,27 @@
11
import { ImageResponse } from 'next/og';
22
import { NextRequest } from 'next/server';
33
import { ogParamsSchema } from '../../../lib/validations';
4-
5-
export const runtime = 'edge';
4+
import { fetchGitHubContributions } from '../../../lib/github';
5+
import { calculateStreak } from '../../../lib/calculate';
66

77
export async function GET(req: NextRequest) {
88
const { searchParams } = new URL(req.url);
9-
109
const { user } = ogParamsSchema.parse(Object.fromEntries(searchParams.entries()));
1110

1211
let totalCommits = 0;
1312
let longestStreak = 0;
1413
let currentStreak = 0;
1514

15+
// Only the data fetching is wrapped in try/catch — not the JSX rendering.
1616
try {
17-
const baseUrl = req.nextUrl.origin;
18-
19-
const res = await fetch(`${baseUrl}/api/streak?user=${user}`, {
20-
cache: 'no-store',
21-
});
22-
23-
if (res.ok) {
24-
const data = (await res.json()) as {
25-
totalContributions?: number;
26-
longestStreak?: number;
27-
currentStreak?: number;
28-
};
29-
30-
totalCommits = data.totalContributions ?? 0;
31-
longestStreak = data.longestStreak ?? 0;
32-
currentStreak = data.currentStreak ?? 0;
33-
}
34-
} catch {
35-
// fallback
17+
const calendar = await fetchGitHubContributions(user, { bypassCache: true });
18+
const stats = calculateStreak(calendar);
19+
totalCommits = stats.totalContributions;
20+
longestStreak = stats.longestStreak;
21+
currentStreak = stats.currentStreak;
22+
} catch (err) {
23+
console.error('[OG] stats fetch failed:', err);
24+
// fallback to zeros if GitHub is unreachable
3625
}
3726

3827
return new ImageResponse(
@@ -57,6 +46,7 @@ export async function GET(req: NextRequest) {
5746
background: 'radial-gradient(ellipse, #58a6ff22 0%, transparent 70%)',
5847
top: '50px',
5948
left: '300px',
49+
display: 'flex',
6050
}}
6151
/>
6252
<div
@@ -68,12 +58,20 @@ export async function GET(req: NextRequest) {
6858
marginBottom: '24px',
6959
}}
7060
>
71-
⚡ CommitPulse
61+
{'⚡ CommitPulse'}
7262
</div>
7363
<div
74-
style={{ display: 'flex', fontSize: '32px', color: '#c9d1d9', marginBottom: '48px' }}
75-
>{`@${user}`}</div>
64+
style={{
65+
display: 'flex',
66+
fontSize: '32px',
67+
color: '#c9d1d9',
68+
marginBottom: '48px',
69+
}}
70+
>
71+
{`@${user}`}
72+
</div>
7673
<div style={{ display: 'flex', gap: '48px' }}>
74+
{/* Total Commits */}
7775
<div
7876
style={{
7977
display: 'flex',
@@ -85,11 +83,14 @@ export async function GET(req: NextRequest) {
8583
padding: '32px 48px',
8684
}}
8785
>
88-
<div style={{ fontSize: '56px', fontWeight: 'bold', color: '#58a6ff' }}>
89-
{totalCommits}
86+
<div style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: '#58a6ff' }}>
87+
{String(totalCommits)}
88+
</div>
89+
<div style={{ display: 'flex', fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
90+
Total Commits
9091
</div>
91-
<div style={{ fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>Total Commits</div>
9292
</div>
93+
{/* Longest Streak */}
9394
<div
9495
style={{
9596
display: 'flex',
@@ -101,13 +102,14 @@ export async function GET(req: NextRequest) {
101102
padding: '32px 48px',
102103
}}
103104
>
104-
<div style={{ fontSize: '56px', fontWeight: 'bold', color: '#f78166' }}>
105-
{longestStreak}
105+
<div style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: '#f78166' }}>
106+
{String(longestStreak)}
106107
</div>
107-
<div style={{ fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
108-
Longest Streak 🔥
108+
<div style={{ display: 'flex', fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
109+
{'Longest Streak 🔥'}
109110
</div>
110111
</div>
112+
{/* Current Streak */}
111113
<div
112114
style={{
113115
display: 'flex',
@@ -119,11 +121,11 @@ export async function GET(req: NextRequest) {
119121
padding: '32px 48px',
120122
}}
121123
>
122-
<div style={{ fontSize: '56px', fontWeight: 'bold', color: '#3fb950' }}>
123-
{currentStreak}
124+
<div style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: '#3fb950' }}>
125+
{String(currentStreak)}
124126
</div>
125-
<div style={{ fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
126-
Current Streak ⚡
127+
<div style={{ display: 'flex', fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
128+
{'Current Streak ⚡'}
127129
</div>
128130
</div>
129131
</div>

app/api/stats/route.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { GET } from './route';
3+
4+
vi.mock('../../../lib/github', () => ({
5+
fetchGitHubContributions: vi.fn(),
6+
}));
7+
8+
import { fetchGitHubContributions } from '../../../lib/github';
9+
import type { ContributionCalendar } from '../../../types';
10+
11+
// Calendar with a known, predictable streak so assertions are deterministic.
12+
// Last day (2024-06-16) has commits; "today" in tests is set to that date.
13+
const mockCalendar: ContributionCalendar = {
14+
totalContributions: 10,
15+
weeks: [
16+
{
17+
contributionDays: [
18+
{ contributionCount: 1, date: '2024-06-10' },
19+
{ contributionCount: 2, date: '2024-06-11' },
20+
{ contributionCount: 0, date: '2024-06-12' },
21+
{ contributionCount: 3, date: '2024-06-13' },
22+
{ contributionCount: 1, date: '2024-06-14' },
23+
{ contributionCount: 0, date: '2024-06-15' },
24+
{ contributionCount: 3, date: '2024-06-16' },
25+
],
26+
},
27+
],
28+
};
29+
30+
function makeRequest(params: Record<string, string> = {}): Request {
31+
const url = new URL('http://localhost/api/stats');
32+
for (const [key, value] of Object.entries(params)) {
33+
url.searchParams.set(key, value);
34+
}
35+
return new Request(url.toString());
36+
}
37+
38+
describe('GET /api/stats', () => {
39+
beforeEach(() => {
40+
vi.clearAllMocks();
41+
vi.mocked(fetchGitHubContributions).mockResolvedValue(mockCalendar);
42+
});
43+
44+
// ─── Parameter validation ──────────────────────────────────────────────────
45+
46+
it('returns 400 when the user parameter is missing', async () => {
47+
const response = await GET(makeRequest());
48+
expect(response.status).toBe(400);
49+
const body = await response.json();
50+
expect(body.error).toBe('Invalid parameters');
51+
});
52+
53+
it('does not call the GitHub API when user is missing', async () => {
54+
await GET(makeRequest());
55+
expect(fetchGitHubContributions).not.toHaveBeenCalled();
56+
});
57+
58+
it('returns 400 for an unknown timezone', async () => {
59+
const response = await GET(makeRequest({ user: 'testuser', tz: 'Not/ATimezone' }));
60+
expect(response.status).toBe(400);
61+
const body = await response.json();
62+
expect(body.error).toMatch(/Invalid "tz" parameter/);
63+
});
64+
65+
// ─── Successful responses ──────────────────────────────────────────────────
66+
67+
it('returns JSON with the three expected fields', async () => {
68+
const response = await GET(makeRequest({ user: 'testuser' }));
69+
expect(response.status).toBe(200);
70+
71+
const body = await response.json();
72+
expect(body).toHaveProperty('totalContributions');
73+
expect(body).toHaveProperty('longestStreak');
74+
expect(body).toHaveProperty('currentStreak');
75+
});
76+
77+
it('returns Content-Type: application/json', async () => {
78+
const response = await GET(makeRequest({ user: 'testuser' }));
79+
expect(response.headers.get('content-type')).toMatch(/application\/json/);
80+
});
81+
82+
it('returns correct totalContributions from the calendar', async () => {
83+
const response = await GET(makeRequest({ user: 'testuser' }));
84+
const body = await response.json();
85+
expect(body.totalContributions).toBe(10);
86+
});
87+
88+
it('returns numeric values (not strings) for all stat fields', async () => {
89+
const response = await GET(makeRequest({ user: 'testuser' }));
90+
const body = await response.json();
91+
expect(typeof body.totalContributions).toBe('number');
92+
expect(typeof body.longestStreak).toBe('number');
93+
expect(typeof body.currentStreak).toBe('number');
94+
});
95+
96+
it('passes bypassCache=true to GitHub when refresh=true', async () => {
97+
await GET(makeRequest({ user: 'testuser', refresh: 'true' }));
98+
expect(fetchGitHubContributions).toHaveBeenCalledWith('testuser', { bypassCache: true });
99+
});
100+
101+
it('passes bypassCache=false to GitHub when refresh is omitted', async () => {
102+
await GET(makeRequest({ user: 'testuser' }));
103+
expect(fetchGitHubContributions).toHaveBeenCalledWith('testuser', { bypassCache: false });
104+
});
105+
106+
it('accepts a valid IANA timezone without error', async () => {
107+
const response = await GET(makeRequest({ user: 'testuser', tz: 'America/New_York' }));
108+
expect(response.status).toBe(200);
109+
});
110+
111+
// ─── Error handling ────────────────────────────────────────────────────────
112+
113+
it('returns 500 when the GitHub API throws', async () => {
114+
vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('GitHub API error'));
115+
const response = await GET(makeRequest({ user: 'testuser' }));
116+
expect(response.status).toBe(500);
117+
const body = await response.json();
118+
expect(body.error).toBe('GitHub API error');
119+
});
120+
121+
it('returns 500 with a generic message for non-Error throws', async () => {
122+
vi.mocked(fetchGitHubContributions).mockRejectedValue('something went wrong');
123+
const response = await GET(makeRequest({ user: 'testuser' }));
124+
expect(response.status).toBe(500);
125+
const body = await response.json();
126+
expect(body.error).toBe('Unknown error');
127+
});
128+
});

app/api/stats/route.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// app/api/stats/route.ts
2+
import { NextResponse } from 'next/server';
3+
import { fetchGitHubContributions } from '../../../lib/github';
4+
import { calculateStreak } from '../../../lib/calculate';
5+
import { statsParamsSchema } from '../../../lib/validations';
6+
7+
/**
8+
* GET /api/stats?user=<username>[&refresh=true][&tz=<IANA timezone>]
9+
*
10+
* Returns JSON contribution stats for a GitHub user. Used by the OG image
11+
* endpoint (/api/og) and any other consumer that needs numeric stats rather
12+
* than the SVG badge returned by /api/streak.
13+
*
14+
* Response shape:
15+
* {
16+
* "totalContributions": number,
17+
* "longestStreak": number,
18+
* "currentStreak": number
19+
* }
20+
*/
21+
export async function GET(request: Request) {
22+
const { searchParams } = new URL(request.url);
23+
24+
const parseResult = statsParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));
25+
26+
if (!parseResult.success) {
27+
return NextResponse.json(
28+
{
29+
error: 'Invalid parameters',
30+
details: parseResult.error.flatten(),
31+
},
32+
{ status: 400 }
33+
);
34+
}
35+
36+
const { user, refresh, tz } = parseResult.data;
37+
38+
// Validate the optional IANA timezone early so callers get a clear 400
39+
// rather than a silent fallback or a 500.
40+
let timezone = 'UTC';
41+
if (tz) {
42+
try {
43+
timezone = new Intl.DateTimeFormat(undefined, { timeZone: tz }).resolvedOptions().timeZone;
44+
} catch {
45+
return NextResponse.json({ error: `Invalid "tz" parameter: "${tz}"` }, { status: 400 });
46+
}
47+
}
48+
49+
try {
50+
const calendar = await fetchGitHubContributions(user, { bypassCache: refresh });
51+
const stats = calculateStreak(calendar, timezone);
52+
53+
return NextResponse.json(
54+
{
55+
totalContributions: stats.totalContributions,
56+
longestStreak: stats.longestStreak,
57+
currentStreak: stats.currentStreak,
58+
},
59+
{
60+
headers: {
61+
// Cache until next UTC midnight; clients can bust with ?refresh=true
62+
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
63+
},
64+
}
65+
);
66+
} catch (error: unknown) {
67+
const message = error instanceof Error ? error.message : 'Unknown error';
68+
return NextResponse.json({ error: message }, { status: 500 });
69+
}
70+
}

lib/validations.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,16 @@ export const ogParamsSchema = z.object({
9191
user: z.string().optional().default('unknown'),
9292
});
9393

94+
export const statsParamsSchema = z.object({
95+
user: z.string({ error: 'Missing user parameter' }).min(1, { message: 'Missing user parameter' }),
96+
refresh: z
97+
.string()
98+
.optional()
99+
.transform((val) => val === 'true'),
100+
tz: z.string().optional(),
101+
});
102+
94103
export type StreakParams = z.infer<typeof streakParamsSchema>;
95104
export type GithubParams = z.infer<typeof githubParamsSchema>;
96105
export type OgParams = z.infer<typeof ogParamsSchema>;
106+
export type StatsParams = z.infer<typeof statsParamsSchema>;

0 commit comments

Comments
 (0)