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
156 changes: 156 additions & 0 deletions frontend/src/api/github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { apiClient } from './client';

export interface GitHubActivity {
date: string;
commits: number;
pullRequests: number;
issues: number;
}

export interface ContributorStats {
totalCommits: number;
totalPullRequests: number;
totalIssues: number;
currentStreak: number;
longestStreak: number;
}

export interface EarningRecord {
date: string;
amount: number;
token: string;
bountyId: string;
bountyTitle: string;
}

const GITHUB_API = 'https://api.github.com';

export const githubApi = {
async getUserActivity(username: string, days: number = 30): Promise<GitHubActivity[]> {
// Calculate date range
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);

// Fetch events from GitHub API
const response = await fetch(`${GITHUB_API}/users/${username}/events/public?per_page=100`);

if (!response.ok) {
// Return mock data if API fails
return generateMockActivity(days);
}

const events = await response.json();

// Aggregate by date
const activityMap = new Map<string, GitHubActivity>();

// Initialize all dates
for (let i = 0; i < days; i++) {
const date = new Date();
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
activityMap.set(dateStr, { date: dateStr, commits: 0, pullRequests: 0, issues: 0 });
}

// Count events
for (const event of events) {
const date = event.created_at?.split('T')[0];
if (!date || !activityMap.has(date)) continue;

const activity = activityMap.get(date)!;
switch (event.type) {
case 'PushEvent':
activity.commits += event.payload?.commits?.length || 1;
break;
case 'PullRequestEvent':
activity.pullRequests++;
break;
case 'IssuesEvent':
activity.issues++;
break;
}
}

return Array.from(activityMap.values()).reverse();
},

async getContributorStats(username: string): Promise<ContributorStats> {
try {
const activity = await this.getUserActivity(username, 365);

let totalCommits = 0;
let totalPullRequests = 0;
let totalIssues = 0;
let currentStreak = 0;
let longestStreak = 0;
let tempStreak = 0;

const reversedActivity = [...activity].reverse();

for (const day of reversedActivity) {
totalCommits += day.commits;
totalPullRequests += day.pullRequests;
totalIssues += day.issues;

const hasActivity = day.commits > 0 || day.pullRequests > 0 || day.issues > 0;

if (hasActivity) {
tempStreak++;
longestStreak = Math.max(longestStreak, tempStreak);
} else {
tempStreak = 0;
}
}

// Calculate current streak (from today backwards)
for (let i = reversedActivity.length - 1; i >= 0; i--) {
const day = reversedActivity[i];
const hasActivity = day.commits > 0 || day.pullRequests > 0 || day.issues > 0;
if (hasActivity) {
currentStreak++;
} else {
break;
}
}

return { totalCommits, totalPullRequests, totalIssues, currentStreak, longestStreak };
} catch {
return { totalCommits: 0, totalPullRequests: 0, totalIssues: 0, currentStreak: 0, longestStreak: 0 };
}
},
};

export const earningsApi = {
async getEarningsHistory(userId: string): Promise<EarningRecord[]> {
// TODO: Replace with real API when backend supports it
// For now, return mock data based on bounty completions
return [
{ date: '2024-01-15', amount: 150000, token: 'FNDRY', bountyId: '1', bountyTitle: 'Toast Notification System' },
{ date: '2024-01-20', amount: 100000, token: 'FNDRY', bountyId: '2', bountyTitle: 'Loading Skeleton' },
{ date: '2024-02-01', amount: 150000, token: 'FNDRY', bountyId: '3', bountyTitle: 'Activity Feed API' },
{ date: '2024-02-15', amount: 100000, token: 'FNDRY', bountyId: '4', bountyTitle: 'Countdown Timer' },
];
},

async getTotalEarnings(userId: string): Promise<{ total: number; token: string }> {
const history = await this.getEarningsHistory(userId);
const total = history.reduce((sum, r) => sum + r.amount, 0);
return { total, token: 'FNDRY' };
},
};

function generateMockActivity(days: number): GitHubActivity[] {
const activity: GitHubActivity[] = [];
for (let i = days - 1; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
activity.push({
date: date.toISOString().split('T')[0],
commits: Math.floor(Math.random() * 5),
pullRequests: Math.floor(Math.random() * 2),
issues: Math.floor(Math.random() * 1),
});
}
return activity;
}
185 changes: 185 additions & 0 deletions frontend/src/components/profile/ActivityCharts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import React from 'react';
import { motion } from 'framer-motion';
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
import type { GitHubActivity } from '../../api/github';

