From d0f099dbb8784d9704208e202d9f499fe75d51c9 Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 02:22:28 +0530 Subject: [PATCH 01/12] fix: harden IP and bound in-memory rate limiters (#1435) --- src/app/api/public/[username]/route.ts | 78 +++++++-------------- src/lib/badge-rate-limit.ts | 43 ++++-------- src/lib/rate-limit.test.ts | 72 ++++++++++++++++++++ src/lib/rate-limit.ts | 93 ++++++++++++++++++++++++++ src/middleware.ts | 61 +++-------------- 5 files changed, 212 insertions(+), 135 deletions(-) create mode 100644 src/lib/rate-limit.test.ts create mode 100644 src/lib/rate-limit.ts diff --git a/src/app/api/public/[username]/route.ts b/src/app/api/public/[username]/route.ts index 47ce34938..0df36b02b 100644 --- a/src/app/api/public/[username]/route.ts +++ b/src/app/api/public/[username]/route.ts @@ -1,79 +1,47 @@ import { NextRequest, NextResponse } from "next/server"; import { fetchPublicProfile } from "@/lib/public-profile-data"; import { getUpstashConfig, upstashRateLimitFixedWindow } from "@/lib/upstash-rest"; +import { createMemoryFixedWindowRateLimiter, getClientIp } from "@/lib/rate-limit"; export const dynamic = "force-dynamic"; -/** - * In-memory rate limiter for IP addresses. - * Maps IP -> { count: number, resetAt: number } - * This resets on server restart. For production, use Redis. - */ -const ipRateLimits = new Map< - string, - { count: number; resetAt: number } ->(); - const RATE_LIMIT_REQUESTS = 30; const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute - -function cleanOldEntries(map: Map) { - const now = Date.now(); - for (const [key, val] of map.entries()) { - if (val.resetAt <= now) map.delete(key); - } -} - -function getRateLimitKey(req: NextRequest): string { - // req.ip is populated by the Next.js / Vercel runtime from the verified - // network-layer source address and cannot be spoofed by the caller. - // - // x-forwarded-for is intentionally excluded here: it is a plain request - // header that any client can set to an arbitrary value. Trusting it as the - // primary key allows an attacker to rotate the header on every request, - // bypass the per-IP limit entirely, and exhaust the shared GITHUB_TOKEN - // quota (5 000 req/hr), making the endpoint unavailable for all users. - return req.ip || req.headers.get("x-real-ip") || "unknown"; -} - -function checkRateLimit(ip: string): { allowed: boolean; retryAfter?: number } { - const now = Date.now(); - for (const [key, record] of ipRateLimits) { - if (now > record.resetAt) ipRateLimits.delete(key); - } - const record = ipRateLimits.get(ip); - - if (!record || now > record.resetAt) { - // New window or expired - ipRateLimits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); - return { allowed: true }; - } - - if (record.count < RATE_LIMIT_REQUESTS) { - record.count++; - return { allowed: true }; - } - - // Rate limit exceeded - const retryAfter = Math.ceil((record.resetAt - now) / 1000); - return { allowed: false, retryAfter }; -} +const MEMORY_MAX_ENTRIES = Number(process.env.MEMORY_RATE_LIMIT_MAX_ENTRIES ?? 10_000); +const memoryLimiter = createMemoryFixedWindowRateLimiter({ + windowMs: RATE_LIMIT_WINDOW_MS, + pruneIntervalMs: 5 * 60 * 1000, + maxEntries: Number.isFinite(MEMORY_MAX_ENTRIES) ? MEMORY_MAX_ENTRIES : 10_000, +}); export async function GET( req: NextRequest, { params }: { params: { username: string } } ): Promise { - cleanOldEntries(ipRateLimits); const { username } = params; // Rate limiting - const ip = getRateLimitKey(req); + const ip = getClientIp(req); const rateLimit = getUpstashConfig() ? await upstashRateLimitFixedWindow({ key: `public-profile-rate-limit:${ip}`, limit: RATE_LIMIT_REQUESTS, windowSeconds: Math.ceil(RATE_LIMIT_WINDOW_MS / 1000), }) - : checkRateLimit(ip); + : (() => { + const local = memoryLimiter.check( + `public-profile-rate-limit:${ip}`, + RATE_LIMIT_REQUESTS + ); + return local.allowed + ? { allowed: true } + : { + allowed: false, + retryAfter: Math.max( + local.reset - Math.floor(Date.now() / 1000), + 1 + ), + }; + })(); if (!rateLimit.allowed) { return NextResponse.json( diff --git a/src/lib/badge-rate-limit.ts b/src/lib/badge-rate-limit.ts index 8a212fa2b..690c44035 100644 --- a/src/lib/badge-rate-limit.ts +++ b/src/lib/badge-rate-limit.ts @@ -1,4 +1,5 @@ import { NextRequest } from "next/server"; +import { createMemoryFixedWindowRateLimiter, getClientIp } from "@/lib/rate-limit"; const WINDOW_MS = 60 * 1000; const BADGE_LIMIT = 20; @@ -6,7 +7,12 @@ const BADGE_LIMIT = 20; // NOTE: This rate limiter is separate from the API middleware rate limiting. // It applies per-IP limiting specifically for badge generation endpoints. -const buckets = new Map(); +const MEMORY_MAX_ENTRIES = Number(process.env.MEMORY_RATE_LIMIT_MAX_ENTRIES ?? 10_000); +const memoryLimiter = createMemoryFixedWindowRateLimiter({ + windowMs: WINDOW_MS, + pruneIntervalMs: 5 * 60 * 1000, + maxEntries: Number.isFinite(MEMORY_MAX_ENTRIES) ? MEMORY_MAX_ENTRIES : 10_000, +}); export type BadgeRateLimitResult = { allowed: boolean; @@ -14,38 +20,17 @@ export type BadgeRateLimitResult = { reset: number; }; -function pruneBuckets(now: number) { - if (buckets.size < 500) return; - const cutoff = now - WINDOW_MS; - for (const [key, timestamps] of Array.from(buckets.entries())) { - if (timestamps.every((t) => t <= cutoff)) buckets.delete(key); - } -} - export function checkBadgeRateLimit(ip: string): BadgeRateLimitResult { const now = Date.now(); - pruneBuckets(now); - const key = `badge:${ip}`; - const cutoff = now - WINDOW_MS; - const active = (buckets.get(key) ?? []).filter((t) => t > cutoff); - const reset = Math.ceil(((active[0] ?? now) + WINDOW_MS) / 1000); - - if (active.length >= BADGE_LIMIT) { - buckets.set(key, active); - return { allowed: false, remaining: 0, reset }; - } - - active.push(now); - buckets.set(key, active); - return { allowed: true, remaining: BADGE_LIMIT - active.length, reset }; + const result = memoryLimiter.check(key, BADGE_LIMIT, now); + return { + allowed: result.allowed, + remaining: result.remaining, + reset: result.reset, + }; } export function getBadgeClientIp(req: NextRequest): string { - return ( - req.ip ?? - req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? - req.headers.get("x-real-ip") ?? - "unknown" - ); + return getClientIp(req); } diff --git a/src/lib/rate-limit.test.ts b/src/lib/rate-limit.test.ts new file mode 100644 index 000000000..ecc0e6c0a --- /dev/null +++ b/src/lib/rate-limit.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { createMemoryFixedWindowRateLimiter, getClientIp } from "./rate-limit"; + +describe("getClientIp", () => { + it("prefers req.ip when present", () => { + const req = { + ip: "203.0.113.1", + headers: new Headers({ "x-forwarded-for": "1.1.1.1" }), + }; + expect(getClientIp(req as any)).toBe("203.0.113.1"); + }); + + it("prefers cf-connecting-ip over x-real-ip and x-forwarded-for", () => { + const req = { + ip: undefined, + headers: new Headers({ + "cf-connecting-ip": "203.0.113.2", + "x-real-ip": "203.0.113.3", + "x-forwarded-for": "203.0.113.4", + }), + }; + expect(getClientIp(req as any)).toBe("203.0.113.2"); + }); + + it("falls back to x-real-ip and then first x-forwarded-for hop", () => { + const req = { + ip: undefined, + headers: new Headers({ + "x-forwarded-for": "1.1.1.1, 2.2.2.2", + "x-real-ip": "203.0.113.5", + }), + }; + expect(getClientIp(req as any)).toBe("203.0.113.5"); + + const req2 = { + ip: undefined, + headers: new Headers({ "x-forwarded-for": "1.1.1.1, 2.2.2.2" }), + }; + expect(getClientIp(req2 as any)).toBe("1.1.1.1"); + }); +}); + +describe("createMemoryFixedWindowRateLimiter", () => { + it("evicts expired entries during prune", () => { + const limiter = createMemoryFixedWindowRateLimiter({ + windowMs: 1000, + pruneIntervalMs: 0, + maxEntries: 100, + }); + + limiter.check("k1", 5, 0); + expect(limiter._unsafeBuckets.has("k1")).toBe(true); + + limiter.check("k2", 5, 2000); + expect(limiter._unsafeBuckets.has("k1")).toBe(false); + }); + + it("caps bucket growth with maxEntries", () => { + const limiter = createMemoryFixedWindowRateLimiter({ + windowMs: 60_000, + pruneIntervalMs: 0, + maxEntries: 2, + }); + + limiter.check("a", 1, 0); + limiter.check("b", 1, 0); + limiter.check("c", 1, 0); + + expect(limiter._unsafeBuckets.size).toBeLessThanOrEqual(2); + }); +}); + diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 000000000..bb2dd82ee --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,93 @@ +import type { NextRequest } from "next/server"; + +export type MemoryRateLimitResult = { + allowed: boolean; + remaining: number; + reset: number; // unix seconds +}; + +function normalizeIp(value: string | null | undefined): string | null { + const ip = typeof value === "string" ? value.trim() : ""; + return ip.length > 0 ? ip : null; +} + +export function getClientIp( + req: Pick +): string { + return ( + normalizeIp(req.ip) ?? + normalizeIp(req.headers.get("cf-connecting-ip")) ?? + normalizeIp(req.headers.get("x-real-ip")) ?? + normalizeIp(req.headers.get("x-forwarded-for")?.split(",")[0]) ?? + "unknown" + ); +} + +type Bucket = { count: number; resetAt: number }; + +export function createMemoryFixedWindowRateLimiter(options: { + windowMs: number; + pruneIntervalMs?: number; + maxEntries?: number; +}) { + const windowMs = options.windowMs; + const pruneIntervalMs = options.pruneIntervalMs ?? windowMs; + const maxEntries = options.maxEntries ?? 10_000; + + const buckets = new Map(); + let lastPruneAt = 0; + + function prune(now: number) { + if (now - lastPruneAt < pruneIntervalMs) return; + lastPruneAt = now; + + for (const [key, bucket] of buckets.entries()) { + if (bucket.resetAt <= now) buckets.delete(key); + } + + if (buckets.size <= maxEntries) return; + + const overflow = buckets.size - maxEntries; + let removed = 0; + for (const key of buckets.keys()) { + buckets.delete(key); + removed += 1; + if (removed >= overflow) break; + } + } + + function check( + key: string, + limit: number, + now = Date.now() + ): MemoryRateLimitResult { + prune(now); + + const existing = buckets.get(key); + if (!existing || existing.resetAt <= now) { + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return { + allowed: true, + remaining: Math.max(limit - 1, 0), + reset: Math.ceil((now + windowMs) / 1000), + }; + } + + if (existing.count >= limit) { + return { + allowed: false, + remaining: 0, + reset: Math.ceil(existing.resetAt / 1000), + }; + } + + existing.count += 1; + return { + allowed: true, + remaining: Math.max(limit - existing.count, 0), + reset: Math.ceil(existing.resetAt / 1000), + }; + } + + return { check, _unsafeBuckets: buckets }; +} diff --git a/src/middleware.ts b/src/middleware.ts index 59d6a1ea1..2257f69aa 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,6 @@ import { getToken } from "next-auth/jwt"; import { NextRequest, NextResponse } from "next/server"; +import { createMemoryFixedWindowRateLimiter, getClientIp } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -20,8 +21,12 @@ const WINDOW_SECONDS = 60; ============================================================ */ const AUTHENTICATED_LIMIT = isDev ? 5000 : 60; const ANONYMOUS_LIMIT = isDev ? 1000 : 10; - -const memoryBuckets = new Map(); +const MEMORY_MAX_ENTRIES = Number(process.env.MEMORY_RATE_LIMIT_MAX_ENTRIES ?? 10_000); +const memoryLimiter = createMemoryFixedWindowRateLimiter({ + windowMs: WINDOW_SECONDS * 1000, + pruneIntervalMs: 5 * 60 * 1000, + maxEntries: Number.isFinite(MEMORY_MAX_ENTRIES) ? MEMORY_MAX_ENTRIES : 10_000, +}); type RateLimitResult = { allowed: boolean; @@ -31,12 +36,7 @@ type RateLimitResult = { }; function getIp(req: NextRequest) { - return ( - req.ip ?? - req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? - req.headers.get("x-real-ip") ?? - "unknown" - ); + return getClientIp(req); } function buildHeaders(result: RateLimitResult) { @@ -55,54 +55,13 @@ function buildHeaders(result: RateLimitResult) { return headers; } -function pruneMemoryBuckets(now: number) { - if (memoryBuckets.size < 500) { - return; - } - - const cutoff = now - WINDOW_SECONDS * 1000; - for (const [key, values] of Array.from(memoryBuckets.entries())) { - const active = values.filter((timestamp: number) => timestamp > cutoff); - if (active.length === 0) { - memoryBuckets.delete(key); - } else { - memoryBuckets.set(key, active); - } - } -} - function checkMemoryLimit( key: string, limit: number, now: number ): RateLimitResult { - pruneMemoryBuckets(now); - - const cutoff = now - WINDOW_SECONDS * 1000; - const active = (memoryBuckets.get(key) ?? []).filter( - (timestamp) => timestamp > cutoff - ); - const reset = Math.ceil(((active[0] ?? now) + WINDOW_SECONDS * 1000) / 1000); - - if (active.length >= limit) { - memoryBuckets.set(key, active); - return { - allowed: false, - limit, - remaining: 0, - reset, - }; - } - - active.push(now); - memoryBuckets.set(key, active); - - return { - allowed: true, - limit, - remaining: Math.max(limit - active.length, 0), - reset, - }; + const result = memoryLimiter.check(key, limit, now); + return { ...result, limit }; } /** From 1578ae3491c6b51d26f1a83a242d3658e7358c7e Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 08:24:54 +0530 Subject: [PATCH 02/12] fix: disable secure NextAuth cookies in Playwright server mode --- src/lib/auth.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 307b6a5f6..c8c8e3b8c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,7 +5,10 @@ import { supabaseAdmin } from "./supabase"; const SESSION_MAX_AGE = 30 * 24 * 60 * 60; const SESSION_UPDATE_AGE = 24 * 60 * 60; -const useSecureCookies = process.env.NEXTAUTH_URL?.startsWith("https://") ?? process.env.NODE_ENV === "production"; +const isPlaywrightServer = process.env.PLAYWRIGHT_SERVER_MODE === "start"; +const useSecureCookies = + !isPlaywrightServer && + (process.env.NEXTAUTH_URL?.startsWith("https://") ?? process.env.NODE_ENV === "production"); const GITHUB_API = "https://api.github.com"; // Re-validate the stored GitHub token at most once every 24 hours per session. @@ -187,4 +190,3 @@ export const authOptions: NextAuthOptions = { }, secret: process.env.NEXTAUTH_SECRET, }; - From c9cd94f6875fcc3f468cf4e0cf0093e8e05e738a Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 09:21:28 +0530 Subject: [PATCH 03/12] fix: restore default NextAuth cookie handling for Playwright E2E --- src/lib/auth.ts | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c8c8e3b8c..912d2c585 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,10 +5,6 @@ import { supabaseAdmin } from "./supabase"; const SESSION_MAX_AGE = 30 * 24 * 60 * 60; const SESSION_UPDATE_AGE = 24 * 60 * 60; -const isPlaywrightServer = process.env.PLAYWRIGHT_SERVER_MODE === "start"; -const useSecureCookies = - !isPlaywrightServer && - (process.env.NEXTAUTH_URL?.startsWith("https://") ?? process.env.NODE_ENV === "production"); const GITHUB_API = "https://api.github.com"; // Re-validate the stored GitHub token at most once every 24 hours per session. @@ -29,18 +25,9 @@ export const authOptions: NextAuthOptions = { pages: { signIn: "/auth/signin", }, - // From PR #1334: use secure cookies on HTTPS deployments, plain cookies on HTTP (local dev). - cookies: { - sessionToken: { - name: `${useSecureCookies ? "__Secure-" : ""}next-auth.session-token`, - options: { - httpOnly: true, - sameSite: "lax", - path: "/", - secure: useSecureCookies, - }, - }, - }, + // Use NextAuth's default cookie behavior (secure cookies on HTTPS deployments), + // which keeps Playwright E2E (http://127.0.0.1) aligned with the default + // `next-auth.session-token` cookie name. session: { strategy: "jwt", maxAge: SESSION_MAX_AGE, From d189f3f2b5a86f68414af73588e2f43126d23bdc Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 10:15:08 +0530 Subject: [PATCH 04/12] fix: force non-secure NextAuth cookies in Playwright server mode --- src/lib/auth.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 912d2c585..3cbf8673c 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -5,6 +5,7 @@ import { supabaseAdmin } from "./supabase"; const SESSION_MAX_AGE = 30 * 24 * 60 * 60; const SESSION_UPDATE_AGE = 24 * 60 * 60; +const isPlaywrightServer = process.env.PLAYWRIGHT_SERVER_MODE === "start"; const GITHUB_API = "https://api.github.com"; // Re-validate the stored GitHub token at most once every 24 hours per session. @@ -13,6 +14,11 @@ const GITHUB_API = "https://api.github.com"; const TOKEN_VALIDATION_INTERVAL_MS = 24 * 60 * 60 * 1000; export const authOptions: NextAuthOptions = { + // Playwright runs on plain HTTP (127.0.0.1) and relies on the default + // `next-auth.session-token` cookie name. If NextAuth infers HTTPS via + // forwarded headers, it may switch to secure cookie prefixes and the E2E + // session cookie won't be read. Force non-secure cookies in this mode. + ...(isPlaywrightServer ? { useSecureCookies: false } : {}), providers: [ GitHubProvider({ clientId: process.env.GITHUB_ID ?? "", From cf824db709062f8530449889cf493a41b829666d Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 10:29:07 +0530 Subject: [PATCH 05/12] fix: allow Playwright E2E session cookie through middleware --- src/middleware.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/middleware.ts b/src/middleware.ts index 2257f69aa..b31c7d18f 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -162,7 +162,26 @@ async function checkRateLimit(identifier: string, limit: number) { export async function middleware(req: NextRequest) { const pathname = req.nextUrl.pathname; - const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }); + + // PLAYWRIGHT_SERVER_MODE is not forwarded into the webServer env by + // playwright.config.mjs, so isPlaywrightServer is always false at runtime. + // Instead, attempt token resolution with both cookie name variants so the + // non-secure cookie set by Playwright tests is always found. + let token = await getToken({ + req, + secret: process.env.NEXTAUTH_SECRET, + secureCookie: false, + cookieName: "next-auth.session-token", + }); + + if (!token) { + token = await getToken({ + req, + secret: process.env.NEXTAUTH_SECRET, + secureCookie: true, + cookieName: "__Secure-next-auth.session-token", + }); + } const protectedRoutes = ["/dashboard", "/settings"]; const isProtectedRoute = protectedRoutes.some( From bb554184758d529829f80616a14cfd55af339c02 Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 11:26:12 +0530 Subject: [PATCH 06/12] fix: repair JSX/TS syntax blocking CI --- src/app/dashboard/settings/page.tsx | 1 + src/components/DashboardHeader.tsx | 17 +++- src/components/GoalTracker.tsx | 146 ++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 92b7511d1..d41faec47 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -145,6 +145,7 @@ function SettingsPageContent() { const [showConfirmModal, setShowConfirmModal] = useState(false); const [pendingPath, setPendingPath] = useState(null); + // Spotlight Repos States const [userRepos, setUserRepos] = useState([]); const [loadingRepos, setLoadingRepos] = useState(false); diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx index ef576eebc..81ba5c6ac 100644 --- a/src/components/DashboardHeader.tsx +++ b/src/components/DashboardHeader.tsx @@ -176,8 +176,21 @@ export default function DashboardHeader() { -

- Your coding activity at a glance 🚀 +

+

+ Dashboard overview +

+

+ Dashboard +

+

+ coding activity at a glance

{minutesAgo !== null && (

diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index 83920c352..0e6acc64d 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -26,20 +26,29 @@ const RECURRENCE_LABELS: Record = { export default function GoalTracker() { const [goals, setGoals] = useState([]); const [loading, setLoading] = useState(true); +<<<<<<< HEAD const [syncing, setSyncing] = useState(false); const [syncError, setSyncError] = useState(null); +======= +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); const [title, setTitle] = useState(""); const [target, setTarget] = useState(7); const [unit, setUnit] = useState("commits"); const [recurrence, setRecurrence] = useState("none"); +<<<<<<< HEAD const [deadline, setDeadline] = useState(""); +======= +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [confirmingId, setConfirmingId] = useState(null); const [deletingId, setDeletingId] = useState(null); +<<<<<<< HEAD const [deleteError, setDeleteError] = useState(null); +======= +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) const [activeConfettiGoalId, setActiveConfettiGoalId] = useState(null); const prevGoalsRef = useRef>(new Map()); @@ -48,6 +57,7 @@ export default function GoalTracker() { const loadGoals = useCallback(async () => { const response = await fetch("/api/goals"); const data: { goals: Goal[] } = await response.json(); +<<<<<<< HEAD const fetchedGoals = data.goals ?? []; setGoals(fetchedGoals); return fetchedGoals; @@ -104,12 +114,20 @@ export default function GoalTracker() { await handleSync(); } }) +======= + setGoals(data.goals ?? []); + }, []); + + useEffect(() => { + loadGoals() +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) .catch(() => {}) .finally(() => { setLoading(false); setLastUpdated(new Date()); setMinutesAgo(0); }); +<<<<<<< HEAD }, [loadGoals, handleSync]); useEffect(() => { @@ -123,6 +141,8 @@ export default function GoalTracker() { }; window.addEventListener("devtrack:sync", handleSyncEvent); return () => window.removeEventListener("devtrack:sync", handleSyncEvent); +======= +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) }, [loadGoals]); async function handleCreate(e: React.FormEvent) { @@ -131,6 +151,7 @@ export default function GoalTracker() { setCreateError(null); try { +<<<<<<< HEAD const result = await submitGoalWithRefresh({ payload: { title, target, unit, recurrence, deadline: deadline || null }, handleSync, @@ -159,6 +180,29 @@ export default function GoalTracker() { } finally { setCreating(false); } +======= + const response = await fetch("/api/goals", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, target, unit, recurrence }), + }); + + if (!response.ok) { + throw new Error("Failed to create goal"); + } + } catch { + setCreateError("Failed to create goal. Please try again."); + setCreating(false); + return; + } + + setTitle(""); + setTarget(7); + setUnit("commits"); + setRecurrence("none"); + await loadGoals().catch(() => {}); + setCreating(false); +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) } async function handleDelete(id: string) { @@ -166,17 +210,26 @@ export default function GoalTracker() { setGoals((prev) => prev.filter((g) => g.id !== id)); setConfirmingId(null); setDeletingId(id); +<<<<<<< HEAD setDeleteError(null); +======= +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) try { const res = await fetch(`/api/goals/${id}`, { method: "DELETE" }); if (!res.ok) { setGoals(previousGoals); +<<<<<<< HEAD setDeleteError("Failed to delete goal. Please try again."); } } catch { setGoals(previousGoals); setDeleteError("Failed to delete goal. Please check your connection."); +======= + } + } catch { + setGoals(previousGoals); +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) } finally { setDeletingId(null); } @@ -188,6 +241,7 @@ export default function GoalTracker() { if (goal.recurrence === "monthly") return "Completed this month ✓"; return "Completed ✓"; } +<<<<<<< HEAD if (goal.deadline) { const msLeft = new Date(goal.deadline).getTime() - Date.now(); @@ -197,6 +251,8 @@ export default function GoalTracker() { return `${daysLeft}d left`; } +======= +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) return ""; } @@ -241,7 +297,11 @@ export default function GoalTracker() { if (loading) { return ( +<<<<<<< HEAD

+======= +
+>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI)
Loading weekly goals
{/* ── Header ── */}
@@ -310,6 +371,10 @@ export default function GoalTracker() {
)} +======= +
+

Weekly Goals

+>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) {goals.length === 0 ? (

@@ -318,19 +383,30 @@ export default function GoalTracker() { ) : (

    {goals.map((goal) => { +<<<<<<< HEAD const pct = Math.min((goal.current / goal.target) * 100, 100); +======= + const progress = goal.target > 0 ? Math.min((goal.current / goal.target) * 100, 100) : 0; +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) const isConfirming = confirmingId === goal.id; const isDeleting = deletingId === goal.id; const completed = goal.current >= goal.target; const completionLabel = getCompletionLabel(goal); +<<<<<<< HEAD const isAutoSynced = goal.unit === "commits" || goal.unit === "prs"; +======= +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) return (
  • {activeConfettiGoalId === goal.id && }
    +<<<<<<< HEAD
    +======= +
    +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) {goal.title} {goal.recurrence !== "none" && ( )} +<<<<<<< HEAD {isAutoSynced && ( ) : null} +======= +
    + {completed && ( + + {completionLabel} + + )} +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI)
    @@ -379,6 +464,7 @@ export default function GoalTracker() { {goal.current}/{goal.target} {goal.unit} +<<<<<<< HEAD {/* Manual +1 only for non-auto-synced goals */} {!isAutoSynced && ( @@ -445,8 +545,12 @@ export default function GoalTracker() {
    >>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) />
  • @@ -461,7 +565,11 @@ export default function GoalTracker() {

    )} +<<<<<<< HEAD {/* Goal Creation Form */} +======= + +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI)
    +
+>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) {/* Recurrence Picker */}
@@ -547,7 +672,11 @@ export default function GoalTracker() { disabled={creating} className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium border transition-all ${ recurrence === r +<<<<<<< HEAD ? "border-[var(--accent)] bg-[var(--accent)] text-[var(--accent-foreground)]" +======= + ? "border-[var(--accent)] bg-[var(--accent)] text-white" +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) : "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--accent)]" }`} > @@ -562,6 +691,7 @@ export default function GoalTracker() { )}
+<<<<<<< HEAD {(unit === "commits" || unit === "prs") && (

âš¡ This goal will auto-update from your GitHub activity. @@ -572,6 +702,12 @@ export default function GoalTracker() { type="submit" disabled={creating || !title.trim()} className="inline-flex items-center gap-2 rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60" +======= + +<<<<<<< HEAD {createError && (

{createError}

+======= + + {createError && ( +

{createError}

+>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) )}
@@ -595,7 +737,11 @@ function ConfettiBurst() { useEffect(() => { const colors = ["var(--accent)", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899"]; +<<<<<<< HEAD const newParticles: Array<{ id: number; x: number; y: number; color: string; rot: number; scale: number; speed: number }> = []; +======= + const newParticles = []; +>>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) for (let i = 0; i < 35; i++) { const angle = Math.random() * Math.PI * 2; const distance = 30 + Math.random() * 140; From a9d8707e1d3f2b3ebceae22c46cab73a2acfd9c0 Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 11:51:30 +0530 Subject: [PATCH 07/12] fix: resolve dashboard page syntax errors --- src/app/dashboard/settings/page.tsx | 2 +- src/app/u/[username]/page.tsx | 9 ++++++--- src/components/DashboardHeader.tsx | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index d41faec47..85103978e 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -752,7 +752,7 @@ function SettingsPageContent() {
{/* Custom Spotlight Repositories */} - {profile.spotlightRepos && profile.spotlightRepos.length > 0 && ( + {profile.spotlightRepos?.length ? (
- +
- )} + ) : null} {/* Row 2: Top repos */}
diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx index 81ba5c6ac..029e32a63 100644 --- a/src/components/DashboardHeader.tsx +++ b/src/components/DashboardHeader.tsx @@ -198,6 +198,7 @@ export default function DashboardHeader() {

)}
+
{/* Right Section */}
From db1cbdd255c6a55d608914b431127bde3ece415d Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 13:09:04 +0530 Subject: [PATCH 08/12] fix: resolve merge conflict markers in GoalTracker.tsx --- src/components/GoalTracker.tsx | 146 --------------------------------- 1 file changed, 146 deletions(-) diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index 0e6acc64d..83920c352 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -26,29 +26,20 @@ const RECURRENCE_LABELS: Record = { export default function GoalTracker() { const [goals, setGoals] = useState([]); const [loading, setLoading] = useState(true); -<<<<<<< HEAD const [syncing, setSyncing] = useState(false); const [syncError, setSyncError] = useState(null); -======= ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); const [title, setTitle] = useState(""); const [target, setTarget] = useState(7); const [unit, setUnit] = useState("commits"); const [recurrence, setRecurrence] = useState("none"); -<<<<<<< HEAD const [deadline, setDeadline] = useState(""); -======= ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); const [confirmingId, setConfirmingId] = useState(null); const [deletingId, setDeletingId] = useState(null); -<<<<<<< HEAD const [deleteError, setDeleteError] = useState(null); -======= ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) const [activeConfettiGoalId, setActiveConfettiGoalId] = useState(null); const prevGoalsRef = useRef>(new Map()); @@ -57,7 +48,6 @@ export default function GoalTracker() { const loadGoals = useCallback(async () => { const response = await fetch("/api/goals"); const data: { goals: Goal[] } = await response.json(); -<<<<<<< HEAD const fetchedGoals = data.goals ?? []; setGoals(fetchedGoals); return fetchedGoals; @@ -114,20 +104,12 @@ export default function GoalTracker() { await handleSync(); } }) -======= - setGoals(data.goals ?? []); - }, []); - - useEffect(() => { - loadGoals() ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) .catch(() => {}) .finally(() => { setLoading(false); setLastUpdated(new Date()); setMinutesAgo(0); }); -<<<<<<< HEAD }, [loadGoals, handleSync]); useEffect(() => { @@ -141,8 +123,6 @@ export default function GoalTracker() { }; window.addEventListener("devtrack:sync", handleSyncEvent); return () => window.removeEventListener("devtrack:sync", handleSyncEvent); -======= ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) }, [loadGoals]); async function handleCreate(e: React.FormEvent) { @@ -151,7 +131,6 @@ export default function GoalTracker() { setCreateError(null); try { -<<<<<<< HEAD const result = await submitGoalWithRefresh({ payload: { title, target, unit, recurrence, deadline: deadline || null }, handleSync, @@ -180,29 +159,6 @@ export default function GoalTracker() { } finally { setCreating(false); } -======= - const response = await fetch("/api/goals", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title, target, unit, recurrence }), - }); - - if (!response.ok) { - throw new Error("Failed to create goal"); - } - } catch { - setCreateError("Failed to create goal. Please try again."); - setCreating(false); - return; - } - - setTitle(""); - setTarget(7); - setUnit("commits"); - setRecurrence("none"); - await loadGoals().catch(() => {}); - setCreating(false); ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) } async function handleDelete(id: string) { @@ -210,26 +166,17 @@ export default function GoalTracker() { setGoals((prev) => prev.filter((g) => g.id !== id)); setConfirmingId(null); setDeletingId(id); -<<<<<<< HEAD setDeleteError(null); -======= ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) try { const res = await fetch(`/api/goals/${id}`, { method: "DELETE" }); if (!res.ok) { setGoals(previousGoals); -<<<<<<< HEAD setDeleteError("Failed to delete goal. Please try again."); } } catch { setGoals(previousGoals); setDeleteError("Failed to delete goal. Please check your connection."); -======= - } - } catch { - setGoals(previousGoals); ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) } finally { setDeletingId(null); } @@ -241,7 +188,6 @@ export default function GoalTracker() { if (goal.recurrence === "monthly") return "Completed this month ✓"; return "Completed ✓"; } -<<<<<<< HEAD if (goal.deadline) { const msLeft = new Date(goal.deadline).getTime() - Date.now(); @@ -251,8 +197,6 @@ export default function GoalTracker() { return `${daysLeft}d left`; } -======= ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) return ""; } @@ -297,11 +241,7 @@ export default function GoalTracker() { if (loading) { return ( -<<<<<<< HEAD
-======= -
->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI)
Loading weekly goals
{/* ── Header ── */}
@@ -371,10 +310,6 @@ export default function GoalTracker() {
)} -======= -
-

Weekly Goals

->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) {goals.length === 0 ? (

@@ -383,30 +318,19 @@ export default function GoalTracker() { ) : (

    {goals.map((goal) => { -<<<<<<< HEAD const pct = Math.min((goal.current / goal.target) * 100, 100); -======= - const progress = goal.target > 0 ? Math.min((goal.current / goal.target) * 100, 100) : 0; ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) const isConfirming = confirmingId === goal.id; const isDeleting = deletingId === goal.id; const completed = goal.current >= goal.target; const completionLabel = getCompletionLabel(goal); -<<<<<<< HEAD const isAutoSynced = goal.unit === "commits" || goal.unit === "prs"; -======= ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) return (
  • {activeConfettiGoalId === goal.id && }
    -<<<<<<< HEAD
    -======= -
    ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) {goal.title} {goal.recurrence !== "none" && ( )} -<<<<<<< HEAD {isAutoSynced && ( ) : null} -======= -
    - {completed && ( - - {completionLabel} - - )} ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI)
    @@ -464,7 +379,6 @@ export default function GoalTracker() { {goal.current}/{goal.target} {goal.unit} -<<<<<<< HEAD {/* Manual +1 only for non-auto-synced goals */} {!isAutoSynced && ( @@ -545,12 +445,8 @@ export default function GoalTracker() {
    >>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) />
  • @@ -565,11 +461,7 @@ export default function GoalTracker() {

    )} -<<<<<<< HEAD {/* Goal Creation Form */} -======= - ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI)
    -
->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) {/* Recurrence Picker */}
@@ -672,11 +547,7 @@ export default function GoalTracker() { disabled={creating} className={`flex-1 py-1.5 px-2 rounded-lg text-xs font-medium border transition-all ${ recurrence === r -<<<<<<< HEAD ? "border-[var(--accent)] bg-[var(--accent)] text-[var(--accent-foreground)]" -======= - ? "border-[var(--accent)] bg-[var(--accent)] text-white" ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) : "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--accent)]" }`} > @@ -691,7 +562,6 @@ export default function GoalTracker() { )}
-<<<<<<< HEAD {(unit === "commits" || unit === "prs") && (

âš¡ This goal will auto-update from your GitHub activity. @@ -702,12 +572,6 @@ export default function GoalTracker() { type="submit" disabled={creating || !title.trim()} className="inline-flex items-center gap-2 rounded-lg bg-[var(--accent)] px-4 py-2 text-sm font-medium text-[var(--accent-foreground)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60" -======= - -<<<<<<< HEAD {createError && (

{createError}

-======= - - {createError && ( -

{createError}

->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) )}
@@ -737,11 +595,7 @@ function ConfettiBurst() { useEffect(() => { const colors = ["var(--accent)", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#EC4899"]; -<<<<<<< HEAD const newParticles: Array<{ id: number; x: number; y: number; color: string; rot: number; scale: number; speed: number }> = []; -======= - const newParticles = []; ->>>>>>> 3c3aa2d (fix: repair JSX/TS syntax blocking CI) for (let i = 0; i < 35; i++) { const angle = Math.random() * Math.PI * 2; const distance = 30 + Math.random() * 140; From 2a6f9e5577858d2cef5a31d12d45e9be0f59319d Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 13:11:13 +0530 Subject: [PATCH 09/12] fix: rename handleSaveBioPlain to handleSaveBio in settings --- src/app/dashboard/settings/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 85103978e..d41faec47 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -752,7 +752,7 @@ function SettingsPageContent() {
-
-

- Dashboard overview -

-

- Dashboard -

-

- coding activity at a glance +

+ Your coding activity at a glance 🚀

{minutesAgo !== null && (

@@ -198,7 +185,6 @@ export default function DashboardHeader() {

)}
-
{/* Right Section */}
From 4c961edb25aa4037ef0724b0d0bbc594af617376 Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Mon, 1 Jun 2026 11:19:33 +0530 Subject: [PATCH 12/12] fix(e2e): update button name from 'Add goal' to 'Create goal' after upstream merge --- e2e/dashboard-widgets.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js index 6904ca6e9..f18d1f535 100644 --- a/e2e/dashboard-widgets.spec.js +++ b/e2e/dashboard-widgets.spec.js @@ -214,7 +214,7 @@ test("goal form posts a new goal", async ({ page }) => { await page.getByLabel("Goal title").fill("Ship one PR"); await page.getByLabel("Target").fill("1"); await page.getByLabel("Unit").selectOption("prs"); - await page.getByRole("button", { name: "Add goal" }).click(); + await page.getByRole("button", { name: "Create goal" }).click(); await expect.poll(() => goalPosts, { timeout: 15000 }).toHaveLength(1); expect(goalPosts[0]).toMatchObject({