Skip to content

Commit 28a1e1d

Browse files
Merge branch 'main' into test-2949
2 parents 045c7b4 + 0783fae commit 28a1e1d

50 files changed

Lines changed: 3274 additions & 279 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/(root)/dashboard/[username]/page.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ describe('DashboardPage', () => {
9393
currentStreak: 5,
9494
peakStreak: 15,
9595
totalContributions: 500,
96+
codingHabit: 'Night Owl',
97+
totalPRs: 10,
98+
totalIssues: 5,
9699
},
97100
languages: [{ name: 'TypeScript', percentage: 100, color: '#3178c6' }],
98101
activity: [],

app/compare/CompareClient.tsx

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ import {
1616
Calendar,
1717
Trophy,
1818
Loader2,
19+
Moon,
20+
Sun,
21+
Coffee,
22+
Plus,
23+
Minus,
24+
Code2,
25+
GitPullRequest,
26+
CircleDot,
1927
} from 'lucide-react';
2028

2129
/* ── types ────────────────────────────────────────────────────────────── */
@@ -41,6 +49,9 @@ interface UserStats {
4149
currentStreak: number;
4250
peakStreak: number;
4351
totalContributions: number;
52+
codingHabit?: string;
53+
totalPRs?: number;
54+
totalIssues?: number;
4455
}
4556

4657
interface LanguageData {
@@ -53,6 +64,8 @@ interface ActivityData {
5364
date: string;
5465
count: number;
5566
intensity: 0 | 1 | 2 | 3 | 4;
67+
locAdditions?: number;
68+
locDeletions?: number;
5669
}
5770

5871
interface CompareUserData {
@@ -389,6 +402,228 @@ function CompareSkeleton() {
389402
);
390403
}
391404

405+
/* ── helper: coding habit showdown ────────────────────────────────────── */
406+
407+
function CodingHabitCard({
408+
username,
409+
habit,
410+
side,
411+
}: {
412+
username: string;
413+
habit?: string;
414+
side: 'left' | 'right';
415+
}) {
416+
const isNight = habit === 'Night Owl';
417+
const isEarly = habit === 'Early Bird';
418+
419+
const icon = isNight ? (
420+
<Moon size={24} className="text-purple-400" />
421+
) : isEarly ? (
422+
<Sun size={24} className="text-amber-400" />
423+
) : (
424+
<Coffee size={24} className="text-teal-400" />
425+
);
426+
const bgClass = isNight
427+
? 'bg-gradient-to-br from-indigo-950 to-purple-950 border-purple-500/30'
428+
: isEarly
429+
? 'bg-gradient-to-br from-amber-900/40 to-orange-900/40 border-orange-500/30'
430+
: 'bg-gradient-to-br from-teal-900/40 to-emerald-900/40 border-teal-500/30';
431+
432+
const glowClass = isNight
433+
? 'shadow-[0_0_15px_rgba(168,85,247,0.15)]'
434+
: isEarly
435+
? 'shadow-[0_0_15px_rgba(245,158,11,0.15)]'
436+
: 'shadow-[0_0_15px_rgba(20,184,166,0.15)]';
437+
438+
return (
439+
<motion.div
440+
initial={{ opacity: 0, x: side === 'left' ? -20 : 20 }}
441+
whileInView={{ opacity: 1, x: 0 }}
442+
viewport={{ once: true }}
443+
whileHover={{ scale: 1.02 }}
444+
className={`relative overflow-hidden p-6 rounded-2xl border ${bgClass} ${glowClass} transition-all duration-300 flex flex-col items-center justify-center text-center h-full min-h-[140px]`}
445+
>
446+
<motion.div
447+
animate={{ y: [0, -5, 0] }}
448+
transition={{ duration: 4, repeat: Infinity, ease: 'easeInOut' }}
449+
className="mb-3 z-10"
450+
>
451+
{icon}
452+
</motion.div>
453+
<h4 className="text-xs font-bold uppercase tracking-widest text-white/70 mb-1 z-10">
454+
@{username}
455+
</h4>
456+
<h3 className="text-xl font-black tracking-tight text-white z-10">{habit || 'Unknown'}</h3>
457+
458+
{/* Decorative background elements */}
459+
{isNight && (
460+
<motion.div
461+
animate={{ opacity: [0.3, 0.7, 0.3] }}
462+
transition={{ duration: 3, repeat: Infinity }}
463+
className="absolute top-4 right-6 text-purple-300/20 text-xs"
464+
>
465+
466+
</motion.div>
467+
)}
468+
{isEarly && (
469+
<motion.div
470+
animate={{ rotate: 360 }}
471+
transition={{ duration: 20, repeat: Infinity, ease: 'linear' }}
472+
className="absolute -bottom-6 -right-6 text-amber-500/10"
473+
>
474+
<Sun size={80} />
475+
</motion.div>
476+
)}
477+
</motion.div>
478+
);
479+
}
480+
481+
function CodingHabitShowdown({ user1, user2 }: { user1: CompareUserData; user2: CompareUserData }) {
482+
return (
483+
<div>
484+
<h2 className="text-xs text-[#A1A1AA] uppercase tracking-widest font-medium mb-4">
485+
Coding Habits
486+
</h2>
487+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 relative">
488+
<CodingHabitCard
489+
username={user1.profile.username}
490+
habit={user1.stats.codingHabit}
491+
side="left"
492+
/>
493+
494+
<div className="hidden md:flex absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10">
495+
<div className="w-8 h-8 rounded-full bg-white dark:bg-[#0a0a0a] border-2 border-black/10 dark:border-[rgba(255,255,255,0.08)] flex items-center justify-center shadow-xl">
496+
<span className="text-[10px] font-bold text-[#A1A1AA] tracking-wider">VS</span>
497+
</div>
498+
</div>
499+
500+
<CodingHabitCard
501+
username={user2.profile.username}
502+
habit={user2.stats.codingHabit}
503+
side="right"
504+
/>
505+
</div>
506+
</div>
507+
);
508+
}
509+
510+
/* ── helper: code volume showdown ─────────────────────────────────────── */
511+
512+
function CodeVolumeShowdown({ user1, user2 }: { user1: CompareUserData; user2: CompareUserData }) {
513+
const calcLoC = (activity: ActivityData[]) => {
514+
let add = 0,
515+
del = 0;
516+
activity.forEach((d) => {
517+
add += d.locAdditions || 0;
518+
del += d.locDeletions || 0;
519+
});
520+
return { add, del, net: add - del };
521+
};
522+
523+
const loc1 = calcLoC(user1.activity);
524+
const loc2 = calcLoC(user2.activity);
525+
526+
const maxAdd = Math.max(loc1.add, loc2.add, 1);
527+
const maxDel = Math.max(loc1.del, loc2.del, 1);
528+
529+
const users = [
530+
{ username: user1.profile.username, loc: loc1, side: 'left' as const },
531+
{ username: user2.profile.username, loc: loc2, side: 'right' as const },
532+
];
533+
534+
return (
535+
<div>
536+
<h2 className="text-xs text-[#A1A1AA] uppercase tracking-widest font-medium mb-4 flex items-center gap-2">
537+
<Code2 size={14} className="text-violet-400" />
538+
Code Volume
539+
</h2>
540+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
541+
{users.map(({ username, loc, side }) => (
542+
<motion.div
543+
key={side}
544+
initial={{ opacity: 0, x: side === 'left' ? -20 : 20 }}
545+
whileInView={{ opacity: 1, x: 0 }}
546+
viewport={{ once: true }}
547+
whileHover={{ scale: 1.01 }}
548+
className="relative overflow-hidden p-6 rounded-2xl bg-white dark:bg-[#0a0a0a] border border-black/10 dark:border-[rgba(255,255,255,0.08)] transition-all duration-300"
549+
>
550+
<h4 className="text-xs font-bold uppercase tracking-widest text-[#A1A1AA] mb-5">
551+
@{username}
552+
</h4>
553+
554+
{/* Additions Bar */}
555+
<div className="mb-4">
556+
<div className="flex justify-between items-center mb-2">
557+
<span className="flex items-center gap-1.5 text-xs font-medium text-emerald-500">
558+
<Plus size={12} /> Lines Added
559+
</span>
560+
<motion.span
561+
initial={{ opacity: 0 }}
562+
whileInView={{ opacity: 1 }}
563+
className="text-sm font-bold text-emerald-500"
564+
>
565+
+{loc.add.toLocaleString()}
566+
</motion.span>
567+
</div>
568+
<div className="w-full h-3 bg-gray-100 dark:bg-[#111] rounded-full overflow-hidden border border-black/5 dark:border-[rgba(255,255,255,0.04)]">
569+
<motion.div
570+
initial={{ width: 0 }}
571+
whileInView={{ width: `${(loc.add / maxAdd) * 100}%` }}
572+
viewport={{ once: true }}
573+
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1], delay: 0.2 }}
574+
className="h-full rounded-full bg-gradient-to-r from-emerald-500 to-emerald-400 shadow-[0_0_10px_rgba(16,185,129,0.3)]"
575+
/>
576+
</div>
577+
</div>
578+
579+
{/* Deletions Bar */}
580+
<div className="mb-5">
581+
<div className="flex justify-between items-center mb-2">
582+
<span className="flex items-center gap-1.5 text-xs font-medium text-rose-500">
583+
<Minus size={12} /> Lines Deleted
584+
</span>
585+
<motion.span
586+
initial={{ opacity: 0 }}
587+
whileInView={{ opacity: 1 }}
588+
className="text-sm font-bold text-rose-500"
589+
>
590+
-{loc.del.toLocaleString()}
591+
</motion.span>
592+
</div>
593+
<div className="w-full h-3 bg-gray-100 dark:bg-[#111] rounded-full overflow-hidden border border-black/5 dark:border-[rgba(255,255,255,0.04)]">
594+
<motion.div
595+
initial={{ width: 0 }}
596+
whileInView={{ width: `${(loc.del / maxDel) * 100}%` }}
597+
viewport={{ once: true }}
598+
transition={{ duration: 1.2, ease: [0.16, 1, 0.3, 1], delay: 0.4 }}
599+
className="h-full rounded-full bg-gradient-to-r from-rose-500 to-rose-400 shadow-[0_0_10px_rgba(244,63,94,0.3)]"
600+
/>
601+
</div>
602+
</div>
603+
604+
{/* Net Impact */}
605+
<div className="flex items-center justify-between p-3 rounded-xl bg-gray-50 dark:bg-[#111] border border-black/5 dark:border-[rgba(255,255,255,0.06)]">
606+
<span className="text-[9px] font-medium text-[#A1A1AA] uppercase tracking-widest">
607+
Net Impact
608+
</span>
609+
<motion.span
610+
initial={{ scale: 0 }}
611+
whileInView={{ scale: 1 }}
612+
viewport={{ once: true }}
613+
transition={{ type: 'spring', stiffness: 300, damping: 20, delay: 0.6 }}
614+
className={`text-lg font-black tracking-tight ${loc.net >= 0 ? 'text-emerald-500' : 'text-rose-500'}`}
615+
>
616+
{loc.net >= 0 ? '+' : ''}
617+
{loc.net.toLocaleString()}
618+
</motion.span>
619+
</div>
620+
</motion.div>
621+
))}
622+
</div>
623+
</div>
624+
);
625+
}
626+
392627
/* ── main component ───────────────────────────────────────────────────── */
393628

