Skip to content

Commit 7817ae0

Browse files
authored
feat: support merging multiple user contribution calendars via comma separated list (JhaSourav07#2188)
## Description Fixes JhaSourav07#2183 Adds support for multi-user contribution "Skyline Grid" merges via a comma-separated list of individual usernames (e.g. `user=user1,user2`). This allows partners, collaborators, or hackathon teams to combine their contribution calendars into a unified skyline. ### Key Changes - **Validation Schema (`lib/validations.ts`)**: Relaxes and improves validation in `baseStreakParamsSchema.user` using Zod `.superRefine()`. Each username in the comma-separated list is trimmed and individually validated, preserving existing validation error messages exactly. - **Concurrent Fetching & Aggregation (`app/api/streak/route.ts`)**: Splitting by commas to concurrently fetch calendars from GitHub, filtering out failed fetches while allowing successful ones, and merging them using `aggregateCalendars`. - **Title Override**: Sets the display user title to a concatenation of the first two names (e.g., `USER1 + USER2`) in the generated SVG layout. - **Unit & Integration Tests**: Comprehensive tests added to `lib/validations.test.ts` and `app/api/streak/route.test.ts`. ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [ ] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview *(Optional: here)* ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents eb203ef + 44e5236 commit 7817ae0

4 files changed

Lines changed: 138 additions & 47 deletions

File tree

app/api/streak/route.test.ts

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,53 +1391,50 @@ describe('GET /api/streak', () => {
13911391
});
13921392
});
13931393

