diff --git a/src/app/api/webhooks/github/route.test.ts b/src/app/api/webhooks/github/route.test.ts deleted file mode 100644 index 54a5cb60..00000000 --- a/src/app/api/webhooks/github/route.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import crypto from "crypto"; -import { describe, it, expect, beforeEach, afterEach, vi, type MockInstance } from "vitest"; -import { safeCompare } from "./safe-compare"; - -describe("safeCompare", () => { - let timingSafeEqualSpy: MockInstance; - - beforeEach(() => { - // Vitest uses vi.spyOn to monitor module methods - timingSafeEqualSpy = vi.spyOn(crypto, "timingSafeEqual"); - }); - - afterEach(() => { - // Clear mock data and restore original implementation - timingSafeEqualSpy.mockRestore(); - }); - - // Test Case 1: Early return optimization on length mismatch - it("should return false immediately and not call timingSafeEqual when lengths differ", () => { - const result = safeCompare("short", "muchlongerstring"); - - expect(result).toBe(false); - expect(timingSafeEqualSpy).not.toHaveBeenCalled(); - }); - - // Test Case 2: Standard identical buffers - it("should return true and call timingSafeEqual when buffers are identical", () => { - const stringA = "secure_token_123"; - const stringB = "secure_token_123"; - - const result = safeCompare(stringA, stringB); - - expect(result).toBe(true); - expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); - }); - - // Test Case 3: Handles empty strings safely - it("should handle empty strings correctly without throwing exceptions", () => { - const result = safeCompare("", ""); - - expect(result).toBe(true); - expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); - }); - - // Test Case 4: Identical length but different content (Should trigger full timing check) - it("should return false and call timingSafeEqual when lengths match but content differs", () => { - const result = safeCompare("abc", "xyz"); - - expect(result).toBe(false); - expect(timingSafeEqualSpy).toHaveBeenCalledTimes(1); - }); -}); \ No newline at end of file diff --git a/src/app/api/webhooks/github/route.ts b/src/app/api/webhooks/github/route.ts deleted file mode 100644 index 5cc31d5c..00000000 --- a/src/app/api/webhooks/github/route.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { createHmac } from "crypto"; -import { revalidatePath } from "next/cache"; -import { NextRequest, NextResponse } from "next/server"; -import { supabaseAdmin } from "@/lib/supabase"; -import { safeCompare } from "./safe-compare"; -import { logError } from "@/lib/error-handler"; - -export const dynamic = "force-dynamic"; - -const SIGNATURE_HEADER = "x-hub-signature-256"; -const GITHUB_EVENT_HEADER = "x-github-event"; - -interface GitHubPushPayload { - after?: string; - commits?: Array; - pusher?: { - name?: string; - }; - repository?: { - full_name?: string; - }; - sender?: { - login?: string; - }; -} - -function getExpectedSignature(secret: string, body: string): string { - return `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`; -} - - -function verifyGitHubSignature( - body: string, - signature: string | null, - secret: string -): boolean { - if (!signature?.startsWith("sha256=")) { - return false; - } - - return safeCompare(signature, getExpectedSignature(secret, body)); -} - -function getPushActor(payload: GitHubPushPayload): string | null { - return payload.sender?.login ?? payload.pusher?.name ?? null; -} - -async function markUserMetricsStale(githubLogin: string) { - const updatedAt = new Date().toISOString(); - - const { data: primaryUser, error: primaryError } = await supabaseAdmin - .from("users") - .update({ updated_at: updatedAt }) - .eq("github_login", githubLogin) - .select("id") - .maybeSingle(); - - if (primaryError) { - throw primaryError; - } - - if (primaryUser) { - return { userId: primaryUser.id as string, accountType: "primary" }; - } - - const { data: linkedAccount, error: linkedError } = await supabaseAdmin - .from("user_github_accounts") - .select("user_id") - .eq("github_login", githubLogin) - .maybeSingle(); - - if (linkedError) { - throw linkedError; - } - - if (!linkedAccount?.user_id) { - return null; - } - - const { error: updateError } = await supabaseAdmin - .from("users") - .update({ updated_at: updatedAt }) - .eq("id", linkedAccount.user_id); - - if (updateError) { - throw updateError; - } - - return { userId: linkedAccount.user_id as string, accountType: "linked" }; -} - -export async function POST(req: NextRequest) { - const secret = process.env.GITHUB_WEBHOOK_SECRET; - - if (!secret) { - return NextResponse.json( - { error: "GitHub webhook secret is not configured" }, - { status: 500 } - ); - } - - const body = await req.text(); - const signature = req.headers.get(SIGNATURE_HEADER); - - if (!verifyGitHubSignature(body, signature, secret)) { - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); - } - - const event = req.headers.get(GITHUB_EVENT_HEADER); - if (event !== "push") { - return NextResponse.json({ received: true, ignored: true, event }); - } - - let payload: GitHubPushPayload; - try { - payload = JSON.parse(body) as GitHubPushPayload; - } catch { - return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 }); - } - - const githubLogin = getPushActor(payload); - if (!githubLogin) { - return NextResponse.json( - { received: true, userMatched: false, reason: "Missing GitHub actor" }, - { status: 200 } - ); - } - - let staleResult: Awaited>; - try { - staleResult = await markUserMetricsStale(githubLogin); - } catch (error) { - logError(error, { - endpoint: "/api/webhooks/github", - operation: "mark_metrics_stale", - userId: githubLogin, - additionalContext: { - repository: (payload.repository?.full_name), - commitCount: payload.commits?.length, - }, - }); - return NextResponse.json( - { error: "Failed to trigger metric refresh" }, - { status: 500 } - ); - } - - if (staleResult) { - revalidatePath(`/u/${githubLogin}`); - revalidatePath("/dashboard"); - } - - return NextResponse.json({ - received: true, - userMatched: Boolean(staleResult), - accountType: staleResult?.accountType ?? null, - githubLogin, - repository: payload.repository?.full_name ?? null, - after: payload.after ?? null, - commitCount: payload.commits?.length ?? 0, - }); -} diff --git a/src/app/u/[username]/page.tsx b/src/app/u/[username]/page.tsx index 8b35b395..f60cc677 100644 --- a/src/app/u/[username]/page.tsx +++ b/src/app/u/[username]/page.tsx @@ -18,6 +18,8 @@ import { type PublicProfileData, } from "@/lib/public-profile-data"; +const DATA_WINDOW_DAYS = 90; + async function fetchPublicProfile( username: string, options: { includeAchievements?: boolean } = {} @@ -292,7 +294,7 @@ function PublicStreakTracker({ streak }: { streak: any }) { icon: "🏆", }, { - label: "Active Days (90d)", + label: `Active Days (${DATA_WINDOW_DAYS}d)`, value: streak.totalActiveDays, unit: "days", highlight: false, diff --git a/src/components/ActivityRingChart.tsx b/src/components/ActivityRingChart.tsx index 2b5a125c..adf7bf43 100644 --- a/src/components/ActivityRingChart.tsx +++ b/src/components/ActivityRingChart.tsx @@ -19,6 +19,7 @@ export default function ActivityRingChart() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [days, setDays] = useState(30); + const WINDOW_OPTIONS = [7, 30, 90]; const [hoveredHour, setHoveredHour] = useState(null); const [animated, setAnimated] = useState(false); const prefersReduced = useRef( @@ -91,9 +92,9 @@ export default function ActivityRingChart() { onChange={(e) => setDays(Number(e.target.value))} className="rounded-lg border border-[var(--border)] bg-[var(--card)] px-2 py-1 text-sm text-[var(--card-foreground)] focus:outline-none focus:border-[var(--accent)]" > - - - + {WINDOW_OPTIONS.map((n) => ( + + ))} diff --git a/src/components/CommitTimeChart.tsx b/src/components/CommitTimeChart.tsx index 91ab081e..e6291f03 100644 --- a/src/components/CommitTimeChart.tsx +++ b/src/components/CommitTimeChart.tsx @@ -32,6 +32,7 @@ export default function CommitTimeChart() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [days, setDays] = useState(30); + const WINDOW_OPTIONS = [7, 30, 90]; const [peakTime, setPeakTime] = useState(null); const fetchContributions = useCallback(() => { @@ -107,9 +108,9 @@ export default function CommitTimeChart() { onChange={(e) => setDays(Number(e.target.value))} className="rounded-lg border border-[var(--border)] bg-[var(--control)] px-2 py-1 text-sm text-[var(--card-foreground)] focus:outline-none focus:border-[var(--accent)]" > - - - + {WINDOW_OPTIONS.map((n) => ( + + ))} diff --git a/src/components/LocalCodingTime.tsx b/src/components/LocalCodingTime.tsx index 3b88f0b3..9af6a0a5 100644 --- a/src/components/LocalCodingTime.tsx +++ b/src/components/LocalCodingTime.tsx @@ -39,6 +39,7 @@ export default function LocalCodingTime() { } | null>(null); const [loading, setLoading] = useState(true); const [days, setDays] = useState(30); + const WINDOW_OPTIONS = [7, 30, 90]; useEffect(() => { async function loadStats() { @@ -82,9 +83,9 @@ export default function LocalCodingTime() { onChange={(e) => setDays(Number(e.target.value))} className="bg-[var(--control)] border border-[var(--border)] rounded px-2 py-1 text-sm text-[var(--foreground)]" > - - - + {WINDOW_OPTIONS.map((n) => ( + + ))}
@@ -125,9 +126,9 @@ export default function LocalCodingTime() { onChange={(e) => setDays(Number(e.target.value))} className="bg-[var(--control)] border border-[var(--border)] rounded px-2 py-1 text-sm text-[var(--foreground)]" > - - - + {WINDOW_OPTIONS.map((n) => ( + + ))}
diff --git a/src/components/RepoHealthPanel.tsx b/src/components/RepoHealthPanel.tsx index 36b10d9c..0b7bead2 100644 --- a/src/components/RepoHealthPanel.tsx +++ b/src/components/RepoHealthPanel.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import type { RepoHealthScore } from "@/types/repo-health"; +const DATA_WINDOW_DAYS = 30; interface Props { health: RepoHealthScore; isOpen: boolean; @@ -92,7 +93,7 @@ export default function RepoHealthPanel({ health, isOpen, onClose }: Props) { ))}

- Score based on activity in the last 30 days. Updates on page refresh. + {`Score based on activity in the last ${DATA_WINDOW_DAYS} days. Updates on page refresh.`}

diff --git a/src/components/StreakTracker.tsx b/src/components/StreakTracker.tsx index 6037441f..1e7bbde0 100644 --- a/src/components/StreakTracker.tsx +++ b/src/components/StreakTracker.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner"; import { toPng } from "html-to-image"; import { Flame, Trophy, Calendar, Zap, Copy, CheckCircle, Medal, Star, Sparkles } from "lucide-react"; +const DATA_WINDOW_DAYS = 90; const STREAK_MILESTONES = [7, 30, 50, 100, 200, 365]; interface StreakData { @@ -331,12 +332,12 @@ export default function StreakTracker() { tooltip: "Your longest streak ever", }, { - label: "Active Days (90d)", + label: `Active Days (${DATA_WINDOW_DAYS}d)`, value: animatedActiveDays, unit: "days", highlight: false, - icon: Calendar, - tooltip: "Days you made commits in the last 90 days", + icon: "📅", + tooltip: `Days you made commits in the last ${DATA_WINDOW_DAYS} days`, }, { label: "Last Commit", diff --git a/src/components/TopRepos.tsx b/src/components/TopRepos.tsx index 5837c336..a133c419 100644 --- a/src/components/TopRepos.tsx +++ b/src/components/TopRepos.tsx @@ -78,6 +78,7 @@ export default function TopRepos() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [days, setDays] = useState(30); + const WINDOW_OPTIONS = [7, 30, 90]; const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); const [healthScores, setHealthScores] = useState>({}); @@ -234,9 +235,9 @@ export default function TopRepos() { aria-label="Select time range for top repositories" className="rounded-lg border border-[var(--border)] bg-[var(--control)] px-2 py-1 text-sm text-[var(--card-foreground)] focus:outline-none focus:border-[var(--accent)]" > - - - + {WINDOW_OPTIONS.map((n) => ( + + ))} {loading ? (