interface ActivityChartProps {
data: GitHubActivity[];
loading?: boolean;
}

export function ActivityChart({ data, loading }: ActivityChartProps) {
if (loading) {
return (
<div className="rounded-xl border border-border bg-forge-900 p-4">
<p className="text-sm font-medium text-text-secondary mb-4">GitHub Activity</p>
<div className="h-40 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-emerald border-t-transparent animate-spin" />
</div>
</div>
);
}

// Aggregate for weekly view
const weeklyData = data.reduce((acc, day) => {
const date = new Date(day.date);
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - date.getDay());
const weekKey = weekStart.toISOString().split('T')[0];

const existing = acc.find(w => w.week === weekKey);
if (existing) {
existing.commits += day.commits;
existing.pullRequests += day.pullRequests;
existing.issues += day.issues;
} else {
acc.push({
week: weekKey,
label: `W${Math.ceil((date.getDate()) / 7)}`,
commits: day.commits,
pullRequests: day.pullRequests,
issues: day.issues,
});
}
return acc;
}, [] as { week: string; label: string; commits: number; pullRequests: number; issues: number }[]);

// Prepare chart data
const chartData = data.slice(-14).map(d => ({
date: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
total: d.commits + d.pullRequests + d.issues,
commits: d.commits,
pullRequests: d.pullRequests,
issues: d.issues,
}));

const totalActivity = data.reduce((sum, d) => sum + d.commits + d.pullRequests + d.issues, 0);

return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl border border-border bg-forge-900 p-4"
>
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-medium text-text-secondary">GitHub Activity</p>
<span className="text-xs text-text-muted">{totalActivity} contributions in last 30 days</span>
</div>

<ResponsiveContainer width="100%" height={140}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="activityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10B981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10B981" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fill: '#64748B', fontSize: 10, fontFamily: 'JetBrains Mono' }}
interval="preserveStartEnd"
/>
<YAxis hide />
<Tooltip
contentStyle={{
backgroundColor: '#16161F',
border: '1px solid #1E1E2E',
borderRadius: 8,
fontFamily: 'JetBrains Mono',
fontSize: 12
}}
labelStyle={{ color: '#A0A0B8' }}
itemStyle={{ color: '#10B981' }}
formatter={(value: number, name: string) => [value, name === 'total' ? 'Activity' : name]}
/>
<Area
type="monotone"
dataKey="total"
stroke="#10B981"
strokeWidth={2}
fill="url(#activityGradient)"
/>
</AreaChart>
</ResponsiveContainer>

{/* Legend */}
<div className="flex items-center gap-4 mt-3 text-xs text-text-muted">
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-emerald" /> Commits
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-magenta" /> PRs
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-amber" /> Issues
</span>
</div>
</motion.div>
);
}

interface EarningsChartProps {
data: { date: string; amount: number; token: string }[];
loading?: boolean;
}

export function EarningsChart({ data, loading }: EarningsChartProps) {
if (loading) {
return (
<div className="rounded-xl border border-border bg-forge-900 p-4">
<div className="h-40 flex items-center justify-center">
<div className="w-6 h-6 rounded-full border-2 border-emerald border-t-transparent animate-spin" />
</div>
</div>
);
}

const chartData = data.map(d => ({
date: new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
amount: d.amount / 1000, // Display in K
}));

const total = data.reduce((sum, d) => sum + d.amount, 0);

return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl border border-border bg-forge-900 p-4"
>
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-medium text-text-secondary">Earnings History</p>
<span className="font-mono text-sm font-semibold text-emerald">
{(total / 1000).toFixed(0)}K FNDRY
</span>
</div>

<ResponsiveContainer width="100%" height={140}>
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={{ fill: '#64748B', fontSize: 10, fontFamily: 'JetBrains Mono' }}
/>
<YAxis hide />
<Tooltip
contentStyle={{
backgroundColor: '#16161F',
border: '1px solid #1E1E2E',
borderRadius: 8,
fontFamily: 'JetBrains Mono',
fontSize: 12
}}
labelStyle={{ color: '#A0A0B8' }}
itemStyle={{ color: '#10B981' }}
formatter={(value: number) => [`${value}K FNDRY`, 'Earned']}
/>
<Bar dataKey="amount" radius={[4, 4, 0, 0]} fill="#10B981" opacity={0.85} />
</BarChart>
</ResponsiveContainer>
</motion.div>
);
}
Loading
Loading