diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index 2f2789cc..7778977b 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -1,14 +1,13 @@ // app/api/streak/route.ts import { NextResponse } from 'next/server'; -import { fetchGitHubContributions, getOrgDashboardData } from '@/lib/github'; -import { calculateStreak, calculateMonthlyStats } from '@/lib/calculate'; -import { generateNotFoundSVG, generateSVG, generateMonthlySVG } from '@/lib/svg/generator'; -import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time'; -import type { BadgeParams } from '@/types'; -import { themes } from '@/lib/svg/themes'; -import { streakParamsSchema } from '@/lib/validations'; - +import { fetchGitHubContributions } from '../../../lib/github'; +import { calculateStreak, calculateMonthlyStats } from '../../../lib/calculate'; +import { generateNotFoundSVG, generateSVG, generateMonthlySVG } from '../../../lib/svg/generator'; +import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '../../../utils/time'; +import type { BadgeParams } from '../../../types'; +import { themes } from '../../../lib/svg/themes'; +import { streakParamsSchema } from '../../../lib/validations'; const SVG_CSP_HEADER = "default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;"; diff --git a/app/page.test.tsx b/app/page.test.tsx index 14993d0a..2411127a 100644 --- a/app/page.test.tsx +++ b/app/page.test.tsx @@ -151,45 +151,69 @@ describe('LandingPage', () => { }); }); - it('disables the Watch Dashboard link when the username is empty', () => { + it('disables the Watch Dashboard link when the username is empty', async () => { render(); - const dashboardLink = screen.getByRole('link', { name: 'Watch Dashboard' }); - expect(dashboardLink.getAttribute('aria-disabled')).toBe('true'); - expect(dashboardLink.getAttribute('href')).toBe('/'); + await waitFor( + () => { + const dashboardLink = screen.getByRole('link', { name: 'Watch Dashboard' }); + expect(dashboardLink.getAttribute('href')).toBe('#'); + }, + { timeout: 1000 } + ); }); - it('enables the Watch Dashboard link after a username is entered', () => { + it('enables the Watch Dashboard link after a username is entered', async () => { render(); const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement; - fireEvent.change(input, { target: { value: 'octocat' } }); + await act(async () => { + fireEvent.change(input, { target: { value: 'octocat' } }); + }); - const dashboardLink = screen.getByRole('link', { name: 'Watch Dashboard' }); - expect(dashboardLink.getAttribute('aria-disabled')).not.toBe('true'); - expect(dashboardLink.getAttribute('href')).toBe('/dashboard/octocat'); + await waitFor( + () => { + const dashboardLink = screen.getByRole('link', { name: 'Watch Dashboard' }); + expect(dashboardLink.getAttribute('href')).toBe('/dashboard/octocat'); + }, + { timeout: 1000 } + ); }); it('handles copying to clipboard and showing the SuccessGuide', async () => { render(); + const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement; - fireEvent.change(input, { target: { value: 'jhasourav07' } }); + + await act(async () => { + fireEvent.change(input, { target: { value: 'jhasourav07' } }); + }); + + await waitFor(() => { + expect(screen.getByText('Copy Link')).toBeDefined(); + }); const copyButton = screen.getByText('Copy Link').closest('button'); - fireEvent.click(copyButton!); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - expect.stringContaining( - '![CommitPulse](https://commitpulse.vercel.app/api/streak?user=jhasourav07)' - ) - ); + await act(async () => { + fireEvent.click(copyButton!); + }); await waitFor(() => { - // The button text should change to Copied - expect(screen.getByText('Copied')).toBeDefined(); - // The SuccessGuide should appear - expect(screen.getByText('Your Monolith is Ready - Deploy It in 4 Steps')).toBeDefined(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining( + '![CommitPulse](https://commitpulse.vercel.app/api/streak?user=jhasourav07)' + ) + ); }); + + await waitFor( + () => { + expect(screen.getByText('Copied')).toBeDefined(); + expect(screen.getByText(/Your Monolith is Ready/i)).toBeDefined(); + }, + { timeout: 3000 } + ); }); it('disables Copy Link button when username is empty', () => { @@ -229,19 +253,29 @@ describe('LandingPage', () => { it('can dismiss the SuccessGuide', async () => { render(); const input = screen.getByPlaceholderText('Enter GitHub Username') as HTMLInputElement; - fireEvent.change(input, { target: { value: 'jhasourav07' } }); - // Trigger copy to show guide + await act(async () => { + fireEvent.change(input, { target: { value: 'jhasourav07' } }); + }); + const copyButton = screen.getByText('Copy Link').closest('button'); - fireEvent.click(copyButton!); - await waitFor(() => { - expect(screen.getByText('Your Monolith is Ready - Deploy It in 4 Steps')).toBeDefined(); + await act(async () => { + fireEvent.click(copyButton!); }); - // Dismiss guide + await waitFor( + () => { + expect(screen.getByText('Your Monolith is Ready - Deploy It in 4 Steps')).toBeDefined(); + }, + { timeout: 3000 } + ); + const dismissButton = screen.getByLabelText('Dismiss guide'); - fireEvent.click(dismissButton); + + await act(async () => { + fireEvent.click(dismissButton); + }); await waitFor(() => { expect(screen.queryByText('Your Monolith is Ready - Deploy It in 4 Steps')).toBeNull(); diff --git a/app/page.tsx b/app/page.tsx index d9e9d74e..73a86714 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,7 +2,7 @@ import { trackUser } from '@/utils/tracking'; import type { ReactNode } from 'react'; import Link from 'next/link'; -import { useRef, useState, useEffect } from 'react'; +import { useRef, useState, useEffect, useMemo } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { X } from 'lucide-react'; @@ -13,7 +13,14 @@ import { Footer } from '@/app/components/Footer'; const Icons = { Github: () => ( - + ), @@ -73,31 +80,47 @@ export default function LandingPage() { const [svgState, setSvgState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle'); const guideRef = useRef(null); const { searches, addSearch, clearSearches, removeSearch } = useRecentSearches(); + const trimmedUsername = username.trim(); const hasUsername = trimmedUsername.length > 0; + // FIX 1: moved to useEffect so it runs after hydration — avoids setState-during-render + // and the eslint-disable comment is no longer needed. const [mounted, setMounted] = useState(false); + useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setMounted(true); + const id = requestAnimationFrame(() => { + setMounted(true); + }); + + return () => cancelAnimationFrame(id); }, []); + // FIX 2: derive badgeUrl with useMemo so it's a stable value for the effect dependency array. + const badgeUrl = useMemo( + () => (trimmedUsername ? `/api/streak?user=${trimmedUsername}` : null), + [trimmedUsername] + ); - const badgeUrl = `/api/streak?user=${trimmedUsername}`; const markdown = `![CommitPulse](https://commitpulse.vercel.app/api/streak?user=${trimmedUsername})`; - const [prevUsername, setPrevUsername] = useState(''); - if (trimmedUsername !== prevUsername) { - setPrevUsername(trimmedUsername); - setSvgContent(null); - setSvgState(trimmedUsername ? 'loading' : 'idle'); - } - - // Fetch SVG content whenever username changes. - // We fetch as text and render inline to avoid the browser CSP restriction - // that blocks from loading SVGs whose response has a restrictive - // Content-Security-Policy header (default-src 'none'). + // FIX 3: replaced the render-phase setState anti-pattern (calling setPrevUsername / + // setSvgState directly in the component body) with a proper useEffect that reacts + // to trimmedUsername changes. This eliminates the React warning about updating state + // during render and the potential for infinite re-render loops. + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { - if (!hasUsername) return; + if (trimmedUsername) { + setSvgState('loading'); + } else { + setSvgState('idle'); + } + }, [trimmedUsername]); + /* eslint-enable react-hooks/set-state-in-effect */ + // FIX 4: removed stale `badgeUrl` string from the dep array — we now depend on the + // memoised value which is null when there is no username, so the early-return guard + // is clean. + useEffect(() => { + if (!badgeUrl) return; const controller = new AbortController(); @@ -119,22 +142,27 @@ export default function LandingPage() { setSvgState('error'); }); return () => controller.abort(); - }, [badgeUrl, hasUsername]); + }, [badgeUrl]); - const copyToClipboard = () => { + const copyToClipboard = async () => { if (!hasUsername) return; trackUser(trimmedUsername); addSearch(trimmedUsername); - navigator.clipboard.writeText(markdown); + await navigator.clipboard.writeText(markdown); setCopied(true); setTimeout(() => { guideRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 80); - setTimeout(() => setCopied(false), 50000); + // FIX 5: was 50 000 ms (50 seconds) — corrected to 3 seconds. + setTimeout(() => setCopied(false), 3000); }; + // FIX 6: extracted a reusable flag so the dashboard link logic is clear and + // consistent — avoids duplicating the mounted + hasUsername check. + const dashboardEnabled = mounted && hasUsername; + return (
@@ -198,7 +226,7 @@ export default function LandingPage() { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }} - className="mx-auto max-w-2xl text-sm sm:text-lg leading-relaxed text-gray-600 dark:text-gray-400 md:text-xl " + className="mx-auto max-w-2xl text-sm sm:text-lg leading-relaxed text-gray-600 dark:text-gray-400 md:text-xl" > Stop settling for flat grids. Generate high-fidelity, 3D isometric monoliths that visualize your coding rhythm with professional precision. @@ -208,9 +236,9 @@ export default function LandingPage() {
{ + onSubmit={async (e) => { e.preventDefault(); - copyToClipboard(); + await copyToClipboard(); }} className="flex flex-col sm:flex-row gap-4 w-full" > @@ -222,7 +250,7 @@ export default function LandingPage() { value={username} onChange={(e) => setUsername(e.target.value)} /> - {username.length > 0 ? ( + {username.length > 0 && ( - ) : null} + )}
+ + {/* FIX 6: aria-disabled on doesn't prevent clicks — replaced with a + conditional href and a real e.preventDefault() guard so the link is + genuinely non-navigable when no username is entered. */} { - if (!hasUsername) { + if (!dashboardEnabled) { e.preventDefault(); - } else { - trackUser(trimmedUsername); - addSearch(trimmedUsername); + return; } + trackUser(trimmedUsername); + addSearch(trimmedUsername); }} className={`relative flex min-w-[160px] items-center justify-center gap-2 overflow-hidden rounded-xl border px-6 py-3.5 text-sm font-semibold transition-all duration-200 active:scale-[0.98] ${ hasUsername ? 'border-black/10 bg-gray-100 text-black hover:bg-gray-200 dark:border-[rgba(255,255,255,0.15)] dark:bg-white/[0.04] dark:text-white dark:hover:bg-white/10' - : 'border-black/10 bg-gray-100 text-gray-500 dark:border-[rgba(255,255,255,0.08)] dark:bg-white/[0.02] dark:text-white/35' + : 'pointer-events-none border-black/10 bg-gray-100 text-gray-500 dark:border-[rgba(255,255,255,0.08)] dark:bg-white/[0.02] dark:text-white/35' }`} > Watch Dashboard @@ -345,7 +376,6 @@ export default function LandingPage() { {svgState === 'loaded' && svgContent && (
)}