├── .eslintrc.json ├── public ├── favicon.ico ├── vercel.svg └── next.svg ├── postcss.config.js ├── src ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── stats.ts │ └── index.tsx └── styles │ └── globals.css ├── next.config.js ├── .gitignore ├── tailwind.config.ts ├── tsconfig.json ├── package.json ├── LICENSE └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gabriel-pineda/git-wrapped-api/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | domains: ["ph-files.imgix.net"], 6 | }, 7 | }; 8 | 9 | module.exports = nextConfig; 10 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | content: [ 5 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 13 | 'gradient-conic': 14 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | } 20 | export default config 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "paths": { 17 | "@/*": ["./src/*"] 18 | } 19 | }, 20 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "git-wrapped", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@octokit/rest": "^21.0.2", 13 | "next": "13.5.7", 14 | "react": "^18", 15 | "react-dom": "^18", 16 | "react-json-view": "^1.21.3" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^20", 20 | "@types/react": "^18", 21 | "@types/react-dom": "^18", 22 | "autoprefixer": "^10", 23 | "eslint": "^8", 24 | "eslint-config-next": "13.5.7", 25 | "postcss": "^8", 26 | "tailwindcss": "^3", 27 | "typescript": "^5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 gabriel-pineda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Git Wrapped API 2 | 3 | Git Wrapped is the API that powers [git-wrapped.com](https://git-wrapped.com) (Github Wrapped), a service that generates beautiful visualizations of your GitHub activity. 4 | 5 | ![Git Wrapped Example](https://git-wrapped.com/images/git-wrapped-gabriel-pineda.png) 6 | 7 | ## 🚀 Quick Start 8 | 9 | ### Prerequisites 10 | 11 | 1. Node.js and npm/yarn 12 | 2. GitHub Personal Access Token (Classic) 13 | - [How to create a GitHub Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) 14 | - Required permissions: `public_repo`, `read:user` 15 | 16 | ### Environment Setup 17 | 18 | Create a `.env.local` file in the root directory with: 19 | 20 | ```bash 21 | GITHUB_TOKEN=ghp_ 22 | ``` 23 | 24 | ### Installation 25 | 26 | ```bash 27 | # Using yarn 28 | yarn install 29 | 30 | # Using npm 31 | npm install 32 | ``` 33 | 34 | ### Development 35 | 36 | Run the development server: 37 | 38 | ```bash 39 | # Using yarn 40 | yarn dev 41 | 42 | # Using npm 43 | npm run dev 44 | ``` 45 | 46 | The application will be available at [http://localhost:3000](http://localhost:3000). 47 | 48 | ## 🛠 Technical Stack 49 | 50 | - Next.js 13 51 | - TypeScript 52 | - Main API file: `api/stats.ts` 53 | 54 | ## 🤝 Contributing 55 | 56 | We welcome contributions from the community! Here's how you can help: 57 | 58 | 1. **Open an Issue** 59 | 60 | - Found a bug? Have a feature request? Open an issue describing what you'd like to change. 61 | 62 | 2. **Submit a Pull Request** 63 | - Fork the repository 64 | - Create a new branch for your feature 65 | - Make your changes 66 | - Submit a PR with: 67 | - Clear description of the changes 68 | - Link to the related issue 69 | - Sample result/screenshot if applicable 70 | - Wait for review 71 | 72 | ## 📝 License 73 | 74 | The code in this repository is licensed under the MIT License. 75 | 76 | --- 77 | 78 |
79 | Additional Next.js Resources 80 | 81 | - [Next.js Documentation](https://nextjs.org/docs) 82 | - [Learn Next.js](https://nextjs.org/learn) 83 | - [Next.js GitHub Repository](https://github.com/vercel/next.js/) 84 | - [Next.js Deployment Documentation](https://nextjs.org/docs/deployment) 85 | 86 |
87 | -------------------------------------------------------------------------------- /src/pages/api/stats.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { Octokit } from "@octokit/rest"; 3 | 4 | export const runtime = "edge"; 5 | 6 | const GITHUB_TOKEN = process.env.GITHUB_TOKEN; 7 | if (!GITHUB_TOKEN) { 8 | throw new Error("Missing GITHUB_TOKEN environment variable"); 9 | } 10 | 11 | const octokit = new Octokit({ 12 | auth: GITHUB_TOKEN, 13 | }); 14 | 15 | interface ContributionDay { 16 | contributionCount: number; 17 | date: string; 18 | weekday: number; 19 | } 20 | 21 | interface GitHubStats { 22 | longestStreak: number; 23 | totalCommits: number; 24 | commitRank: string; 25 | calendarData: ContributionDay[]; 26 | mostActiveDay: { 27 | name: string; 28 | commits: number; 29 | }; 30 | mostActiveMonth: { 31 | name: string; 32 | commits: number; 33 | }; 34 | starsEarned: number; 35 | topLanguages: string[]; 36 | } 37 | 38 | /** 39 | * Determines the user's commit rank based on their total number of contributions 40 | * These thresholds are approximations based on general GitHub activity patterns 41 | */ 42 | function getCommitRank(totalCommits: number): string { 43 | if (totalCommits >= 5000) return "Top 0.5%-1%"; 44 | if (totalCommits >= 2000) return "Top 1%-3%"; 45 | if (totalCommits >= 1000) return "Top 5%-10%"; 46 | if (totalCommits >= 500) return "Top 10%-15%"; 47 | if (totalCommits >= 200) return "Top 25%-30%"; 48 | if (totalCommits >= 50) return "Median 50%"; 49 | return "Bottom 30%"; 50 | } 51 | 52 | // Constants for date formatting 53 | const WEEKDAY_NAMES = [ 54 | "Sunday", 55 | "Monday", 56 | "Tuesday", 57 | "Wednesday", 58 | "Thursday", 59 | "Friday", 60 | "Saturday", 61 | ] as const; 62 | 63 | const MONTH_NAMES = [ 64 | "January", 65 | "February", 66 | "March", 67 | "April", 68 | "May", 69 | "June", 70 | "July", 71 | "August", 72 | "September", 73 | "October", 74 | "November", 75 | "December", 76 | ] as const; 77 | 78 | /** 79 | * GitHub Stats API endpoint 80 | * Fetches and processes a user's GitHub statistics including: 81 | * - Contribution data 82 | * - Commit patterns 83 | * - Repository stars 84 | * - Programming languages 85 | * 86 | * @param request - Incoming HTTP request with 'username' query parameter 87 | * @returns JSON response with processed GitHub statistics 88 | */ 89 | export default async function GET(request: Request): Promise { 90 | try { 91 | // Extract username from query parameters 92 | const { searchParams } = new URL(request.url); 93 | const username = searchParams.get("username"); 94 | 95 | if (!username) { 96 | return NextResponse.json( 97 | { error: "Username parameter is required" }, 98 | { status: 400 } 99 | ); 100 | } 101 | 102 | // GraphQL query to fetch user's GitHub data 103 | const query = ` 104 | query($username: String!) { 105 | user(login: $username) { 106 | contributionsCollection { 107 | contributionCalendar { 108 | totalContributions 109 | weeks { 110 | contributionDays { 111 | contributionCount 112 | date 113 | weekday 114 | } 115 | } 116 | } 117 | } 118 | repositories(first: 100, orderBy: {field: STARGAZERS, direction: DESC}) { 119 | nodes { 120 | stargazerCount 121 | primaryLanguage { 122 | name 123 | } 124 | } 125 | } 126 | } 127 | } 128 | `; 129 | 130 | const graphqlResponse = (await octokit.graphql(query, { username })) as any; 131 | const userData = graphqlResponse.user; 132 | 133 | // Process contribution data for the current year 134 | const contributionDays = 135 | userData.contributionsCollection.contributionCalendar.weeks 136 | .flatMap((week: any) => week.contributionDays) 137 | .filter((day: any) => new Date(day.date) >= new Date("2024-01-01")); 138 | 139 | // Calculate monthly contribution statistics 140 | const monthlyCommits: Record = {}; 141 | contributionDays.forEach((day: ContributionDay) => { 142 | const month = new Date(day.date).getMonth() + 1; 143 | const monthKey = month.toString().padStart(2, "0"); 144 | monthlyCommits[monthKey] = 145 | (monthlyCommits[monthKey] || 0) + day.contributionCount; 146 | }); 147 | 148 | // Calculate daily contribution patterns 149 | const dailyCommits: Record = {}; 150 | contributionDays.forEach((day: ContributionDay) => { 151 | dailyCommits[day.weekday] = 152 | (dailyCommits[day.weekday] || 0) + day.contributionCount; 153 | }); 154 | 155 | // Find peak activity periods 156 | const [mostActiveMonth] = Object.entries(monthlyCommits).sort( 157 | ([, a], [, b]) => b - a 158 | ); 159 | 160 | const [mostActiveDay] = Object.entries(dailyCommits).sort( 161 | ([, a], [, b]) => b - a 162 | ); 163 | 164 | // Calculate repository statistics 165 | const totalStars = userData.repositories.nodes.reduce( 166 | (acc: number, repo: any) => acc + repo.stargazerCount, 167 | 0 168 | ); 169 | 170 | // Process programming language statistics 171 | const languages = userData.repositories.nodes.reduce( 172 | (acc: Record, repo: any) => { 173 | if (repo.primaryLanguage?.name) { 174 | acc[repo.primaryLanguage.name] = 175 | (acc[repo.primaryLanguage.name] || 0) + 1; 176 | } 177 | return acc; 178 | }, 179 | {} 180 | ); 181 | 182 | const topLanguages = Object.entries(languages) 183 | .sort(([, a], [, b]): number => (b as number) - (a as number)) 184 | .slice(0, 3) 185 | .map(([lang]) => lang); 186 | 187 | // Calculate contribution streaks 188 | let currentStreak = 0; 189 | let maxStreak = 0; 190 | for (const day of contributionDays) { 191 | if (day.contributionCount > 0) { 192 | currentStreak++; 193 | maxStreak = Math.max(maxStreak, currentStreak); 194 | } else { 195 | currentStreak = 0; 196 | } 197 | } 198 | 199 | const totalCommits = 200 | userData.contributionsCollection.contributionCalendar.totalContributions; 201 | 202 | // Prepare and return the final statistics 203 | const stats: GitHubStats = { 204 | longestStreak: maxStreak, 205 | totalCommits, 206 | commitRank: getCommitRank(totalCommits), 207 | calendarData: contributionDays, 208 | mostActiveDay: { 209 | name: WEEKDAY_NAMES[parseInt(mostActiveDay[0])], 210 | commits: Math.round(mostActiveDay[1] / (contributionDays.length / 7)), // Average per day 211 | }, 212 | mostActiveMonth: { 213 | name: MONTH_NAMES[parseInt(mostActiveMonth[0]) - 1], 214 | commits: mostActiveMonth[1], 215 | }, 216 | starsEarned: totalStars, 217 | topLanguages, 218 | }; 219 | 220 | return NextResponse.json(stats); 221 | } catch (error: any) { 222 | console.error("Error fetching GitHub stats:", error); 223 | return NextResponse.json( 224 | { error: error.message || "Failed to fetch GitHub statistics" }, 225 | { status: 500 } 226 | ); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Inter } from "next/font/google"; 3 | import { useState } from "react"; 4 | import dynamic from "next/dynamic"; 5 | 6 | const ReactJson = dynamic(() => import("react-json-view"), { ssr: false }); 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export default function Home() { 10 | const [username, setUsername] = useState(""); 11 | const [stats, setStats] = useState(null); 12 | const [loading, setLoading] = useState(false); 13 | const [error, setError] = useState(""); 14 | 15 | const handleSubmit = async (e: React.FormEvent) => { 16 | e.preventDefault(); 17 | setLoading(true); 18 | setError(""); 19 | setStats(null); 20 | 21 | try { 22 | const response = await fetch(`/api/stats?username=${username}`); 23 | const data = await response.json(); 24 | 25 | if (!response.ok) { 26 | throw new Error(data.error || "Failed to fetch stats"); 27 | } 28 | 29 | setStats(data); 30 | } catch (err: any) { 31 | setError(err.message); 32 | } finally { 33 | setLoading(false); 34 | } 35 | }; 36 | 37 | return ( 38 |
41 | 77 | 78 | {/* Subtle Pattern Overlay */} 79 |
85 | 86 | {/* Header */} 87 |
88 |
89 | Git Wrapped Logo 96 |

Git Wrapped API

97 |
98 |
99 | 100 | {/* Main Content */} 101 |
102 |
103 |
104 | {/* Left Column - Input Form */} 105 |
106 |
107 |
108 | 114 |
115 | setUsername(e.target.value)} 120 | className="w-full px-4 py-3 bg-gray-800/50 border border-gray-700 rounded-lg focus:ring-2 focus:ring-cyan-500 focus:border-cyan-500 outline-none placeholder-gray-500 backdrop-blur-sm" 121 | placeholder="Enter GitHub username" 122 | required 123 | /> 124 |
125 |
126 |
127 | 156 |
157 | 158 | {/* Error Message */} 159 | {error && ( 160 |
161 | {error} 162 |
163 | )} 164 |
165 | 166 | {/* Right Column - Results */} 167 |
168 | {!stats && !loading && ( 169 |
170 |
171 |
172 | 179 | 185 | 186 |
187 |
188 |

189 | No Stats Found 190 |

191 |

192 | Enter a GitHub username to view API response 193 |

194 |
195 |
196 |
197 | )} 198 | {stats && ( 199 |
200 |
201 |

202 | Results for @{username} 203 |

204 |
205 |
206 | 221 |
222 |
223 | )} 224 |
225 |
226 |
227 |
228 |
229 | ); 230 | } 231 | --------------------------------------------------------------------------------