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
12 changes: 0 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

106 changes: 81 additions & 25 deletions src/app/(app)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,39 @@ import LevelUpBanner from './level-up-banner';
import { redirect } from 'next/navigation';
import Link from 'next/link';

// Component imports
// Existing dashboard components
import StatsRow, { StatsSkeleton } from './stats-row';
import ActiveIssuesSection, { RecsSkeleton } from './active-issues';
import GitHubPRsWrapper, { PrsSkeleton } from './github-prs-wrapper';
import LeaderboardSnapshot, { LeaderboardSkeleton } from './leaderboard-snapshot';
import MenteesSection, { MenteesSkeleton } from './mentees-section';

// New contributor-dashboard components
import {
ProfileSidebar,
ProfileSidebarSkeleton,
} from '@/components/contributor-dashboard/profile-sidebar';
import JourneyProgress, {
JourneyProgressSkeleton,
} from '@/components/contributor-dashboard/journey-progress';
import RecentActivity, {
RecentActivitySkeleton,
} from '@/components/contributor-dashboard/recent-activity';
import HeatmapWrapper, {
HeatmapSkeleton,
} from '@/components/contributor-dashboard/heatmap-wrapper';
import { DailyChallenge } from '@/components/contributor-dashboard/daily-challenge';
import { CourseProgress } from '@/components/contributor-dashboard/course-progress';
import {
RightSidebar,
RightSidebarSkeleton,
} from '@/components/contributor-dashboard/right-sidebar';

export const dynamic = 'force-dynamic';

