From 0dac9fb0d7955198225ff295dd8f02c3e69eabb3 Mon Sep 17 00:00:00 2001 From: nishtha-agarwal-211 Date: Mon, 1 Jun 2026 00:19:01 +0530 Subject: [PATCH] feat: support merging multiple user contribution calendars via comma-separated list --- app/api/streak/route.test.ts | 47 ++++++++++++++++++++++++++++++++++++ app/api/streak/route.ts | 42 +++++++++++++++++++++++++++++--- lib/validations.test.ts | 41 +++++++++++++++++++++++++++++++ lib/validations.ts | 35 ++++++++++++++++++++++++--- 4 files changed, 159 insertions(+), 6 deletions(-) diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts index acb539d8..06784589 100644 --- a/app/api/streak/route.test.ts +++ b/app/api/streak/route.test.ts @@ -1337,4 +1337,51 @@ describe('GET /api/streak', () => { expect(body).toContain('strictly for organizations'); }); }); + + describe('multi-user skyline merges', () => { + it('fetches calendars concurrently, aggregates them, and overrides the title in the SVG', async () => { + vi.mocked(fetchGitHubContributions) + .mockResolvedValueOnce({ + calendar: mockCalendar, + repoContributions: [], + } as unknown as ExtendedContributionData) + .mockResolvedValueOnce({ + calendar: mockCalendar, + repoContributions: [], + } as unknown as ExtendedContributionData); + + const response = await GET(makeRequest({ user: 'a, b' })); + expect(response.status).toBe(200); + + expect(fetchGitHubContributions).toHaveBeenCalledWith('a', expect.any(Object)); + expect(fetchGitHubContributions).toHaveBeenCalledWith('b', expect.any(Object)); + + const body = await response.text(); + expect(body).toContain('A + B'); + }); + + it('gracefully handles partial fetch failures by filtering out failed calendars', async () => { + vi.mocked(fetchGitHubContributions) + .mockResolvedValueOnce({ + calendar: mockCalendar, + repoContributions: [], + } as unknown as ExtendedContributionData) + .mockRejectedValueOnce(new Error('GitHub user "b" not found')); + + const response = await GET(makeRequest({ user: 'a, b' })); + expect(response.status).toBe(200); + + const body = await response.text(); + expect(body).toContain('A + B'); + }); + + it('returns a 404/error response when all users in the list fail to load', async () => { + vi.mocked(fetchGitHubContributions) + .mockRejectedValueOnce(new Error('GitHub user "a" not found')) + .mockRejectedValueOnce(new Error('GitHub user "b" not found')); + + const response = await GET(makeRequest({ user: 'a, b' })); + expect(response.status).toBe(404); + }); + }); }); diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index c32a0c03..bfc63cc9 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'; import { fetchGitHubContributions, getOrgDashboardData } from '@/lib/github'; -import { calculateStreak, calculateMonthlyStats } from '@/lib/calculate'; +import { calculateStreak, calculateMonthlyStats, aggregateCalendars } from '@/lib/calculate'; import { generateNotFoundSVG, generateRateLimitSVG, @@ -13,7 +13,7 @@ import { generatePulseSVG, } from '@/lib/svg/generator'; import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time'; -import type { BadgeParams } from '@/types'; +import type { BadgeParams, ContributionCalendar } from '@/types'; import { themes } from '@/lib/svg/themes'; import { streakParamsSchema } from '@/lib/validations'; @@ -127,7 +127,15 @@ export async function GET(request: Request) { })(); // If 'org' is provided, we use it as the display user - const targetEntity = org || user; + const targetEntity = + org || + (user.includes(',') + ? user + .split(',') + .map((u) => u.trim()) + .slice(0, 2) + .join(' + ') + : user); const borderParam = searchParams.get('border'); const sanitizedBorder = borderParam ? borderParam.replace(/[^a-fA-F0-9]/g, '') : undefined; const animate = searchParams.get('animate') !== 'false'; @@ -176,6 +184,34 @@ export async function GET(request: Request) { to, }); calendar = orgData.calendar; + } else if (user.includes(',')) { + const users = user + .split(',') + .map((u) => u.trim()) + .filter(Boolean); + let lastError: unknown = null; + const fetchedCalendars = await Promise.all( + users.map(async (u) => { + try { + const userData = await fetchGitHubContributions(u, { + bypassCache: refresh, + from, + to, + }); + return userData.calendar; + } catch (err) { + lastError = err; + return null; + } + }) + ); + const successfulCalendars = fetchedCalendars.filter( + (c): c is ContributionCalendar => c !== null + ); + if (successfulCalendars.length === 0) { + throw lastError || new Error('No successful calendars fetched'); + } + calendar = aggregateCalendars(successfulCalendars); } else { const userData = await fetchGitHubContributions(user, { bypassCache: refresh, diff --git a/lib/validations.test.ts b/lib/validations.test.ts index 20d46212..35fe0bbc 100644 --- a/lib/validations.test.ts +++ b/lib/validations.test.ts @@ -98,6 +98,47 @@ describe('streakParamsSchema user validation', () => { expect(result.success).toBe(true); }); + + it('should pass when user is a comma-separated list of valid usernames', () => { + const result = streakParamsSchema.safeParse({ + user: 'octocat, JhaSourav07, nishtha-agarwal-211', + }); + + expect(result.success).toBe(true); + }); + + it('should fail when one of the usernames in the list is invalid', () => { + const result = streakParamsSchema.safeParse({ + user: 'octocat, invalid_name_with_spaces', + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Invalid GitHub username'); + } + }); + + it('should fail when one of the usernames in the list exceeds 39 characters', () => { + const result = streakParamsSchema.safeParse({ + user: `octocat, ${'a'.repeat(40)}`, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('GitHub username cannot exceed 39 characters'); + } + }); + + it('should fail when list has empty usernames due to consecutive or trailing commas', () => { + const result = streakParamsSchema.safeParse({ + user: 'octocat, , JhaSourav07', + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0]?.message).toBe('Invalid GitHub username'); + } + }); }); describe('streakParamsSchema', () => { diff --git a/lib/validations.ts b/lib/validations.ts index 9d653267..d326df5a 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -65,9 +65,38 @@ const baseStreakParamsSchema = z.object({ user: z .string({ error: 'Missing user parameter' }) .min(1, { message: 'Missing user parameter' }) - .max(39, { message: 'GitHub username cannot exceed 39 characters' }) - .regex(GITHUB_USERNAME_REGEX, { - message: 'Invalid GitHub username', + .superRefine((val, ctx) => { + const users = val.split(',').map((u) => u.trim()); + if (users.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Missing user parameter', + }); + return; + } + for (const u of users) { + if (u.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid GitHub username', + }); + return; + } + if (u.length > 39) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'GitHub username cannot exceed 39 characters', + }); + return; + } + if (!GITHUB_USERNAME_REGEX.test(u)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid GitHub username', + }); + return; + } + } }), theme: z.string().default('dark'),