Skip to content

Commit 05751c3

Browse files
Merge branch 'main' into test-wrapped-date-range
2 parents bed06f2 + 9999e0e commit 05751c3

41 files changed

Lines changed: 1702 additions & 226 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ yarn-error.log*
3333
# env files — ignore all local secrets but commit the example template
3434
.env*
3535
!.env.local.example
36-
36+
.env.local
3737
# vercel
3838
.vercel
3939

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,14 @@ Yes — if private contributions visibility is enabled in your GitHub settings.
451451

452452
### Are there GitHub API rate limits?
453453

454-
Yes, but CommitPulse minimizes API usage using caching and optimized GraphQL queries.
454+
Yes. CommitPulse minimizes API usage via caching and optimized GraphQL queries, but if you hit the GitHub API rate limit (typically 5,000 requests per hour for authenticated users), you might see errors or missing data.
455+
456+
#### Troubleshooting Rate Limit Errors
457+
458+
1. **Wait it out:** Rate limits automatically reset every hour.
459+
2. **Provide your own PAT:** If self-hosting, ensure you've provided a valid `GITHUB_TOKEN` in `.env.local` to get the authenticated rate limit.
460+
3. **Avoid aggressive bypassing:** Avoid repeatedly using the `&refresh=true` parameter, which bypasses the cache and consumes API quota on every load.
461+
4. **Check GitHub API Status:** Occasionally, GitHub's GraphQL API itself experiences degradation. Check [githubstatus.com](https://www.githubstatus.com/).
455462

456463
---
457464

app/(root)/dashboard/[username]/page.test.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,17 +103,33 @@ describe('DashboardPage', () => {
103103
});
104104