1394-
describe('JSON output mode (format=json)', () => {
1395-
it('returns JSON with correct Content-Type when format=json is set', async () => {
1396-
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1394+
describe('multi-user skyline merges', () => {
1395+
it('fetches calendars concurrently, aggregates them, and overrides the title in the SVG', async () => {
1396+
vi.mocked(fetchGitHubContributions)
1397+
.mockResolvedValueOnce({
1398+
calendar: mockCalendar,
1399+
repoContributions: [],
1400+
} as unknown as ExtendedContributionData)
1401+
.mockResolvedValueOnce({
1402+
calendar: mockCalendar,
1403+
repoContributions: [],
1404+
} as unknown as ExtendedContributionData);
1405+
1406+
const response = await GET(makeRequest({ user: 'a, b' }));
13971407
expect(response.status).toBe(200);
1398-
expect(response.headers.get('Content-Type')).toContain('application/json');
1399-
});
14001408

1401-
it('returns stats, monthlyStats, and calendar in JSON response', async () => {
1402-
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1403-
const data = await response.json();
1409+
expect(fetchGitHubContributions).toHaveBeenCalledWith('a', expect.any(Object));
1410+
expect(fetchGitHubContributions).toHaveBeenCalledWith('b', expect.any(Object));
14041411

1405-
expect(data.user).toBe('octocat');
1406-
expect(data.stats).toBeDefined();
1407-
expect(data.stats.currentStreak).toBeDefined();
1408-
expect(data.stats.longestStreak).toBeDefined();
1409-
expect(data.stats.totalContributions).toBeDefined();
1410-
expect(data.monthlyStats).toBeDefined();
1411-
expect(data.monthlyStats.currentMonthTotal).toBeDefined();
1412-
expect(data.calendar).toBeDefined();
1413-
expect(data.calendar.totalContributions).toBe(10);
1414-
expect(data.calendar.weeks).toHaveLength(2);
1412+
const body = await response.text();
1413+
expect(body).toContain('A + B');
14151414
});
14161415

1417-
it('includes Cache-Control header in JSON response', async () => {
1418-
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1419-
expect(response.headers.get('Cache-Control')).toContain('s-maxage=');
1420-
});
1416+
it('gracefully handles partial fetch failures by filtering out failed calendars', async () => {
1417+
vi.mocked(fetchGitHubContributions)
1418+
.mockResolvedValueOnce({
1419+
calendar: mockCalendar,
1420+
repoContributions: [],
1421+
} as unknown as ExtendedContributionData)
1422+
.mockRejectedValueOnce(new Error('GitHub user "b" not found'));
14211423

1422-
it('includes X-Cache-Status header in JSON response', async () => {
1423-
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1424-
expect(response.headers.get('X-Cache-Status')).toBe('HIT');
1425-
});
1424+
const response = await GET(makeRequest({ user: 'a, b' }));
1425+
expect(response.status).toBe(200);
14261426

1427-
it('returns SVG when format is not set (default)', async () => {
1428-
const response = await GET(makeRequest({ user: 'octocat' }));
1429-
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
1427+
const body = await response.text();
1428+
expect(body).toContain('A + B');
14301429
});
14311430

1432-
it('falls back to SVG for invalid format values', async () => {
1433-
const response = await GET(makeRequest({ user: 'octocat', format: 'xml' }));
1434-
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
1435-
});
1431+
it('returns a 404/error response when all users in the list fail to load', async () => {
1432+
vi.mocked(fetchGitHubContributions)
1433+
.mockRejectedValueOnce(new Error('GitHub user "a" not found'))
1434+
.mockRejectedValueOnce(new Error('GitHub user "b" not found'));
14361435

1437-
it('uses org name as user field when org parameter is provided', async () => {
1438-
const response = await GET(makeRequest({ user: 'octocat', org: 'github', format: 'json' }));
1439-
const data = await response.json();
1440-
expect(data.user).toBe('github');
1436+
const response = await GET(makeRequest({ user: 'a, b' }));
1437+
expect(response.status).toBe(404);
14411438
});
14421439
});
14431440
});

app/api/streak/route.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { NextResponse } from 'next/server';
44
import { fetchGitHubContributions, getOrgDashboardData } from '@/lib/github';
5-
import { calculateStreak, calculateMonthlyStats } from '@/lib/calculate';
5+
import { calculateStreak, calculateMonthlyStats, aggregateCalendars } from '@/lib/calculate';
66
import {
77
generateNotFoundSVG,
88
generateRateLimitSVG,
@@ -13,7 +13,7 @@ import {
1313
generatePulseSVG,
1414
} from '@/lib/svg/generator';
1515
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time';
16-
import type { BadgeParams } from '@/types';
16+
import type { BadgeParams, ContributionCalendar } from '@/types';
1717
import { themes } from '@/lib/svg/themes';
1818
import { streakParamsSchema } from '@/lib/validations';
1919
import { sanitizeHexColor } from '@/lib/svg/sanitizer';
@@ -132,7 +132,15 @@ export async function GET(request: Request) {
132132
})();
133133

134134
// If 'org' is provided, we use it as the display user
135-
const targetEntity = org || user;
135+
const targetEntity =
136+
org ||
137+
(user.includes(',')
138+
? user
139+
.split(',')
140+
.map((u) => u.trim())
141+
.slice(0, 2)
142+
.join(' + ')
143+
: user);
136144
const borderParam = searchParams.get('border');
137145
const sanitizedBorder = borderParam ? borderParam.replace(/[^a-fA-F0-9]/g, '') : undefined;
138146
const animate = searchParams.get('animate') !== 'false';
@@ -182,6 +190,34 @@ export async function GET(request: Request) {
182190
to,
183191
});
184192
calendar = orgData.calendar;
193+
} else if (user.includes(',')) {
194+
const users = user
195+
.split(',')
196+
.map((u) => u.trim())
197+
.filter(Boolean);
198+
let lastError: unknown = null;
199+
const fetchedCalendars = await Promise.all(
200+
users.map(async (u) => {
201+
try {
202+
const userData = await fetchGitHubContributions(u, {
203+
bypassCache: refresh,
204+
from,
205+
to,
206+
});
207+
return userData.calendar;
208+
} catch (err) {
209+
lastError = err;
210+
return null;
211+
}
212+
})
213+
);
214+
const successfulCalendars = fetchedCalendars.filter(
215+
(c): c is ContributionCalendar => c !== null
216+
);
217+
if (successfulCalendars.length === 0) {
218+
throw lastError || new Error('No successful calendars fetched');
219+
}
220+
calendar = aggregateCalendars(successfulCalendars);
185221
} else {
186222
const userData = await fetchGitHubContributions(user, {
187223
bypassCache: refresh,

lib/validations.test.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,15 +135,44 @@ describe('streakParamsSchema user validation', () => {
135135
expect(result.success).toBe(true);
136136
});
137137

138-
it('should enforce a maximum length constraint of 39 characters for the user parameter', () => {
139-
const invalidUser = 'a'.repeat(40);
138+
it('should pass when user is a comma-separated list of valid usernames', () => {
140139
const result = streakParamsSchema.safeParse({
141-
user: invalidUser,
140+
user: 'octocat, JhaSourav07, nishtha-agarwal-211',
141+
});
142+
143+
expect(result.success).toBe(true);
144+
});
145+
146+
it('should fail when one of the usernames in the list is invalid', () => {
147+
const result = streakParamsSchema.safeParse({
148+
user: 'octocat, invalid_name_with_spaces',
142149
});
143150

144151
expect(result.success).toBe(false);
145152
if (!result.success) {
146-
expect(result.error.issues[0]?.message).toMatch(/cannot exceed 39 characters/);
153+
expect(result.error.issues[0]?.message).toBe('Invalid GitHub username');
154+
}
155+
});
156+
157+
it('should fail when one of the usernames in the list exceeds 39 characters', () => {
158+
const result = streakParamsSchema.safeParse({
159+
user: `octocat, ${'a'.repeat(40)}`,
160+
});
161+
162+
expect(result.success).toBe(false);
163+
if (!result.success) {
164+
expect(result.error.issues[0]?.message).toBe('GitHub username cannot exceed 39 characters');
165+
}
166+
});
167+
168+
it('should fail when list has empty usernames due to consecutive or trailing commas', () => {
169+
const result = streakParamsSchema.safeParse({
170+
user: 'octocat, , JhaSourav07',
171+
});
172+
173+
expect(result.success).toBe(false);
174+
if (!result.success) {
175+
expect(result.error.issues[0]?.message).toBe('Invalid GitHub username');
147176
}
148177
});
149178
});

lib/validations.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,38 @@ const baseStreakParamsSchema = z.object({
7575
user: z
7676
.string({ error: 'Missing user parameter' })
7777
.min(1, { message: 'Missing user parameter' })
78-
.max(39, { message: 'GitHub username cannot exceed 39 characters' })
79-
.regex(GITHUB_USERNAME_REGEX, {
80-
message: 'Invalid GitHub username',
78+
.superRefine((val, ctx) => {
79+
const users = val.split(',').map((u) => u.trim());
80+
if (users.length === 0) {
81+
ctx.addIssue({
82+
code: z.ZodIssueCode.custom,
83+
message: 'Missing user parameter',
84+
});
85+
return;
86+
}
87+
for (const u of users) {
88+
if (u.length === 0) {
89+
ctx.addIssue({
90+
code: z.ZodIssueCode.custom,
91+
message: 'Invalid GitHub username',
92+
});
93+
return;
94+
}
95+
if (u.length > 39) {
96+
ctx.addIssue({
97+
code: z.ZodIssueCode.custom,
98+
message: 'GitHub username cannot exceed 39 characters',
99+
});
100+
return;
101+
}
102+
if (!GITHUB_USERNAME_REGEX.test(u)) {
103+
ctx.addIssue({
104+
code: z.ZodIssueCode.custom,
105+
message: 'Invalid GitHub username',
106+
});
107+
return;
108+
}
109+
}
81110
}),
82111

83112
theme: z

0 commit comments

Comments
 (0)