export default async function DashboardPage() {
const sb = await getServerSupabase();
if (!sb) {
return <NotConfigured />;
}
if (!sb) return <NotConfigured />;

const {
data: { user },
Expand All @@ -29,57 +48,94 @@ export default async function DashboardPage() {
const service = getServiceSupabase();
if (!service) return <NotConfigured />;

// Fetch only the profile info we need for the page shell header and subcomponents
const { data: profile } = await service
.from('profiles')
.select('github_handle, xp, level, github_total_merges, github_streak, github_stats_synced_at')
.eq('id', user.id)
.maybeSingle();

const xp = profile?.xp ?? 0;
const level = profile?.level ?? 0;
const githubHandle = profile?.github_handle ?? 'Contributor';

return (
<div className="min-h-screen bg-[#111318] p-12 font-mono text-white">
<div className="mx-auto max-w-6xl">
<div className="min-h-screen bg-[#0d1117] p-6 font-mono text-white md:p-10">
<div className="mx-auto max-w-[1400px]">
<LevelUpBanner />

{/* Header */}
<header className="mb-12 flex flex-col justify-between gap-6 border-b border-[#2d333b] pb-6 md:flex-row md:items-end">
<header className="mb-10 flex flex-col justify-between gap-4 border-b border-[#2d333b] pb-6 md:flex-row md:items-end">
<div>
<div className="mb-4 text-[11px] uppercase tracking-widest text-zinc-500">
<div className="mb-3 text-[11px] uppercase tracking-widest text-zinc-500">
01 / DASHBOARD
</div>
<h1 className="font-serif text-4xl text-white">
Welcome back, {profile?.github_handle ?? 'Contributor'}.
<h1 className="font-serif text-3xl text-white md:text-4xl">
Welcome back, {githubHandle}.
</h1>
</div>
<div className="flex items-center gap-4">
<SyncButton lastSyncedAt={profile?.github_stats_synced_at ?? null} />
</div>
<SyncButton lastSyncedAt={profile?.github_stats_synced_at ?? null} />
</header>

{/* Stats Row */}
<Suspense fallback={<StatsSkeleton />}>
<StatsRow userId={user.id} profile={profile} />
</Suspense>

{/* Main Columns */}
<div className="grid grid-cols-1 gap-16 lg:grid-cols-2">
{/* Left Column */}
<div className="space-y-16">
{/* Three-column layout */}
<div className="grid grid-cols-1 gap-10 lg:grid-cols-[260px_1fr_260px]">
{/* ── Left Sidebar ── */}
<Suspense fallback={<ProfileSidebarSkeleton />}>
<ProfileSidebar githubHandle={githubHandle} xp={xp} level={level} />
</Suspense>

{/* ── Center Feed ── */}
<main className="min-w-0 space-y-12">
{/* Journey progress */}
<Suspense fallback={<JourneyProgressSkeleton />}>
<JourneyProgress xp={xp} level={level} />
</Suspense>

{/* Recent XP activity */}
<Suspense fallback={<RecentActivitySkeleton />}>
<RecentActivity userId={user.id} />
</Suspense>

{/* Active issues */}
<Suspense fallback={<RecsSkeleton />}>
<ActiveIssuesSection />
</Suspense>

{/* GitHub PRs */}
<Suspense fallback={<PrsSkeleton />}>
<GitHubPRsWrapper userId={user.id} githubHandle={githubHandle} />
</Suspense>

{/* Contribution heatmap */}
<Suspense fallback={<HeatmapSkeleton />}>
<HeatmapWrapper userId={user.id} />
</Suspense>

{/* Daily challenge */}
<DailyChallenge />

{/* Course progression */}
<CourseProgress />

{/* Mentees */}
<Suspense fallback={<MenteesSkeleton />}>
<MenteesSection userId={user.id} />
</Suspense>
</div>
</main>

{/* Right Column */}
<div className="space-y-16">
<Suspense fallback={<PrsSkeleton />}>
<GitHubPRsWrapper userId={user.id} githubHandle={profile?.github_handle ?? ''} />
{/* ── Right Sidebar ── */}
<div className="space-y-12">
<Suspense fallback={<RightSidebarSkeleton />}>
<RightSidebar />
</Suspense>

{/* Leaderboard */}
<Suspense fallback={<LeaderboardSkeleton />}>
<LeaderboardSnapshot githubHandle={profile?.github_handle ?? ''} />
<LeaderboardSnapshot githubHandle={githubHandle} />
</Suspense>
</div>
</div>
Expand All @@ -106,7 +162,7 @@ export default async function DashboardPage() {

function NotConfigured() {
return (
<div className="min-h-screen bg-[#111318] px-6 py-20 text-white">
<div className="min-h-screen bg-[#000E12] px-6 py-20 text-white">
<div className="mx-auto max-w-xl">
<h1 className="mb-4 font-serif text-3xl font-bold">Dashboard not configured</h1>
<p className="text-gray-400">Auth isn&apos;t wired on this deployment yet.</p>
Expand Down
5 changes: 0 additions & 5 deletions src/app/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,6 @@ async function loadProfileData(handle: string): Promise<ProfileData | null> {

if (!profile) return null;

const oneYearAgo = new Date();
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
oneYearAgo.setHours(0, 0, 0, 0);

// Fetch all data in parallel
const [
prsResult,
Expand Down Expand Up @@ -193,7 +189,6 @@ async function loadProfileData(handle: string): Promise<ProfileData | null> {
.from('xp_events')
.select('created_at')
.eq('user_id', profile.id)
.gte('created_at', oneYearAgo.toISOString())
.in('source', ['recommended_merge', 'unrecommended_merge', 'help_review']),
]);

Expand Down
65 changes: 65 additions & 0 deletions src/components/contributor-dashboard/course-progress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Link from 'next/link';
import { CheckCircle2, Circle, ArrowRight } from 'lucide-react';

// Static curriculum — replace with DB query when a `course_progress` table exists
const STEPS = [
{ id: 1, title: 'Fork & clone a repository', done: true },
{ id: 2, title: 'Make your first commit', done: true },
{ id: 3, title: 'Open a pull request', done: false },
{ id: 4, title: 'Respond to review feedback', done: false },
{ id: 5, title: 'Get your PR merged', done: false },
];

export function CourseProgress() {
const completedCount = STEPS.filter((s) => s.done).length;
const nextStep = STEPS.find((s) => !s.done);

return (
<section>
<div className="mb-4 flex items-center justify-between border-b border-zinc-800 pb-3">
<h2 className="text-[11px] uppercase tracking-widest text-zinc-500">
CONTRIBUTOR CURRICULUM
</h2>
<span className="text-[10px] uppercase tracking-widest text-zinc-600">
{completedCount}/{STEPS.length}
</span>
</div>

<div className="mb-4 space-y-0">
{STEPS.map((step) => (
<div
key={step.id}
className={`flex items-center gap-3 border-b border-zinc-800 py-3 last:border-0 ${
step.done ? 'opacity-50' : ''
}`}
>
{step.done ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-[#00FF87]" />
) : (
<Circle className="h-4 w-4 shrink-0 text-zinc-600" />
)}
<span
className={`text-[12px] ${step.done ? 'text-zinc-500 line-through' : 'text-zinc-300'}`}
>
{step.title}
</span>
{!step.done && step.id === nextStep?.id && (
<span className="ml-auto shrink-0 border border-amber-700/50 bg-amber-900/20 px-1.5 py-0.5 text-[9px] uppercase tracking-widest text-amber-400">
NEXT
</span>
)}
</div>
))}
</div>

{nextStep && (
<Link
href="/issues"
className="flex w-full items-center justify-center gap-2 border border-[#00FF87]/40 bg-[#10b981]/10 px-4 py-2.5 text-[10px] uppercase tracking-widest text-[#00FF87] transition-colors hover:bg-[#10b981]/20"
>
CONTINUE COURSE <ArrowRight className="h-3 w-3" />
</Link>
)}
</section>
);
}
78 changes: 78 additions & 0 deletions src/components/contributor-dashboard/daily-challenge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use client';

import { useEffect, useState } from 'react';

// Static challenge data — replace with a DB query when a `daily_challenges` table exists
const CHALLENGE = {
title: 'Comment on 2 open issues today',
description: 'Leave a helpful comment on any 2 open issues in the org.',
goal: 2,
current: 0, // TODO: wire to real progress when table exists
xpReward: 50,
};

function getSecondsUntilMidnightUTC(): number {
const now = new Date();
const midnight = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1),
);
return Math.floor((midnight.getTime() - now.getTime()) / 1000);
}

function formatCountdown(secs: number): string {
const h = Math.floor(secs / 3600)
.toString()
.padStart(2, '0');
const m = Math.floor((secs % 3600) / 60)
.toString()
.padStart(2, '0');
const s = (secs % 60).toString().padStart(2, '0');
return `${h}:${m}:${s}`;
}

export function DailyChallenge() {
const [secs, setSecs] = useState(getSecondsUntilMidnightUTC());

useEffect(() => {
const id = setInterval(() => {
setSecs((prev) => (prev <= 1 ? getSecondsUntilMidnightUTC() : prev - 1));
}, 1000);
return () => clearInterval(id);
}, []);

const pct = Math.min(100, Math.round((CHALLENGE.current / CHALLENGE.goal) * 100));
const done = CHALLENGE.current >= CHALLENGE.goal;

return (
<section>
<div className="mb-4 flex items-center justify-between border-b border-zinc-800 pb-3">
<h2 className="text-[11px] uppercase tracking-widest text-zinc-500">DAILY CHALLENGE</h2>
<span
className={`font-mono text-[11px] uppercase tracking-widest ${done ? 'text-[#10b981]' : 'text-amber-400'}`}
>
{done ? 'COMPLETE ✓' : formatCountdown(secs)}
</span>
</div>

<div className="border border-zinc-800 bg-[#161b22] p-4">
<div className="mb-1 text-[13px] text-zinc-200">{CHALLENGE.title}</div>
<div className="mb-4 text-[11px] text-zinc-500">{CHALLENGE.description}</div>

{/* Progress bar */}
<div className="mb-2 h-1.5 w-full overflow-hidden bg-[#000E12]">
<div
className={`h-full transition-all duration-500 ${done ? 'bg-[#10b981]' : 'bg-[#00FF87'}`}
style={{ width: `${pct}%` }}
/>
</div>

<div className="flex items-center justify-between text-[10px] uppercase tracking-widest text-zinc-600">
<span>
{CHALLENGE.current} / {CHALLENGE.goal} DONE
</span>
<span className="text-[#10b981]">+{CHALLENGE.xpReward} XP</span>
</div>
</div>
</section>
);
}
Loading