105105
describe('generateMetadata', () => {
106-
it('generates correct metadata for a given user', async () => {
106+
it('generates correct metadata for a given user and forwards valid searchParams', async () => {
107107
const username = 'octocat';
108108
const metadata = await generateMetadata({
109109
params: Promise.resolve({ username }),
110+
searchParams: Promise.resolve({
111+
theme: 'neon',
112+
bg: '000000',
113+
text: '00ff00',
114+
accent: 'ff00ff',
115+
ignoredArray: ['a', 'b'],
116+
ignoredUndefined: undefined,
117+
}),
110118
});
111119

112120
const openGraphImage = (metadata.openGraph?.images as any[])?.[0];
113121

114122
expect(metadata.title).toBe("octocat's Commit Pulse");
115123
expect(metadata.description).toContain("octocat's GitHub contribution pulse");
116-
expect(openGraphImage.url).toContain('api/og?user=octocat');
124+
const url = openGraphImage.url;
125+
expect(url).toContain('api/og?');
126+
expect(url).toContain('user=octocat');
127+
expect(url).toContain('theme=neon');
128+
expect(url).toContain('bg=000000');
129+
expect(url).toContain('text=00ff00');
130+
expect(url).toContain('accent=ff00ff');
131+
expect(url).not.toContain('ignoredArray');
132+
expect(url).not.toContain('ignoredUndefined');
117133
expect(openGraphImage.width).toBe(1200);
118134
expect(openGraphImage.height).toBe(630);
119135
expect(openGraphImage.alt).toContain(username);

app/(root)/dashboard/[username]/page.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,24 @@ const BASE_URL =
1313

1414
export async function generateMetadata({
1515
params,
16+
searchParams,
1617
}: {
1718
params: Promise<{ username: string }>;
19+
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
1820
}): Promise<Metadata> {
1921
const { username } = await params;
20-
const ogImage = `${BASE_URL}/api/og?user=${username}`;
22+
const resolvedSearchParams = await searchParams;
23+
24+
const queryParams = new URLSearchParams({ user: username });
25+
if (typeof resolvedSearchParams?.theme === 'string')
26+
queryParams.set('theme', resolvedSearchParams.theme);
27+
if (typeof resolvedSearchParams?.bg === 'string') queryParams.set('bg', resolvedSearchParams.bg);
28+
if (typeof resolvedSearchParams?.text === 'string')
29+
queryParams.set('text', resolvedSearchParams.text);
30+
if (typeof resolvedSearchParams?.accent === 'string')
31+
queryParams.set('accent', resolvedSearchParams.accent);
32+
33+
const ogImage = `${BASE_URL}/api/og?${queryParams.toString()}`;
2134
const title = `${username}'s Commit Pulse`;
2235
const description = `Check out ${username}'s GitHub contribution pulse — streaks, insights, and more on CommitPulse.`;
2336

app/api/og/route.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,42 @@ describe('OG Route', () => {
5252

5353
expect(res.status).toBe(200);
5454
});
55+
56+
it('handles custom themes and valid custom colors without crashing', async () => {
57+
vi.mocked(fetchGitHubContributions).mockResolvedValue({} as never);
58+
vi.mocked(calculateStreak).mockReturnValue({
59+
totalContributions: 120,
60+
longestStreak: 20,
61+
currentStreak: 5,
62+
todayDate: '2026-05-27',
63+
});
64+
65+
// Uses 4-digit hex shorthand for bg to ensure getLuminance handles it
66+
const req = new NextRequest(
67+
'http://localhost:3000/api/og?user=testuser&theme=dracula&bg=000&text=ffffff&accent=ff0000'
68+
);
69+
const res = await GET(req as never);
70+
71+
expect(res).toBeDefined();
72+
expect(res.status).toBe(200);
73+
});
74+
75+
it('handles invalid custom themes and invalid colors gracefully with fallbacks', async () => {
76+
vi.mocked(fetchGitHubContributions).mockResolvedValue({} as never);
77+
vi.mocked(calculateStreak).mockReturnValue({
78+
totalContributions: 120,
79+
longestStreak: 20,
80+
currentStreak: 5,
81+
todayDate: '2026-05-27',
82+
});
83+
84+
// Invalid theme and invalid hexes
85+
const req = new NextRequest(
86+
'http://localhost:3000/api/og?user=testuser&theme=non_existent_theme_xyz&bg=not-hex&text=xyz&accent=12'
87+
);
88+
const res = await GET(req as never);
89+
90+
expect(res).toBeDefined();
91+
expect(res.status).toBe(200);
92+
});
5593
});

app/api/og/route.tsx

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,43 @@
33
import { ImageResponse } from 'next/og';
44
import { NextRequest } from 'next/server';
55
import { ogParamsSchema } from '@/lib/validations';
6+
import { themes } from '@/lib/svg/themes';
67
import { fetchGitHubContributions } from '@/lib/github';
78
import { calculateStreak } from '@/lib/calculate';
89

10+
function getLuminance(hex: string) {
11+
let normalizedHex = hex.trim();
12+
// Normalize short hex (e.g., #fff or #ffff) to #rrggbb (alpha is ignored for luminance)
13+
if (normalizedHex.length === 4 || normalizedHex.length === 5) {
14+
normalizedHex = `#${normalizedHex[1]}${normalizedHex[1]}${normalizedHex[2]}${normalizedHex[2]}${normalizedHex[3]}${normalizedHex[3]}`;
15+
}
16+
const r = parseInt(normalizedHex.slice(1, 3), 16) / 255 || 0;
17+
const g = parseInt(normalizedHex.slice(3, 5), 16) / 255 || 0;
18+
const b = parseInt(normalizedHex.slice(5, 7), 16) / 255 || 0;
19+
20+
const [R, G, B] = [r, g, b].map((c) =>
21+
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
22+
);
23+
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
24+
}
25+
926
export async function GET(req: NextRequest) {
1027
const { searchParams } = new URL(req.url);
11-
const { user } = ogParamsSchema.parse(Object.fromEntries(searchParams.entries()));
28+
29+
const { user, theme, bg, text, accent } = ogParamsSchema.parse(
30+
Object.fromEntries(searchParams.entries())
31+
);
32+
33+
const selectedTheme = themes[theme] || themes.dark;
34+
const resolvedBg = `#${bg || selectedTheme.bg}`;
35+
const resolvedText = `#${text || selectedTheme.text}`;
36+
const resolvedAccent = `#${accent || selectedTheme.accent}`;
37+
38+
const luminance = getLuminance(resolvedBg);
39+
const isLight = luminance > 0.5;
40+
const cardBg = isLight ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.08)';
41+
const cardBorder = isLight ? 'rgba(0,0,0,0.08)' : 'rgba(255,255,255,0.1)';
42+
const subText = isLight ? '#666666' : '#8b949e';
1243

1344
let totalCommits = 0;
1445
let longestStreak = 0;
@@ -31,7 +62,7 @@ export async function GET(req: NextRequest) {
3162
style={{
3263
width: '1200px',
3364
height: '630px',
34-
background: '#0d1117',
65+
background: resolvedBg,
3566
display: 'flex',
3667
flexDirection: 'column',
3768
alignItems: 'center',
@@ -45,7 +76,7 @@ export async function GET(req: NextRequest) {
4576
position: 'absolute',
4677
width: '600px',
4778
height: '300px',
48-
background: 'radial-gradient(ellipse, #58a6ff22 0%, transparent 70%)',
79+
background: `radial-gradient(ellipse, ${resolvedAccent}33 0%, transparent 70%)`,
4980
top: '50px',
5081
left: '300px',
5182
display: 'flex',
@@ -55,21 +86,14 @@ export async function GET(req: NextRequest) {
5586
style={{
5687
display: 'flex',
5788
fontSize: '48px',
58-
color: '#58a6ff',
89+
color: resolvedAccent,
5990
fontWeight: 'bold',
6091
marginBottom: '24px',
6192
}}
6293
>
6394
{'⚡ CommitPulse'}
6495
</div>
65-
<div
66-
style={{
67-
display: 'flex',
68-
fontSize: '32px',
69-
color: '#c9d1d9',
70-
marginBottom: '48px',
71-
}}
72-
>
96+
<div style={{ display: 'flex', fontSize: '32px', color: resolvedText, marginBottom: '48px' }}>
7397
{`@${user}`}
7498
</div>
7599
<div style={{ display: 'flex', gap: '48px' }}>
@@ -79,16 +103,18 @@ export async function GET(req: NextRequest) {
79103
display: 'flex',
80104
flexDirection: 'column',
81105
alignItems: 'center',
82-
background: '#161b22',
83-
border: '1px solid #30363d',
106+
background: cardBg,
107+
border: `1px solid ${cardBorder}`,
84108
borderRadius: '16px',
85109
padding: '32px 48px',
86110
}}
87111
>
88-
<div style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: '#58a6ff' }}>
112+
<div
113+
style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: resolvedAccent }}
114+
>
89115
{String(totalCommits)}
90116
</div>
91-
<div style={{ display: 'flex', fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
117+
<div style={{ display: 'flex', fontSize: '18px', color: subText, marginTop: '8px' }}>
92118
Total Commits
93119
</div>
94120
</div>
@@ -98,16 +124,18 @@ export async function GET(req: NextRequest) {
98124
display: 'flex',
99125
flexDirection: 'column',
100126
alignItems: 'center',
101-
background: '#161b22',
102-
border: '1px solid #30363d',
127+
background: cardBg,
128+
border: `1px solid ${cardBorder}`,
103129
borderRadius: '16px',
104130
padding: '32px 48px',
105131
}}
106132
>
107-
<div style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: '#f78166' }}>
133+
<div
134+
style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: resolvedAccent }}
135+
>
108136
{String(longestStreak)}
109137
</div>
110-
<div style={{ display: 'flex', fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
138+
<div style={{ display: 'flex', fontSize: '18px', color: subText, marginTop: '8px' }}>
111139
{'Longest Streak 🔥'}
112140
</div>
113141
</div>
@@ -117,16 +145,18 @@ export async function GET(req: NextRequest) {
117145
display: 'flex',
118146
flexDirection: 'column',
119147
alignItems: 'center',
120-
background: '#161b22',
121-
border: '1px solid #30363d',
148+
background: cardBg,
149+
border: `1px solid ${cardBorder}`,
122150
borderRadius: '16px',
123151
padding: '32px 48px',
124152
}}
125153
>
126-
<div style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: '#3fb950' }}>
154+
<div
155+
style={{ display: 'flex', fontSize: '56px', fontWeight: 'bold', color: resolvedAccent }}
156+
>
127157
{String(currentStreak)}
128158
</div>
129-
<div style={{ display: 'flex', fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
159+
<div style={{ display: 'flex', fontSize: '18px', color: subText, marginTop: '8px' }}>
130160
{'Current Streak ⚡'}
131161
</div>
132162
</div>
@@ -137,7 +167,7 @@ export async function GET(req: NextRequest) {
137167
position: 'absolute',
138168
bottom: '32px',
139169
fontSize: '16px',
140-
color: '#484f58',
170+
color: subText,
141171
}}
142172
>
143173
commitpulse.vercel.app

app/api/streak/route.test.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ describe('GET /api/streak', () => {
6969
const response = await GET(makeRequest());
7070

7171
expect(response.status).toBe(400);
72-
const body = await response.text();
73-
expect(body).toContain('Missing');
72+
const body = await response.json();
73+
expect(response.status).toBe(400);
74+
expect(body.error).toBe('Invalid parameters');
75+
expect(body.details).not.toBeNull();
76+
expect(typeof body.details).toBe('object');
77+
expect(Array.isArray(body.details)).toBe(false);
7478
});
7579

7680
it('does not hit the GitHub API at all when user is missing', async () => {
@@ -90,6 +94,20 @@ describe('GET /api/streak', () => {
9094

9195
expect(fetchGitHubContributions).not.toHaveBeenCalled();
9296
});
97+
it('returns 400 when user contains spaces', async () => {
98+
const response = await GET(makeRequest({ user: 'john doe' }));
99+
const body = await response.json();
100+
101+
expect(response.status).toBe(400);
102+
expect(body.details.fieldErrors.user[0]).toContain('Invalid GitHub username');
103+
});
104+
105+
it('returns 400 when user exceeds 39 characters', async () => {
106+
const response = await GET(makeRequest({ user: 'a'.repeat(40) }));
107+
expect(response.status).toBe(400);
108+
const body = await response.json();
109+
expect(JSON.stringify(body)).toContain('cannot exceed 39 characters');
110+
});
93111

94112
it('returns 400 for invalid monthly badge dimensions', async () => {
95113
const invalidDimensionParams: Array<Record<string, string>> = [
@@ -109,6 +127,17 @@ describe('GET /api/streak', () => {
109127

110128
expect(fetchGitHubContributions).not.toHaveBeenCalled();
111129
});
130+
131+
it('should return 200 OK and valid SVG when the optional repo query parameter is provided', async () => {
132+
// 1. Make request with both parameters present
133+
const response = await GET(makeRequest({ user: 'octocat', repo: 'commitpulse' }));
134+
135+
// 2. Assert definitions of done
136+
expect(response.status).toBe(200);
137+
138+
const textOutput = await response.text();
139+
expect(textOutput).toContain('<svg');
140+
});
112141
});
113142

114143
describe('successful response', () => {
@@ -140,6 +169,14 @@ describe('GET /api/streak', () => {
140169
// The generator puts params.user.toUpperCase() in the SVG as the badge title.
141170
expect(body).toContain('OCTOCAT');
142171
});
172+
173+
it('should contain a <title> element with accessible label in the SVG response', async () => {
174+
const response = await GET(makeRequest({ user: 'octocat' }));
175+
const body = await response.text();
176+
177+
expect(body).toContain('<title>');
178+
expect(body).toContain('Stats for');
179+
});
143180
});
144181

145182
describe('cache-control header', () => {
@@ -484,7 +521,7 @@ describe('GET /api/streak', () => {
484521
it('does not crash when an invalid text color is provided', async () => {
485522
const response = await GET(makeRequest({ user: 'octocat', text: 'notacolor' }));
486523

487-
expect(response.status).toBe(200);
524+
expect(response.status).toBe(400);
488525
});
489526
});
490527

@@ -672,7 +709,7 @@ describe('GET /api/streak', () => {
672709
it('returns no-cache header when ?theme=random is given', async () => {
673710
const response = await GET(makeRequest({ user: 'octocat', theme: 'random' }));
674711

675-
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate');
712+
expect(response.headers.get('Cache-Control')).toMatch(/public, s-maxage=/);
676713
});
677714
});
678715

0 commit comments

Comments
 (0)