Skip to content

Commit cc606f9

Browse files
authored
Merge branch 'main' into test/2279-feature-cards
2 parents 455d020 + 2dda1bb commit cc606f9

65 files changed

Lines changed: 5377 additions & 167 deletions

File tree

Some content is hidden

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

.github/scripts/check-duplicates.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ function cosineSimilarity(vecA, vecB) {
1818

1919
async function run() {
2020
const geminiApiKey = process.env.GEMINI_API_KEY;
21-
const githubToken = process.env.GITHUB_TOKEN;
21+
const githubToken = process.env.GITHUB_PAT || process.env.GITHUB_TOKEN;
2222

2323
if (!geminiApiKey) {
2424
throw new Error('❌ Missing GEMINI_API_KEY environment variable.');
2525
}
2626
if (!githubToken) {
27-
throw new Error('❌ Missing GITHUB_TOKEN environment variable.');
27+
throw new Error('❌ Missing GITHUB_PAT or GITHUB_TOKEN environment variable.');
2828
}
2929

3030
const octokit = github.getOctokit(githubToken);

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ URL Parameter > Theme Default > System Fallback
191191
| `shading` | `boolean` | No | `false` | Apply intensity-based opacity shading to tower faces so lower intensity levels appear slightly dimmer |
192192
| `opacity` | `number` | No | `1.0` | Global opacity scalar for all tower fill-opacity values (0.1–1.0). `opacity=0.5` = semi-transparent ghost look. `opacity=0.8` = faded, great on light backgrounds. |
193193
| `gradient` | `boolean` | No | `false` | Opt-in to show volumetric gradients on the monolith floor |
194+
| `badges` | `boolean` | No | `false` | Render dynamic glowing milestone badges (e.g., 365-day streak, 1K+ commits) on the SVG |
194195

195196
### Grace Period Examples
196197

app/api/notify/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ export async function POST(req: NextRequest): Promise<NextResponse<NotificationR
3737
// Rate limiting
3838
const ip = getClientIp(req);
3939

40-
if (ip !== 'unknown' && !(await notifyRateLimiter.check(ip))) {
40+
// fallback ensures rate limit is ALWAYS applied
41+
const rateLimitKey =
42+
ip && ip !== 'unknown' ? ip : `unknown:${req.headers.get('user-agent') ?? 'no-agent'}`;
43+
44+
if (!(await notifyRateLimiter.check(rateLimitKey))) {
4145
return NextResponse.json(
4246
{ success: false, message: 'Too many requests, please try again later.' },
4347
{ status: 429 }

app/api/streak/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export async function GET(request: Request) {
9999
glow,
100100
format,
101101
days,
102+
badges,
102103
} = parseResult.data;
103104
const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse';
104105
const themeName = theme || 'dark';
@@ -182,6 +183,7 @@ export async function GET(request: Request) {
182183
disable_particles,
183184
glow,
184185
animate,
186+
badges,
185187
};
186188

187189
let calendar;

app/api/track-user/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ export async function POST(req: Request) {
99
// Get IP for rate limiting securely
1010
const ip = getClientIp(req);
1111

12-
if (ip !== '127.0.0.1' && ip !== 'unknown' && !(await trackUserRateLimiter.check(ip))) {
12+
const rateLimitKey = ip === 'unknown' ? 'unknown-client' : ip;
13+
14+
if (ip !== '127.0.0.1' && !(await trackUserRateLimiter.check(rateLimitKey))) {
1315
return NextResponse.json(
1416
{ success: false, error: 'Too many requests, please try again later.' },
1517
{ status: 429 }

app/api/user-details/route.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { GET } from './route';
3+
4+
vi.mock('@/lib/github', () => ({
5+
fetchUserProfile: vi.fn(),
6+
fetchGitHubContributions: vi.fn(),
7+
}));
8+
9+
import { fetchUserProfile, fetchGitHubContributions } from '@/lib/github';
10+
import type { ContributionCalendar } from '@/types';
11+
12+
const mockCalendar: ContributionCalendar = {
13+
totalContributions: 15,
14+
weeks: [
15+
{
16+
contributionDays: [
17+
{ contributionCount: 5, date: '2024-06-10' },
18+
{ contributionCount: 5, date: '2024-06-11' },
19+
{ contributionCount: 5, date: '2024-06-12' },
20+
],
21+
},
22+
],
23+
};
24+
25+
const mockProfile = {
26+
login: 'testuser',
27+
name: 'Test User',
28+
avatar_url: 'https://github.com/testuser.png',
29+
public_repos: 12,
30+
};
31+
32+
function makeRequest(params: Record<string, string> = {}): Request {
33+
const url = new URL('http://localhost/api/user-details');
34+
for (const [key, value] of Object.entries(params)) {
35+
url.searchParams.set(key, value);
36+
}
37+
return new Request(url.toString());
38+
}
39+
40+
describe('GET /api/user-details', () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
44+
vi.mocked(fetchUserProfile).mockResolvedValue(mockProfile as any);
45+
vi.mocked(fetchGitHubContributions).mockResolvedValue({
46+
calendar: mockCalendar,
47+
repoContributions: [],
48+
totalPRs: 0,
49+
totalIssues: 0,
50+
});
51+
});
52+
53+
it('returns 400 when username is missing', async () => {
54+
const response = await GET(makeRequest());
55+
expect(response.status).toBe(400);
56+
const body = await response.json();
57+
expect(body.error).toBe('Username is required');
58+
});
59+
60+
it('returns 400 when username format is invalid', async () => {
61+
const response = await GET(makeRequest({ username: 'invalid_user_name_@' }));
62+
expect(response.status).toBe(400);
63+
const body = await response.json();
64+
expect(body.error).toBe('Invalid username format');
65+
});
66+
67+
it('returns 200 with user details and streak stats on success', async () => {
68+
const response = await GET(makeRequest({ username: 'testuser' }));
69+
expect(response.status).toBe(200);
70+
const body = await response.json();
71+
expect(body).toEqual({
72+
exists: true,
73+
login: 'testuser',
74+
name: 'Test User',
75+
avatar_url: 'https://github.com/testuser.png',
76+
public_repos: 12,
77+
stats: {
78+
currentStreak: 3,
79+
longestStreak: 3,
80+
totalContributions: 15,
81+
},
82+
});
83+
});
84+
85+
it('returns 404 when user is not found', async () => {
86+
vi.mocked(fetchUserProfile).mockRejectedValue(new Error('User not found'));
87+
const response = await GET(makeRequest({ username: 'missinguser' }));
88+
expect(response.status).toBe(404);
89+
const body = await response.json();
90+
expect(body.error).toBe('User not found');
91+
});
92+
93+
it('gracefully handles contributions fetch failure and returns profile details', async () => {
94+
vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('API limit reached'));
95+
const response = await GET(makeRequest({ username: 'testuser' }));
96+
expect(response.status).toBe(200);
97+
const body = await response.json();
98+
expect(body.stats).toEqual({
99+
currentStreak: 0,
100+
longestStreak: 0,
101+
totalContributions: 0,
102+
});
103+
});
104+
});

app/api/user-details/route.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { NextResponse } from 'next/server';
2+
import { fetchUserProfile, fetchGitHubContributions } from '@/lib/github';
3+
import { calculateStreak } from '@/lib/calculate';
4+
import { validateGitHubUsername } from '@/lib/validations';
5+
6+
export async function GET(request: Request) {
7+
const { searchParams } = new URL(request.url);
8+
const username = searchParams.get('username')?.trim();
9+
10+
if (!username) {
11+
return NextResponse.json({ error: 'Username is required' }, { status: 400 });
12+
}
13+
14+
if (!validateGitHubUsername(username)) {
15+
return NextResponse.json({ error: 'Invalid username format' }, { status: 400 });
16+
}
17+
18+
try {
19+
const [profile, contributions] = await Promise.all([
20+
fetchUserProfile(username),
21+
fetchGitHubContributions(username).catch(() => null),
22+
]);
23+
24+
let stats = { currentStreak: 0, longestStreak: 0, totalContributions: 0 };
25+
if (contributions) {
26+
const calculated = calculateStreak(contributions.calendar);
27+
stats = {
28+
currentStreak: calculated.currentStreak,
29+
longestStreak: calculated.longestStreak,
30+
totalContributions: calculated.totalContributions,
31+
};
32+
}
33+
34+
return NextResponse.json({
35+
exists: true,
36+
login: profile.login,
37+
name: profile.name,
38+
avatar_url: profile.avatar_url,
39+
public_repos: profile.public_repos,
40+
stats,
41+
});
42+
} catch (error: unknown) {
43+
const message = error instanceof Error ? error.message : '';
44+
if (message.includes('not found') || message.includes('404')) {
45+
return NextResponse.json({ error: 'User not found' }, { status: 404 });
46+
}
47+
return NextResponse.json({ error: message || 'Failed to fetch user details' }, { status: 500 });
48+
}
49+
}

app/compare/CompareClient.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737

3838
/* ── types ────────────────────────────────────────────────────────────── */
3939

40-
interface UserProfile {
40+
export interface UserProfile {
4141
username: string;
4242
name: string;
4343
avatarUrl: string;
@@ -54,7 +54,7 @@ interface UserProfile {
5454
};
5555
}
5656

57-
interface UserStats {
57+
export interface UserStats {
5858
currentStreak: number;
5959
peakStreak: number;
6060
totalContributions: number;
@@ -63,28 +63,28 @@ interface UserStats {
6363
totalIssues?: number;
6464
}
6565

66-
interface LanguageData {
66+
export interface LanguageData {
6767
name: string;
6868
color: string;
6969
percentage: number;
7070
}
7171

72-
interface ActivityData {
72+
export interface ActivityData {
7373
date: string;
7474
count: number;
7575
intensity: 0 | 1 | 2 | 3 | 4;
7676
locAdditions?: number;
7777
locDeletions?: number;
7878
}
7979

80-
interface CompareUserData {
80+
export interface CompareUserData {
8181
profile: UserProfile;
8282
stats: UserStats;
8383
languages: LanguageData[];
8484
activity: ActivityData[];
8585
}
8686

87-
interface CompareResponse {
87+
export interface CompareResponse {
8888
user1: CompareUserData;
8989
user2: CompareUserData;
9090
error?: string;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expectTypeOf, it } from 'vitest';
2+
3+
import type {
4+
ActivityData,
5+
CompareResponse,
6+
CompareUserData,
7+
LanguageData,
8+
UserProfile,
9+
UserStats,
10+
} from './CompareClient';
11+
12+
describe('CompareClient Type Compiler Validation', () => {
13+
it('validates UserProfile structure', () => {
14+
expectTypeOf<UserProfile>().toEqualTypeOf<{
15+
username: string;
16+
name: string;
17+
avatarUrl: string;
18+
isPro: boolean;
19+
bio: string;
20+
location: string;
21+
joinedDate: string;
22+
developerScore: number;
23+
stats: {
24+
repositories: number;
25+
followers: number;
26+
following: number;
27+
stars: number;
28+
};
29+
}>();
30+
});
31+
32+
it('validates UserStats numeric and optional fields', () => {
33+
expectTypeOf<UserStats>().toEqualTypeOf<{
34+
currentStreak: number;
35+
peakStreak: number;
36+
totalContributions: number;
37+
codingHabit?: string;
38+
totalPRs?: number;
39+
totalIssues?: number;
40+
}>();
41+
});
42+
43+
it('validates LanguageData structure', () => {
44+
expectTypeOf<LanguageData>().toEqualTypeOf<{
45+
name: string;
46+
color: string;
47+
percentage: number;
48+
}>();
49+
});
50+
51+
it('accepts optional fields in ActivityData without compile errors', () => {
52+
expectTypeOf<ActivityData>().toEqualTypeOf<{
53+
date: string;
54+
count: number;
55+
intensity: 0 | 1 | 2 | 3 | 4;
56+
locAdditions?: number;
57+
locDeletions?: number;
58+
}>();
59+
});
60+
61+
it('validates CompareResponse structure', () => {
62+
expectTypeOf<CompareResponse>().toEqualTypeOf<{
63+
user1: CompareUserData;
64+
user2: CompareUserData;
65+
error?: string;
66+
}>();
67+
});
68+
});

0 commit comments

Comments
 (0)