Skip to content

Commit 5bf99ac

Browse files
authored
fix: use GraphQL for accurate language stats (JhaSourav07#1202)
## Description — What does this PR do? Which issue does it fix? This PR refactors the language data gathering logic in `lib/github.ts`. Previously, language data was only collected when generating the badge SVG, leaving the server-side calculations unaware of the user's languages. Now, it queries `commitContributionsByRepository` to calculate language stats accurately and updates `fetchGitHubContributions` to return both `calendar` and `repoContributions` inside an `ExtendedContributionData` type. Fixes JhaSourav07#979 ## Pillar — Which contribution pillar does this fall under? - [ ] 🚀 New Feature - [ ] 🐛 Bug Fix - [ ] 🎨 New Theme Design - [ ] 📐 Geometric SVG Improvement - [ ] ⏱️ Timezone Logic Optimization - [x] 🧹 Other (Bug fix, refactoring, docs) ## Checklist — Have you ticked off the quality checklist? - [x] I have read the CONTRIBUTING.md guidelines. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] New and existing unit tests pass locally with my changes. - [x] I have kept the scope of my changes strictly related to the issue.
2 parents 0a630e3 + 6e2342e commit 5bf99ac

11 files changed

Lines changed: 212 additions & 69 deletions

File tree

app/api/og/route.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ export async function GET(req: NextRequest) {
5959

6060
// Only the data fetching is wrapped in try/catch — not the JSX rendering.
6161
try {
62-
const calendar = await fetchGitHubContributions(user, { bypassCache: true });
62+
const userData = await fetchGitHubContributions(user, { bypassCache: true });
63+
const calendar = userData.calendar;
6364
const stats = calculateStreak(calendar);
6465
totalCommits = stats.totalContributions;
6566
longestStreak = stats.longestStreak;

app/api/stats/route.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ function makeRequest(params: Record<string, string> = {}): Request {
3838
describe('GET /api/stats', () => {
3939
beforeEach(() => {
4040
vi.clearAllMocks();
41-
vi.mocked(fetchGitHubContributions).mockResolvedValue(mockCalendar);
41+
vi.mocked(fetchGitHubContributions).mockResolvedValue({
42+
calendar: mockCalendar,
43+
repoContributions: [],
44+
});
4245
});
4346

4447
// ─── Parameter validation ──────────────────────────────────────────────────

app/api/stats/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ export async function GET(request: Request) {
4747
}
4848

4949
try {
50-
const calendar = await fetchGitHubContributions(user, { bypassCache: refresh });
50+
const userData = await fetchGitHubContributions(user, { bypassCache: refresh });
51+
const calendar = userData.calendar;
5152
const stats = calculateStreak(calendar, timezone);
5253

5354
return NextResponse.json(

app/api/streak/route.test.ts

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ vi.mock('../../../utils/time', () => ({
1616

1717
import { fetchGitHubContributions, getOrgDashboardData } from '../../../lib/github';
1818
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '../../../utils/time';
19-
import type { ContributionCalendar } from '../../../types';
19+
import type { ContributionCalendar, ExtendedContributionData } from '../../../types';
2020

2121
// Two weeks of realistic data. The last day has 0 contributions so the streak
2222
// is in "grace period" territory — a good baseline that exercises most code paths.
@@ -59,7 +59,10 @@ function makeRequest(params: Record<string, string> = {}): Request {
5959
describe('GET /api/streak', () => {
6060
beforeEach(() => {
6161
vi.clearAllMocks(); // reset call counts so per-test call assertions are isolated
62-
vi.mocked(fetchGitHubContributions).mockResolvedValue(mockCalendar);
62+
vi.mocked(fetchGitHubContributions).mockResolvedValue({
63+
calendar: mockCalendar,
64+
repoContributions: [],
65+
} as unknown as ExtendedContributionData);
6366
vi.mocked(getOrgDashboardData).mockResolvedValue({
6467
profile: {
6568
username: 'octocat',
@@ -849,12 +852,15 @@ describe('GET /api/streak', () => {
849852
vi.setSystemTime(new Date('2026-05-20T12:00:00Z'));
850853

851854
vi.mocked(fetchGitHubContributions).mockResolvedValueOnce({
852-
totalContributions: 25,
853-
weeks: [
854-
{ contributionDays: [{ date: '2024-11-15', contributionCount: 10 }] },
855-
{ contributionDays: [{ date: '2024-12-15', contributionCount: 15 }] },
856-
],
857-
} as ContributionCalendar);
855+
calendar: {
856+
totalContributions: 25,
857+
weeks: [
858+
{ contributionDays: [{ date: '2024-11-15', contributionCount: 10 }] },
859+
{ contributionDays: [{ date: '2024-12-15', contributionCount: 15 }] },
860+
],
861+
} as ContributionCalendar,
862+
repoContributions: [],
863+
} as unknown as ExtendedContributionData);
858864

859865
try {
860866
const response = await GET(
@@ -955,12 +961,15 @@ describe('GET /api/streak', () => {
955961
it('applies delta_format=both to show percent and absolute values in the monthly SVG', async () => {
956962
// 1. Mock the GitHub fetch with actual weekly data using vi.mocked
957963
vi.mocked(fetchGitHubContributions).mockResolvedValueOnce({
958-
totalContributions: 150,
959-
weeks: [
960-
{ contributionDays: [{ date: '2026-04-15', contributionCount: 10 }] },
961-
{ contributionDays: [{ date: '2026-05-15', contributionCount: 15 }] },
962-
],
963-
} as unknown as ContributionCalendar);
964+
calendar: {
965+
totalContributions: 150,
966+
weeks: [
967+
{ contributionDays: [{ date: '2026-04-15', contributionCount: 10 }] },
968+
{ contributionDays: [{ date: '2026-05-15', contributionCount: 15 }] },
969+
],
970+
} as ContributionCalendar,
971+
repoContributions: [],
972+
} as unknown as ExtendedContributionData);
964973

965974
// 2. Lock the system time to May 2026 so the calendar calculation aligns
966975
vi.useFakeTimers();
@@ -987,12 +996,15 @@ describe('GET /api/streak', () => {
987996
it('applies delta_format=absolute to show raw commit counts in the monthly SVG', async () => {
988997
// 1. Mock the GitHub fetch with actual weekly data using vi.mocked
989998
vi.mocked(fetchGitHubContributions).mockResolvedValueOnce({
990-
totalContributions: 150,
991-
weeks: [
992-
{ contributionDays: [{ date: '2026-04-15', contributionCount: 10 }] },
993-
{ contributionDays: [{ date: '2026-05-15', contributionCount: 15 }] },
994-
],
995-
} as unknown as ContributionCalendar);
999+
calendar: {
1000+
totalContributions: 150,
1001+
weeks: [
1002+
{ contributionDays: [{ date: '2026-04-15', contributionCount: 10 }] },
1003+
{ contributionDays: [{ date: '2026-05-15', contributionCount: 15 }] },
1004+
],
1005+
} as ContributionCalendar,
1006+
repoContributions: [],
1007+
} as unknown as ExtendedContributionData);
9961008
// 2. Lock the system time to May 2026 so the calendar calculation aligns
9971009
vi.useFakeTimers();
9981010
vi.setSystemTime(new Date('2026-05-20T12:00:00Z'));
@@ -1032,7 +1044,10 @@ describe('GET /api/streak', () => {
10321044
],
10331045
};
10341046

1035-
vi.mocked(fetchGitHubContributions).mockResolvedValue(emptyCalendar);
1047+
vi.mocked(fetchGitHubContributions).mockResolvedValue({
1048+
calendar: emptyCalendar,
1049+
repoContributions: [],
1050+
} as unknown as ExtendedContributionData);
10361051
const response = await GET(makeRequest({ user: 'octocat' }));
10371052
const body = await response.text();
10381053

app/api/streak/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,18 +180,20 @@ export async function GET(request: Request) {
180180
});
181181
calendar = orgData.calendar;
182182
} else {
183-
calendar = await fetchGitHubContributions(user, {
183+
const userData = await fetchGitHubContributions(user, {
184184
bypassCache: refresh,
185185
from,
186186
to,
187187
});
188+
calendar = userData.calendar;
188189

189190
if (versus) {
190-
versusCalendar = await fetchGitHubContributions(versus, {
191+
const versusData = await fetchGitHubContributions(versus, {
191192
bypassCache: refresh,
192193
from,
193194
to,
194195
});
196+
versusCalendar = versusData.calendar;
195197
}
196198
}
197199

app/api/wrapped/route.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,9 @@ describe('GET /api/wrapped', () => {
4444
beforeEach(() => {
4545
vi.clearAllMocks();
4646
vi.mocked(getWrappedData).mockResolvedValue(mockWrappedStats);
47-
vi.mocked(fetchGitHubContributions).mockResolvedValue(mockCalendar);
47+
vi.mocked(fetchGitHubContributions).mockResolvedValue({
48+
calendar: mockCalendar,
49+
} as unknown as import('../../../types').ExtendedContributionData);
4850
});
4951

5052
describe('parameter validation', () => {

app/api/wrapped/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ export async function GET(request: Request) {
9696
// Fetch calendar contributions for rendering the background mini-monolith
9797
const from = `${year}-01-01T00:00:00Z`;
9898
const to = `${year}-12-31T23:59:59Z`;
99-
const calendar = await fetchGitHubContributions(user, { from, to, bypassCache: refresh });
99+
const { calendar } = await fetchGitHubContributions(user, { from, to, bypassCache: refresh });
100100

101101
const svg = generateWrappedSVG(wrappedStats, params, year, calendar);
102102

lib/github.test.ts

Lines changed: 60 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,16 @@ describe('fetchGitHubContributions', () => {
167167
mockResponse({
168168
data: {
169169
user: {
170-
contributionsCollection: { contributionCalendar: mockCalendar },
170+
contributionsCollection: {
171+
contributionCalendar: mockCalendar,
172+
commitContributionsByRepository: [],
173+
},
171174
},
172175
},
173176
})
174177
);
175178

176-
const result = await fetchGitHubContributions('octocat');
179+
const { calendar: result } = await fetchGitHubContributions('octocat');
177180

178181
expect(result.totalContributions).toBe(mockCalendar.totalContributions);
179182
expect(result.weeks[0].contributionDays[0].contributionCount).toBe(3);
@@ -184,7 +187,10 @@ describe('fetchGitHubContributions', () => {
184187
mockResponse({
185188
data: {
186189
user: {
187-
contributionsCollection: { contributionCalendar: mockCalendar },
190+
contributionsCollection: {
191+
contributionCalendar: mockCalendar,
192+
commitContributionsByRepository: [],
193+
},
188194
},
189195
},
190196
})
@@ -214,7 +220,10 @@ describe('fetchGitHubContributions', () => {
214220
mockResponse({
215221
data: {
216222
user: {
217-
contributionsCollection: { contributionCalendar: mockCalendar },
223+
contributionsCollection: {
224+
contributionCalendar: mockCalendar,
225+
commitContributionsByRepository: [],
226+
},
218227
},
219228
},
220229
})
@@ -254,7 +263,7 @@ describe('fetchGitHubContributions', () => {
254263
})
255264
);
256265

257-
const result = await fetchGitHubContributions('new-user');
266+
const { calendar: result } = await fetchGitHubContributions('new-user');
258267

259268
expect(result.totalContributions).toBe(0);
260269
expect(result.weeks).toHaveLength(0);
@@ -393,7 +402,7 @@ describe('fetchGitHubContributions', () => {
393402
})
394403
);
395404

396-
const result = await fetchGitHubContributions('sparse-user');
405+
const { calendar: result } = await fetchGitHubContributions('sparse-user');
397406
expect(result.totalContributions).toBe(0);
398407
expect(result.weeks).toHaveLength(1);
399408
});
@@ -420,8 +429,8 @@ describe('fetchGitHubContributions', () => {
420429
const r2 = await fetchGitHubContributions('empty-user', {
421430
bypassCache: true,
422431
});
423-
expect(r1.totalContributions).toBe(r2.totalContributions);
424-
expect(r1.weeks).toEqual(r2.weeks);
432+
expect(r1.calendar.totalContributions).toBe(r2.calendar.totalContributions);
433+
expect(r1.calendar.weeks).toEqual(r2.calendar.weeks);
425434
});
426435
});
427436

@@ -705,7 +714,19 @@ describe('getFullDashboardData', () => {
705714
return mockResponse({
706715
data: {
707716
user: {
708-
contributionsCollection: { contributionCalendar: mockCalendar },
717+
contributionsCollection: {
718+
contributionCalendar: mockCalendar,
719+
commitContributionsByRepository: [
720+
{
721+
repository: { primaryLanguage: { name: 'TypeScript' } },
722+
contributions: { totalCount: 200 },
723+
},
724+
{
725+
repository: { primaryLanguage: { name: 'Rust' } },
726+
contributions: { totalCount: 100 },
727+
},
728+
],
729+
},
709730
},
710731
},
711732
});
@@ -784,7 +805,10 @@ describe('getFullDashboardData', () => {
784805
return mockResponse({
785806
data: {
786807
user: {
787-
contributionsCollection: { contributionCalendar: mockCalendar },
808+
contributionsCollection: {
809+
contributionCalendar: mockCalendar,
810+
commitContributionsByRepository: [],
811+
},
788812
},
789813
},
790814
});
@@ -813,7 +837,10 @@ describe('getFullDashboardData', () => {
813837
return mockResponse({
814838
data: {
815839
user: {
816-
contributionsCollection: { contributionCalendar: mockCalendar },
840+
contributionsCollection: {
841+
contributionCalendar: mockCalendar,
842+
commitContributionsByRepository: [],
843+
},
817844
},
818845
},
819846
});
@@ -883,7 +910,10 @@ describe('GitHub API cache behavior', () => {
883910
mockResponse({
884911
data: {
885912
user: {
886-
contributionsCollection: { contributionCalendar: mockCalendar },
913+
contributionsCollection: {
914+
contributionCalendar: mockCalendar,
915+
commitContributionsByRepository: [],
916+
},
887917
},
888918
},
889919
})
@@ -916,14 +946,17 @@ describe('GitHub API cache behavior', () => {
916946
mockResponse({
917947
data: {
918948
user: {
919-
contributionsCollection: { contributionCalendar: mockCalendar },
949+
contributionsCollection: {
950+
contributionCalendar: mockCalendar,
951+
commitContributionsByRepository: [],
952+
},
920953
},
921954
},
922955
})
923956
);
924957

925958
const results = await requests;
926-
expect(results.map((result) => result.totalContributions)).toEqual([42, 42, 42]);
959+
expect(results.map((result) => result.calendar.totalContributions)).toEqual([42, 42, 42]);
927960
});
928961

929962
it('dedupes rapid synchronous contribution requests until the delayed fetch resolves once', async () => {
@@ -967,15 +1000,18 @@ describe('GitHub API cache behavior', () => {
9671000
const results = await Promise.all(requests);
9681001

9691002
expect(resolveFetchSpy).toHaveBeenCalledTimes(1);
970-
expect(results.map((result) => result.totalContributions)).toEqual([42, 42, 42]);
1003+
expect(results.map((result) => result.calendar.totalContributions)).toEqual([42, 42, 42]);
9711004
});
9721005

9731006
it('refresh bypass: bypassCache=true forces a fresh fetch', async () => {
9741007
vi.mocked(fetch).mockImplementation(async () =>
9751008
mockResponse({
9761009
data: {
9771010
user: {
978-
contributionsCollection: { contributionCalendar: mockCalendar },
1011+
contributionsCollection: {
1012+
contributionCalendar: mockCalendar,
1013+
commitContributionsByRepository: [],
1014+
},
9791015
},
9801016
},
9811017
})
@@ -995,7 +1031,10 @@ describe('GitHub API cache behavior', () => {
9951031
mockResponse({
9961032
data: {
9971033
user: {
998-
contributionsCollection: { contributionCalendar: mockCalendar },
1034+
contributionsCollection: {
1035+
contributionCalendar: mockCalendar,
1036+
commitContributionsByRepository: [],
1037+
},
9991038
},
10001039
},
10011040
})
@@ -1372,7 +1411,10 @@ describe('getOrgDashboardData', () => {
13721411
return mockResponse({
13731412
data: {
13741413
user: {
1375-
contributionsCollection: { contributionCalendar: mockCalendar },
1414+
contributionsCollection: {
1415+
contributionCalendar: mockCalendar,
1416+
commitContributionsByRepository: [],
1417+
},
13761418
},
13771419
},
13781420
});

0 commit comments

Comments
 (0)