394629
export default function CompareClient() {
@@ -659,9 +894,27 @@ export default function CompareClient() {
659894
valueA={d1.profile.stats.followers}
660895
valueB={d2.profile.stats.followers}
661896
/>
897+
<StatBattle
898+
label="Pull Requests"
899+
icon={GitPullRequest}
900+
valueA={d1.stats.totalPRs || 0}
901+
valueB={d2.stats.totalPRs || 0}
902+
/>
903+
<StatBattle
904+
label="Issues"
905+
icon={CircleDot}
906+
valueA={d1.stats.totalIssues || 0}
907+
valueB={d2.stats.totalIssues || 0}
908+
/>
662909
</div>
663910
</div>
664911

912+
{/* Coding Habits Showdown */}
913+
<CodingHabitShowdown user1={d1} user2={d2} />
914+
915+
{/* Code Volume Showdown */}
916+
<CodeVolumeShowdown user1={d1} user2={d2} />
917+
665918
{/* Language Comparison */}
666919
<LanguageComparison
667920
langsA={d1.languages}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2+
import { describe, expect, it, vi, beforeEach } from 'vitest';
3+
4+
import CopyRepoButton from './CopyRepoButton';
5+
6+
vi.mock('lucide-react', () => ({
7+
Copy: () => <svg data-testid="copy-icon" />,
8+
}));
9+
10+
describe('CopyRepoButton error resilience', () => {
11+
beforeEach(() => {
12+
vi.restoreAllMocks();
13+
});
14+
15+
it('renders successfully during initial mount', () => {
16+
render(<CopyRepoButton />);
17+
18+
expect(screen.getByRole('button', { name: /copy url/i })).toBeDefined();
19+
});
20+
21+
it('does not crash when clipboard write rejects', async () => {
22+
Object.assign(navigator, {
23+
clipboard: {
24+
writeText: vi.fn().mockRejectedValue(new Error('Clipboard failed')),
25+
},
26+
});
27+
28+
render(<CopyRepoButton />);
29+
30+
fireEvent.click(screen.getByRole('button'));
31+
32+
await waitFor(() => {
33+
expect(screen.getByRole('button')).toBeDefined();
34+
});
35+
});
36+
37+
it('keeps original label visible after clipboard failure', async () => {
38+
Object.assign(navigator, {
39+
clipboard: {
40+
writeText: vi.fn().mockRejectedValue(new Error('Clipboard failed')),
41+
},
42+
});
43+
44+
render(<CopyRepoButton />);
45+
46+
fireEvent.click(screen.getByRole('button'));
47+
48+
await waitFor(() => {
49+
expect(screen.getByText(/copy url/i)).toBeDefined();
50+
});
51+
});
52+
53+
it('renders icon even when copy action fails', async () => {
54+
Object.assign(navigator, {
55+
clipboard: {
56+
writeText: vi.fn().mockRejectedValue(new Error('Clipboard failed')),
57+
},
58+
});
59+
60+
render(<CopyRepoButton />);
61+
62+
fireEvent.click(screen.getByRole('button'));
63+
64+
await waitFor(() => {
65+
expect(screen.getByTestId('copy-icon')).toBeDefined();
66+
});
67+
});
68+
69+
it('allows repeated failed copy attempts without crashing', async () => {
70+
Object.assign(navigator, {
71+
clipboard: {
72+
writeText: vi.fn().mockRejectedValue(new Error('Clipboard failed')),
73+
},
74+
});
75+
76+
render(<CopyRepoButton />);
77+
78+
const button = screen.getByRole('button');
79+
80+
fireEvent.click(button);
81+
fireEvent.click(button);
82+
fireEvent.click(button);
83+
84+
await waitFor(() => {
85+
expect(screen.getByRole('button')).toBeDefined();
86+
});
87+
});
88+
});

0 commit comments

Comments
 (0)