Skip to content
Open
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
15 changes: 7 additions & 8 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
@@ -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;";

Expand Down Expand Up @@ -113,7 +112,7 @@

// Fetch Organization Mega-City Data OR Single User Data
if (org) {
const orgData = await getOrgDashboardData(org, {

Check failure on line 115 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Cannot find name 'getOrgDashboardData'.
bypassCache: refresh,
from,
to,
Expand Down
88 changes: 61 additions & 27 deletions app/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<LandingPage />);
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(<LandingPage />);
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(<LandingPage />);

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', () => {
Expand Down Expand Up @@ -229,19 +253,29 @@ describe('LandingPage', () => {
it('can dismiss the SuccessGuide', async () => {
render(<LandingPage />);
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();
Expand Down
102 changes: 66 additions & 36 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -13,7 +13,14 @@ import { Footer } from '@/app/components/Footer';

const Icons = {
Github: () => (
<svg height="24" width="24" viewBox="0 0 16 16" fill="currentColor">
<svg
role="img"
aria-label="github icon"
height="24"
width="24"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
),
Expand Down Expand Up @@ -73,31 +80,47 @@ export default function LandingPage() {
const [svgState, setSvgState] = useState<'idle' | 'loading' | 'loaded' | 'error'>('idle');
const guideRef = useRef<HTMLDivElement>(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 <img> 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();

Expand All @@ -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 (
<div className="min-h-screen overflow-x-hidden bg-transparent font-sans text-black dark:text-white selection:bg-black/20 dark:selection:bg-white/20">
<div className="pointer-events-none fixed inset-0 overflow-hidden">
Expand Down Expand Up @@ -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.
Expand All @@ -208,9 +236,9 @@ export default function LandingPage() {
<section className="mx-auto mb-32 max-w-4xl">
<div className="rounded-2xl border border-black/10 bg-white p-4 dark:border-[rgba(255,255,255,0.08)] dark:bg-[#0a0a0a] md:p-8">
<form
onSubmit={(e) => {
onSubmit={async (e) => {
e.preventDefault();
copyToClipboard();
await copyToClipboard();
}}
className="flex flex-col sm:flex-row gap-4 w-full"
>
Expand All @@ -222,7 +250,7 @@ export default function LandingPage() {
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
{username.length > 0 ? (
{username.length > 0 && (
<button
onClick={() => setUsername('')}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 transition-colors hover:text-black dark:text-[#A1A1AA] dark:hover:text-white"
Expand All @@ -231,14 +259,14 @@ export default function LandingPage() {
>
<X size={18} />
</button>
) : null}
)}
</div>

<div className="flex flex-col sm:flex-row gap-4">
<button
type="submit"
disabled={!mounted || !hasUsername}
className={`relative flex min-w-[160px] items-center justify-center gap-2 overflow-hidden rounded-xl px-6 py-3.5 text-sm font-semibold transition-all duration-200 transform cursor-pointer hover:scale-105 hover:brightness-125 active:scale-[0.98] disabled:cursor-not-allowed ${
disabled={!dashboardEnabled}
className={`relative flex min-w-[160px] items-center justify-center gap-2 overflow-hidden rounded-xl px-6 py-3.5 text-sm font-semibold transition-all duration-200 active:scale-[0.98] ${
hasUsername
? 'bg-black text-white hover:bg-zinc-800 dark:bg-white dark:text-black dark:hover:bg-zinc-100'
: 'bg-gray-200 text-gray-500 dark:bg-white/10 dark:text-white/35'
Expand Down Expand Up @@ -266,21 +294,24 @@ export default function LandingPage() {
)}
</AnimatePresence>
</button>

{/* FIX 6: aria-disabled on <a> 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. */}
<Link
href={hasUsername ? `/dashboard/${trimmedUsername}` : '/'}
aria-disabled={!mounted || !hasUsername}
href={dashboardEnabled ? `/dashboard/${trimmedUsername}` : '#'}
onClick={(e) => {
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
Expand Down Expand Up @@ -345,7 +376,6 @@ export default function LandingPage() {
{svgState === 'loaded' && svgContent && (
<div
className="cp-svg-container w-full max-w-[600px] drop-shadow-[0_20px_50px_rgba(0,0,0,0.5)] [&>svg]:w-full [&>svg]:h-auto"
// Safe: SVG is generated server-side by our own trusted generator
dangerouslySetInnerHTML={{ __html: svgContent }}
/>
)}
Expand Down
Loading