Skip to content

Commit 15ef23f

Browse files
authored
Merged [Fix] #1312 with main
[Fix] #1312
2 parents 982a07e + 7a65658 commit 15ef23f

6 files changed

Lines changed: 50 additions & 196 deletions

File tree

src/app/api/leaderboard/route.ts

Lines changed: 2 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,7 @@
11
// @ts-nocheck
22
import { NextRequest, NextResponse } from "next/server";
3-
import { supabaseAdmin } from "@/lib/supabase";
4-
import { toDateStr } from "@/lib/dateUtils";
5-
import { calculateCurrentStreak } from "@/lib/streak";
6-
import {
7-
cacheGet,
8-
cacheSet,
9-
isMetricsCacheBypassed,
10-
} from "@/lib/metrics-cache";
11-
import {
12-
pruneExpiredLeaderboardCache,
13-
pruneExpiredRateLimits,
14-
type LeaderboardCacheEntry,
15-
type RateLimitEntry,
16-
} from "@/lib/leaderboard-cache";
3+
import { cacheGet, isMetricsCacheBypassed } from "@/lib/metrics-cache";
4+
import { pruneExpiredRateLimits, type RateLimitEntry } from "@/lib/leaderboard-cache";
175
import {
186
getUpstashConfig,
197
upstashRateLimitFixedWindow,
@@ -77,156 +65,6 @@ async function checkRateLimit(
7765
return checkMemoryRateLimit(ip);
7866
}
7967

80-
function isFresh(payload: LeaderboardPayload): boolean {
81-
const generatedAt = Date.parse(payload.generatedAt);
82-
if (!Number.isFinite(generatedAt)) {
83-
return false;
84-
}
85-
return Date.now() - generatedAt < CACHE_REFRESH_SECONDS * 1000;
86-
}
87-
88-
async function mapWithConcurrency<T, R>(
89-
items: T[],
90-
concurrency: number,
91-
mapper: (item: T, index: number) => Promise<R>
92-
): Promise<R[]> {
93-
const safeConcurrency =
94-
Number.isFinite(concurrency) && concurrency > 0 ? Math.floor(concurrency) : 1;
95-
const results: R[] = new Array(items.length);
96-
let cursor = 0;
97-
98-
async function worker() {
99-
while (true) {
100-
const index = cursor;
101-
cursor += 1;
102-
if (index >= items.length) {
103-
return;
104-
}
105-
results[index] = await mapper(items[index], index);
106-
}
107-
}
108-
109-
const workers = Array.from(
110-
{ length: Math.min(safeConcurrency, items.length) },
111-
() => worker()
112-
);
113-
114-
await Promise.all(workers);
115-
return results;
116-
}
117-
118-
async function fetchGitHubJson<T>(path: string): Promise<T | null> {
119-
const token = process.env.GITHUB_TOKEN;
120-
const headers: Record<string, string> = {
121-
Accept: "application/vnd.github+json",
122-
};
123-
if (token) {
124-
headers.Authorization = `Bearer ${token}`;
125-
}
126-
127-
const res = await fetch(`${GITHUB_API}${path}`, {
128-
headers,
129-
next: { revalidate: 3600 },
130-
});
131-
132-
if (!res.ok) {
133-
console.error("GitHub leaderboard request failed:", path, res.status);
134-
return null;
135-
}
136-
137-
return (await res.json()) as T;
138-
}
139-
140-
141-
async function fetchCommitStats(username: string, since: string) {
142-
const query = new URLSearchParams({
143-
q: `author:${username} author-date:>=${since}`,
144-
per_page: "100",
145-
sort: "author-date",
146-
order: "desc",
147-
});
148-
return fetchGitHubJson<{
149-
total_count: number;
150-
items: Array<{ commit: { author: { date: string } } }>;
151-
}>(`/search/commits?${query.toString()}`);
152-
}
153-
154-
async function fetchPrCount(username: string, since: string): Promise<number> {
155-
const query = new URLSearchParams({
156-
q: `author:${username} type:pr created:>=${since}`,
157-
per_page: "1",
158-
});
159-
const data = await fetchGitHubJson<{ total_count: number }>(
160-
`/search/issues?${query.toString()}`
161-
);
162-
return data?.total_count ?? 0;
163-
}
164-
165-
async function buildLeaderboard(): Promise<LeaderboardPayload> {
166-
const { data: users, error } = await supabaseAdmin
167-
.from("users")
168-
.select("id, github_login")
169-
.eq("is_public", true)
170-
.eq("leaderboard_opt_in", true)
171-
.limit(50);
172-
173-
if (error) {
174-
console.error("Failed to fetch leaderboard users:", error);
175-
throw new Error("Failed to load leaderboard users");
176-
}
177-
178-
const now = new Date();
179-
const monthStart = toDateStr(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)));
180-
const streakStart = toDateStr(new Date(Date.now() - 90 * 86400000));
181-
182-
const safeUsers = (users ?? []) as PublicUser[];
183-
184-
const rows = await mapWithConcurrency(
185-
safeUsers,
186-
USER_CONCURRENCY,
187-
async (user) => {
188-
const [monthlyCommits, streakCommits, prs] = await Promise.all([
189-
fetchCommitStats(user.github_login, monthStart),
190-
fetchCommitStats(user.github_login, streakStart),
191-
fetchPrCount(user.github_login, monthStart),
192-
]);
193-
194-
const streak = calculateCurrentStreak(
195-
streakCommits?.items.map((item) => item.commit.author.date) ?? []
196-
);
197-
const commits = monthlyCommits?.total_count ?? 0;
198-
const score = streak * 5 + commits + prs * 3;
199-
200-
return {
201-
rank: 0,
202-
username: user.github_login,
203-
avatarUrl: `https://github.com/${user.github_login}.png?size=96`,
204-
profileUrl: `/u/${user.github_login}`,
205-
streak,
206-
commits,
207-
prs,
208-
score,
209-
};
210-
}
211-
);
212-
213-
const rankBy = (metric: LeaderboardMetric) =>
214-
[...rows]
215-
.sort((a, b) => b[metric] - a[metric] || b.score - a.score)
216-
.slice(0, 50)
217-
.map((entry, index) => ({ ...entry, rank: index + 1 }));
218-
219-
return {
220-
generatedAt: now.toISOString(),
221-
refreshSeconds: CACHE_REFRESH_SECONDS,
222-
leaders: {
223-
streak: rankBy("streak"),
224-
commits: rankBy("commits"),
225-
prs: rankBy("prs"),
226-
},
227-
};
228-
}
229-
23068
export async function GET(req: NextRequest) {
23169
const ip = getRateLimitKey(req);
23270
const rateLimit = await checkRateLimit(ip);

src/app/api/metrics/compare/route.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { getServerSession } from "next-auth";
22
import { NextRequest } from "next/server";
33
import { authOptions } from "@/lib/auth";
4+
import { toDateStr } from "@/lib/dateUtils";
45
import { calculateCurrentStreak } from "@/lib/streak";
56
import { normalizeGitHubUsername } from "@/lib/validate-github-username";
67
import { supabaseAdmin } from "@/lib/supabase";
7-
import { calculateStreak } from "@/lib/streak";
8-
98
export const dynamic = "force-dynamic";
109

1110
const GITHUB_API = "https://api.github.com";

src/app/api/metrics/streak/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { supabaseAdmin } from "@/lib/supabase";
1313
import { resolveAppUser } from "@/lib/resolve-user";
1414
import { calculateStreakFromDates } from "@/lib/streak";
15+
import { dispatchToAllWebhooks } from "@/lib/webhooks";
1516

1617
export const dynamic = "force-dynamic";
1718

src/app/api/metrics/weekly-summary/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { NextRequest } from "next/server";
33
import { authOptions } from "@/lib/auth";
44
import { GITHUB_API } from "@/lib/github";
55
import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache";
6+
import { getAccountToken } from "@/lib/github-accounts";
7+
import { supabaseAdmin } from "@/lib/supabase";
8+
import { resolveAppUser } from "@/lib/resolve-user";
69
import { toDateStr } from "@/lib/dateUtils";
710
import { calculateCurrentStreak } from "@/lib/streak";
811

src/lib/leaderboard.ts

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { supabaseAdmin } from "@/lib/supabase";
2-
import { dateDiffDays, toDateStr } from "@/lib/dateUtils";
2+
import { toDateStr } from "@/lib/dateUtils";
3+
import { calculateCurrentStreak } from "@/lib/streak";
34
import { cacheGet, cacheSet, cacheDelete } from "@/lib/metrics-cache";
45
import {
56
pruneExpiredLeaderboardCache,
@@ -165,30 +166,6 @@ async function fetchGitHubJson<T>(path: string): Promise<T | null> {
165166
}
166167
}
167168

168-
function calculateCurrentStreak(commitDates: string[]): number {
169-
const days = Array.from(
170-
new Set(commitDates.map((d) => d.slice(0, 10)))
171-
).sort();
172-
if (days.length === 0) return 0;
173-
174-
let runLength = 1;
175-
const runs: { end: string; length: number }[] = [];
176-
for (let i = 1; i < days.length; i++) {
177-
if (dateDiffDays(days[i - 1], days[i]) === 1) {
178-
runLength++;
179-
} else {
180-
runs.push({ end: days[i - 1], length: runLength });
181-
runLength = 1;
182-
}
183-
}
184-
runs.push({ end: days[days.length - 1], length: runLength });
185-
186-
const today = toDateStr(new Date());
187-
const yesterday = toDateStr(new Date(Date.now() - 86400000));
188-
const latest = runs[runs.length - 1];
189-
return latest.end === today || latest.end === yesterday ? latest.length : 0;
190-
}
191-
192169
async function fetchCommitStats(username: string, since: string) {
193170
const query = new URLSearchParams({
194171
q: `author:${username} author-date:>=${since}`,

src/lib/streak.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@ export interface StreakResult {
88
freezeDates: string[];
99
}
1010

11+
function todayAndYesterday(timeZone: string): { today: string; yesterday: string } {
12+
if (timeZone === "UTC") {
13+
const today = toDateStr(new Date());
14+
const yesterday = toDateStr(new Date(Date.now() - 86400000));
15+
return { today, yesterday };
16+
}
17+
18+
const fmt = new Intl.DateTimeFormat("en", {
19+
timeZone,
20+
year: "numeric",
21+
month: "2-digit",
22+
day: "2-digit",
23+
});
24+
25+
const parts = (d: Date) => {
26+
const p = fmt.formatToParts(d);
27+
const y = p.find((x) => x.type === "year")?.value ?? "0000";
28+
const m = p.find((x) => x.type === "month")?.value ?? "00";
29+
const day = p.find((x) => x.type === "day")?.value ?? "00";
30+
return `${y}-${m}-${day}`;
31+
};
32+
33+
return {
34+
today: parts(new Date()),
35+
yesterday: parts(new Date(Date.now() - 86400000)),
36+
};
37+
}
38+
1139
/**
1240
* Canonical streak calculation shared across all endpoints.
1341
* freeze dates count as active days so they don't break the streak.
@@ -17,7 +45,8 @@ export interface StreakResult {
1745
*/
1846
export function calculateStreakFromDates(
1947
activeDates: Set<string>,
20-
freezeDates: Set<string> = new Set()
48+
freezeDates: Set<string> = new Set(),
49+
timeZone = "UTC"
2150
): StreakResult {
2251
const combinedDates = new Set<string>([
2352
...Array.from(activeDates),
@@ -58,9 +87,7 @@ export function calculateStreakFromDates(
5887
// Push the final run.
5988
runs.push({ start: runStart, end: commitDays[commitDays.length - 1], length: currentRun });
6089

61-
const lastDay = commitDays[commitDays.length - 1];
62-
const today = toDateStr(new Date());
63-
const yesterday = toDateStr(new Date(Date.now() - 86400000));
90+
const { today, yesterday } = todayAndYesterday(timeZone);
6491

6592
// Current streak is alive if the last active day is today OR yesterday.
6693
const lastRun = runs[runs.length - 1];
@@ -70,7 +97,7 @@ export function calculateStreakFromDates(
7097
return {
7198
current: currentStreak,
7299
longest: longestStreak,
73-
lastCommitDate: lastDay,
100+
lastCommitDate: commitDays[commitDays.length - 1],
74101
totalActiveDays: commitDays.length,
75102
freezeDates: Array.from(freezeDates),
76103
};
@@ -84,3 +111,12 @@ export function calculateCurrentStreak(dates: Set<string> | string[]): number {
84111
: dates;
85112
return calculateStreakFromDates(dateSet).current;
86113
}
114+
115+
// Adapter for callers that pass Date objects and expect {currentStreak, longestStreak}.
116+
export function calculateStreak(
117+
commitDates: Date[]
118+
): { currentStreak: number; longestStreak: number } {
119+
const dateSet = new Set(commitDates.map((d) => toDateStr(d)));
120+
const result = calculateStreakFromDates(dateSet);
121+
return { currentStreak: result.current, longestStreak: result.longest };
122+
}

0 commit comments

Comments
 (0)