Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions app/api/streak/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,54 @@ describe('GET /api/streak', () => {
});
});

describe('edge cases for empty/private profiles', () => {
it('Scenario 1: Normal active GitHub user', async () => {
const response = await GET(makeRequest({ user: 'octocat' }));
expect(response.status).toBe(200);
const body = await response.text();
expect(body).toContain('<svg');
});

it('Scenario 2 & 3: User with 0 public repositories or private profile (empty calendar)', async () => {
vi.mocked(fetchGitHubContributions).mockResolvedValue({
calendar: {
totalContributions: 0,
weeks: [],
},
repoContributions: [],
} as unknown as ExtendedContributionData);

const response = await GET(makeRequest({ user: 'private-user' }));
expect(response.status).toBe(200);
const body = await response.text();
expect(body).toContain('<svg');
// Should show 0 contributions and streaks
expect(body).toContain('>0<');
});

it('Scenario 4: Nonexistent username', async () => {
vi.mocked(fetchGitHubContributions).mockRejectedValue(
new Error('GitHub user "nonexistent" not found')
);

const response = await GET(makeRequest({ user: 'nonexistent' }));
expect(response.status).toBe(404);
const body = await response.text();
expect(body).toContain('<svg');
expect(body).toContain('NOT FOUND');
});

it('Scenario 5: GitHub API failure', async () => {
vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('API Rate Limit Exceeded'));

const response = await GET(makeRequest({ user: 'octocat' }));
expect(response.status).toBe(429);
const body = await response.text();
expect(body).toContain('<svg');
expect(body).toContain('API RATE LIMIT');
});
});

describe('cache-control header', () => {
it('caches until UTC midnight by default, using the value from getSecondsUntilUTCMidnight', async () => {
const response = await GET(makeRequest({ user: 'octocat' }));
Expand Down
20 changes: 11 additions & 9 deletions lib/calculate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export function calculateStreak(
now: Date = new Date(),
grace: number = 1
): StreakStats {
const weeks = calendar.weeks;
const days = weeks.flatMap((week) => week.contributionDays);
const weeks = calendar?.weeks || [];
const days = weeks.flatMap((week) => week?.contributionDays || []);

let currentStreak = 0;
let longestStreak = 0;
Expand Down Expand Up @@ -95,7 +95,8 @@ export function calculateMonthlyStats(
timezone: string = 'UTC',
now: Date = new Date()
): MonthlyStats {
const days = calendar.weeks.flatMap((week) => week.contributionDays);
const weeks = calendar?.weeks || [];
const days = weeks.flatMap((week) => week?.contributionDays || []);

const localTodayStr = new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
const [currentYearStr, currentMonthStr] = localTodayStr.split('-');
Expand Down Expand Up @@ -171,13 +172,13 @@ export function aggregateCalendars(calendars: ContributionCalendar[]): Contribut
// Find the calendar with the most weeks to serve as our structural base
let baseCalendar = calendars[0];
for (const cal of calendars) {
if (cal.weeks.length > baseCalendar.weeks.length) {
if ((cal.weeks?.length || 0) > (baseCalendar.weeks?.length || 0)) {
baseCalendar = cal;
}

// Populate the Map with all contributions from all calendars
cal.weeks.forEach((week) => {
week.contributionDays.forEach((day) => {
(cal.weeks || []).forEach((week) => {
(week?.contributionDays || []).forEach((day) => {
const currentCount = dateMap.get(day.date) || 0;
dateMap.set(day.date, currentCount + day.contributionCount);
});
Expand All @@ -190,8 +191,8 @@ export function aggregateCalendars(calendars: ContributionCalendar[]): Contribut
aggregatedBase.totalContributions = totalContributions;

// Re-map the structural base using our aggregated date map
aggregatedBase.weeks.forEach((week) => {
week.contributionDays.forEach((day) => {
(aggregatedBase.weeks || []).forEach((week) => {
(week?.contributionDays || []).forEach((day) => {
day.contributionCount = dateMap.get(day.date) || 0;
});
});
Expand All @@ -202,7 +203,8 @@ export function aggregateCalendars(calendars: ContributionCalendar[]): Contribut
* Processes a calendar to generate deep insights for "GitHub Wrapped"
*/
export function calculateWrappedStats(calendar: ContributionCalendar) {
const days = calendar.weeks.flatMap((w) => w.contributionDays);
const weeks = calendar?.weeks || [];
const days = weeks.flatMap((w) => w?.contributionDays || []);

let mostActiveDay = { date: '', count: 0 };
const monthCounts: Record<string, number> = {};
Expand Down
15 changes: 12 additions & 3 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,16 @@ export async function fetchGitHubContributions(
throw new Error(`GitHub user "${username}" not found`);
}

let calendar = data.data.user.contributionsCollection.contributionCalendar;
let calendar = data.data.user.contributionsCollection?.contributionCalendar;
const repoContributions =
data.data.user.contributionsCollection?.commitContributionsByRepository || [];

if (!calendar || !calendar.weeks) {
calendar = {
totalContributions: 0,
weeks: [],
};
}

if (isDeltaSync && cached) {
calendar = mergeCalendars(cached.calendar, calendar);
Expand Down Expand Up @@ -514,14 +523,14 @@ export async function fetchGitHubContributions(
key,
{
calendar,
repoContributions: data.data.user.contributionsCollection.commitContributionsByRepository,
repoContributions,
},
LONG_CACHE_TTL
);
}
return {
calendar,
repoContributions: data.data.user.contributionsCollection.commitContributionsByRepository,
repoContributions,
};
};

Expand Down
Loading