Skip to content

Commit 8e03674

Browse files
committed
feat: optimize GitHub contributions fetching and enhance avatar URL handling
1 parent 1376ee0 commit 8e03674

3 files changed

Lines changed: 176 additions & 116 deletions

File tree

src/controllers/stats.controller.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export class StatsController {
1212
private static CACHE_DURATION: number;
1313
private static pendingRequests: Map<string, Promise<string>> = new Map();
1414
private static pngCache: Map<string, { data: Buffer; timestamp: number }> = new Map();
15+
private static pendingWebpRequests: Map<string, Promise<Buffer>> = new Map();
1516
static routeDocs = {
1617
requiredParams: ['username'],
1718
optionalParams: [
@@ -157,16 +158,33 @@ export class StatsController {
157158
return res.send(cachedWebp.data);
158159
}
159160

160-
const svgCard = await getSvgCard();
161+
const pendingWebp = StatsController.pendingWebpRequests.get(webpCacheKey);
162+
if (pendingWebp) {
163+
const webpBuffer = await pendingWebp;
164+
timings['total'] = Date.now() - startTime;
165+
res.setHeader('X-Timing', JSON.stringify(timings));
166+
res.setHeader('Content-Type', 'image/webp');
167+
res.setHeader('Cache-Control', 'public, max-age=600');
168+
return res.send(webpBuffer);
169+
}
170+
171+
const webpPromise = (async () => {
172+
const svgCard = await getSvgCard();
173+
const webpStartTime = Date.now();
174+
const webpBuffer = await sharp(Buffer.from(svgCard))
175+
.webp({ quality: 75, effort: 4, alphaQuality: 100 })
176+
.toBuffer();
177+
timings['webp_convert'] = Date.now() - webpStartTime;
178+
StatsController.pngCache.set(webpCacheKey, { data: webpBuffer, timestamp: Date.now() });
179+
return webpBuffer;
180+
})();
181+
182+
StatsController.pendingWebpRequests.set(webpCacheKey, webpPromise);
161183

162-
const webpStartTime = Date.now();
163-
// Sharp uses native C++ bindings for efficient SVG→WebP conversion
164-
const webpBuffer = await sharp(Buffer.from(svgCard))
165-
.webp({ quality: 75, effort: 4, alphaQuality: 100 })
166-
.toBuffer();
167-
timings['webp_convert'] = Date.now() - webpStartTime;
184+
const webpBuffer = await webpPromise.finally(() => {
185+
StatsController.pendingWebpRequests.delete(webpCacheKey);
186+
});
168187

169-
StatsController.pngCache.set(webpCacheKey, { data: webpBuffer, timestamp: Date.now() });
170188
timings['total'] = Date.now() - startTime;
171189
res.setHeader('X-Timing', JSON.stringify(timings));
172190
res.setHeader('Content-Type', 'image/webp');

src/services/github.service.ts

Lines changed: 75 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export class GitHubService extends BaseService {
118118
private getDefaultStats(username: string): GitHubStats {
119119
return {
120120
name: username,
121-
avatarUrl: `https://avatars.githubusercontent.com/u/0?v=4?s=130`,
121+
avatarUrl: 'https://avatars.githubusercontent.com/u/0?v=4',
122122
totalStars: 0,
123123
totalCommits: 0,
124124
totalPRs: 0,
@@ -131,6 +131,75 @@ export class GitHubService extends BaseService {
131131
};
132132
}
133133

134+
private buildContributionYearRanges(createdAt: Date): Array<{ from: string; to: string }> {
135+
const now = new Date();
136+
const years: Array<{ from: string; to: string }> = [];
137+
let yearStart = new Date(createdAt.getFullYear(), 0, 1);
138+
139+
while (yearStart <= now) {
140+
const yearEnd = new Date(yearStart.getFullYear(), 11, 31, 23, 59, 59);
141+
years.push({
142+
from: (yearStart > createdAt ? yearStart : createdAt).toISOString(),
143+
to: (yearEnd > now ? now : yearEnd).toISOString(),
144+
});
145+
yearStart = new Date(yearStart.getFullYear() + 1, 0, 1);
146+
}
147+
148+
return years;
149+
}
150+
151+
private async fetchTotalCommitContributions(username: string, createdAt: string): Promise<number> {
152+
const ranges = this.buildContributionYearRanges(new Date(createdAt));
153+
154+
if (ranges.length === 0) {
155+
return 0;
156+
}
157+
158+
const variableDefinitions = ranges
159+
.map((_, index) => `$from${index}: DateTime!, $to${index}: DateTime!`)
160+
.join(', ');
161+
const contributionSelections = ranges
162+
.map((_, index) => `year${index}: contributionsCollection(from: $from${index}, to: $to${index}) { totalCommitContributions restrictedContributionsCount }`)
163+
.join('\n');
164+
165+
const query = `
166+
query($username: String!, ${variableDefinitions}) {
167+
user(login: $username) {
168+
${contributionSelections}
169+
}
170+
}
171+
`;
172+
173+
const variables: Record<string, string> = { username };
174+
ranges.forEach((range, index) => {
175+
variables[`from${index}`] = range.from;
176+
variables[`to${index}`] = range.to;
177+
});
178+
179+
const result: any = await this.octokit.graphql(query, variables);
180+
const user = result.user;
181+
182+
if (!user) {
183+
return 0;
184+
}
185+
186+
return ranges.reduce((sum, _, index) => {
187+
const contributionYear = user[`year${index}`];
188+
return sum + (contributionYear?.totalCommitContributions || 0) + (contributionYear?.restrictedContributionsCount || 0);
189+
}, 0);
190+
}
191+
192+
private withAvatarMode(stats: GitHubStats, avatarMode: 'none' | 'avatar' | 'radar'): GitHubStats {
193+
if (avatarMode === 'none') {
194+
return stats;
195+
}
196+
197+
return {
198+
...stats,
199+
avatarUrl: `${stats.avatarUrl}${stats.avatarUrl.includes('?') ? '&' : '?'}s=130`,
200+
};
201+
}
202+
134203
/**
135204
* Calculate user rank based on stats
136205
*/
@@ -162,7 +231,7 @@ export class GitHubService extends BaseService {
162231
options: { avatarMode: 'none' | 'avatar' | 'radar' }
163232
): Promise<GitHubStats> {
164233
return this.executeWithLogging(`fetchUserStats(${username})`, async () => {
165-
return this.cachedRequest(`user-stats-${username}-${options.avatarMode}`, async () => {
234+
const stats = await this.cachedRequest(`user-stats-${username}`, async () => {
166235
try {
167236
// Use GraphQL to get all-time stats in a single request
168237
const query = `
@@ -217,64 +286,14 @@ export class GitHubService extends BaseService {
217286
const totalPRs = userData.pullRequests.totalCount;
218287
const totalIssues = userData.issues.totalCount;
219288

220-
// Get all-time commits by summing contributions from account creation to now
221-
const createdAt = new Date(userData.createdAt);
222-
const now = new Date();
223-
let totalCommits = 0;
224-
225-
// Fetch commits year by year (GitHub only allows 1 year at a time)
226-
const years: { from: Date; to: Date }[] = [];
227-
let yearStart = new Date(createdAt.getFullYear(), 0, 1);
228-
229-
while (yearStart <= now) {
230-
const yearEnd = new Date(yearStart.getFullYear(), 11, 31, 23, 59, 59);
231-
years.push({
232-
from: yearStart > createdAt ? yearStart : createdAt,
233-
to: yearEnd > now ? now : yearEnd
234-
});
235-
yearStart = new Date(yearStart.getFullYear() + 1, 0, 1);
236-
}
237-
238-
// Fetch all years' contributions in parallel
239-
const commitPromises = years.map(async ({ from, to }) => {
240-
const commitQuery = `
241-
query($username: String!, $from: DateTime!, $to: DateTime!) {
242-
user(login: $username) {
243-
contributionsCollection(from: $from, to: $to) {
244-
totalCommitContributions
245-
restrictedContributionsCount
246-
}
247-
}
248-
}
249-
`;
250-
try {
251-
const result: any = await this.octokit.graphql(commitQuery, {
252-
username,
253-
from: from.toISOString(),
254-
to: to.toISOString()
255-
});
256-
const collection = result.user?.contributionsCollection;
257-
return (collection?.totalCommitContributions || 0) + (collection?.restrictedContributionsCount || 0);
258-
} catch {
259-
return 0;
260-
}
261-
});
262-
263-
const yearlyCommits = await Promise.all(commitPromises);
264-
totalCommits = yearlyCommits.reduce((sum, count) => sum + count, 0);
289+
const totalCommits = await this.fetchTotalCommitContributions(username, userData.createdAt);
265290

266291
// Calculate rank
267292
const rank = this.calculateRank(totalStars, totalCommits, totalPRs, totalIssues);
268293

269-
// Format avatar URL
270-
let avatarUrl = userData.avatarUrl;
271-
if (options.avatarMode !== 'none') {
272-
avatarUrl = `${userData.avatarUrl}${userData.avatarUrl.includes('?') ? '&' : '?'}s=130`;
273-
}
274-
275294
return {
276295
name: userData.name || username,
277-
avatarUrl,
296+
avatarUrl: userData.avatarUrl,
278297
totalStars,
279298
totalCommits,
280299
totalPRs,
@@ -286,6 +305,8 @@ export class GitHubService extends BaseService {
286305
return this.handleGitHubError(error, username);
287306
}
288307
});
308+
309+
return this.withAvatarMode(stats, options.avatarMode);
289310
});
290311
}
291312

src/utils/github-client.ts

Lines changed: 75 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export class GitHubClient {
5151
private getDefaultStats(username: string): GitHubStats {
5252
return {
5353
name: username,
54-
avatarUrl: `https://avatars.githubusercontent.com/u/0?v=4?s=130`,
54+
avatarUrl: 'https://avatars.githubusercontent.com/u/0?v=4',
5555
totalStars: 0,
5656
totalCommits: 0,
5757
totalPRs: 0,
@@ -64,9 +64,78 @@ export class GitHubClient {
6464
};
6565
}
6666

67+
private buildContributionYearRanges(createdAt: Date): Array<{ from: string; to: string }> {
68+
const now = new Date();
69+
const years: Array<{ from: string; to: string }> = [];
70+
let yearStart = new Date(createdAt.getFullYear(), 0, 1);
71+
72+
while (yearStart <= now) {
73+
const yearEnd = new Date(yearStart.getFullYear(), 11, 31, 23, 59, 59);
74+
years.push({
75+
from: (yearStart > createdAt ? yearStart : createdAt).toISOString(),
76+
to: (yearEnd > now ? now : yearEnd).toISOString(),
77+
});
78+
yearStart = new Date(yearStart.getFullYear() + 1, 0, 1);
79+
}
80+
81+
return years;
82+
}
83+
84+
private async fetchTotalCommitContributions(username: string, createdAt: string): Promise<number> {
85+
const ranges = this.buildContributionYearRanges(new Date(createdAt));
86+
87+
if (ranges.length === 0) {
88+
return 0;
89+
}
90+
91+
const variableDefinitions = ranges
92+
.map((_, index) => `$from${index}: DateTime!, $to${index}: DateTime!`)
93+
.join(', ');
94+
const contributionSelections = ranges
95+
.map((_, index) => `year${index}: contributionsCollection(from: $from${index}, to: $to${index}) { totalCommitContributions restrictedContributionsCount }`)
96+
.join('\n');
97+
98+
const query = `
99+
query($username: String!, ${variableDefinitions}) {
100+
user(login: $username) {
101+
${contributionSelections}
102+
}
103+
}
104+
`;
105+
106+
const variables: Record<string, string> = { username };
107+
ranges.forEach((range, index) => {
108+
variables[`from${index}`] = range.from;
109+
variables[`to${index}`] = range.to;
110+
});
111+
112+
const result: any = await this.octokit.graphql(query, variables);
113+
const user = result.user;
114+
115+
if (!user) {
116+
return 0;
117+
}
118+
119+
return ranges.reduce((sum, _, index) => {
120+
const contributionYear = user[`year${index}`];
121+
return sum + (contributionYear?.totalCommitContributions || 0) + (contributionYear?.restrictedContributionsCount || 0);
122+
}, 0);
123+
}
124+
125+
private withAvatarMode(stats: GitHubStats, avatarMode: 'none' | 'avatar' | 'radar'): GitHubStats {
126+
if (avatarMode === 'none') {
127+
return stats;
128+
}
129+
130+
return {
131+
...stats,
132+
avatarUrl: `${stats.avatarUrl}${stats.avatarUrl.includes('?') ? '&' : '?'}s=130`,
133+
};
134+
}
135+
67136
async fetchUserStats(username: string, options: { avatarMode: 'none' | 'avatar' | 'radar' }): Promise<GitHubStats> {
68137
try {
69-
return await this.cachedRequest(`user-stats-${username}`, async () => {
138+
const stats = await this.cachedRequest(`user-stats-${username}`, async () => {
70139
// Use GraphQL to get all-time stats in a single request
71140
const query = `
72141
query($username: String!) {
@@ -122,64 +191,14 @@ export class GitHubClient {
122191
const totalPRs = userData.pullRequests.totalCount;
123192
const totalIssues = userData.issues.totalCount;
124193

125-
// Get all-time commits by summing contributions from account creation to now
126-
const createdAt = new Date(userData.createdAt);
127-
const now = new Date();
128-
let totalCommits = 0;
129-
130-
// Fetch commits year by year (GitHub only allows 1 year at a time for contributionsCollection)
131-
const years: { from: Date; to: Date }[] = [];
132-
let yearStart = new Date(createdAt.getFullYear(), 0, 1);
133-
134-
while (yearStart <= now) {
135-
const yearEnd = new Date(yearStart.getFullYear(), 11, 31, 23, 59, 59);
136-
years.push({
137-
from: yearStart > createdAt ? yearStart : createdAt,
138-
to: yearEnd > now ? now : yearEnd
139-
});
140-
yearStart = new Date(yearStart.getFullYear() + 1, 0, 1);
141-
}
142-
143-
// Fetch all years' contributions in parallel
144-
const commitPromises = years.map(async ({ from, to }) => {
145-
const commitQuery = `
146-
query($username: String!, $from: DateTime!, $to: DateTime!) {
147-
user(login: $username) {
148-
contributionsCollection(from: $from, to: $to) {
149-
totalCommitContributions
150-
restrictedContributionsCount
151-
}
152-
}
153-
}
154-
`;
155-
try {
156-
const result: any = await this.octokit.graphql(commitQuery, {
157-
username,
158-
from: from.toISOString(),
159-
to: to.toISOString()
160-
});
161-
const collection = result.user?.contributionsCollection;
162-
return (collection?.totalCommitContributions || 0) + (collection?.restrictedContributionsCount || 0);
163-
} catch {
164-
return 0;
165-
}
166-
});
167-
168-
const yearlyCommits = await Promise.all(commitPromises);
169-
totalCommits = yearlyCommits.reduce((sum, count) => sum + count, 0);
194+
const totalCommits = await this.fetchTotalCommitContributions(username, userData.createdAt);
170195

171196
// Calculate rank
172197
const rank = this.calculateRank(totalStars, totalCommits, totalPRs, totalIssues);
173198

174-
// Format avatar URL
175-
let avatarUrl = userData.avatarUrl;
176-
if (options.avatarMode !== 'none') {
177-
avatarUrl = `${userData.avatarUrl}${userData.avatarUrl.includes('?') ? '&' : '?'}s=130`;
178-
}
179-
180199
return {
181200
name: userData.name || username,
182-
avatarUrl,
201+
avatarUrl: userData.avatarUrl,
183202
totalStars,
184203
totalCommits,
185204
totalPRs,
@@ -188,6 +207,8 @@ export class GitHubClient {
188207
rank,
189208
};
190209
});
210+
211+
return this.withAvatarMode(stats, options.avatarMode);
191212
} catch (error: any) {
192213
// Check if it's a rate limit error
193214
if (error.status === 403 && error.message?.includes('rate limit')) {

0 commit comments

Comments
 (0)