diff --git a/.github/scripts/check-duplicates.js b/.github/scripts/check-duplicates.js index 7783491d4..884cf3e91 100644 --- a/.github/scripts/check-duplicates.js +++ b/.github/scripts/check-duplicates.js @@ -18,13 +18,13 @@ function cosineSimilarity(vecA, vecB) { async function run() { const geminiApiKey = process.env.GEMINI_API_KEY; - const githubToken = process.env.GITHUB_TOKEN; + const githubToken = process.env.GITHUB_PAT || process.env.GITHUB_TOKEN; if (!geminiApiKey) { throw new Error('❌ Missing GEMINI_API_KEY environment variable.'); } if (!githubToken) { - throw new Error('❌ Missing GITHUB_TOKEN environment variable.'); + throw new Error('❌ Missing GITHUB_PAT or GITHUB_TOKEN environment variable.'); } const octokit = github.getOctokit(githubToken); diff --git a/README.md b/README.md index 2ef508dda..0109824a4 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ - [Design Philosophy](#the-isometric-monolith--design-philosophy) - [Live Demo](#live-demo) - [Deep Customization](#deep-customization--url-parameters) -- [Theme Presets](#theme-presets) + - [Parameter Reference](#parameter-reference) + - [Grace Period Examples](#grace-period-examples) + - [Theme Presets](#theme-presets) - [Theme Preview Gallery](#theme-preview-gallery) - [Real-Time Accuracy](#real-time-accuracy--the-contribution-count-problem) - [Architecture & Tech Stack](#architecture--tech-stack) @@ -41,7 +43,7 @@ ![CommitPulse](https://commitpulse.vercel.app/api/streak?user=YOUR_USERNAME) ``` - + --- @@ -77,50 +79,50 @@ Transform your GitHub contribution history into a cinematic 3D monolith. ### ✨ Theme Showcase - - +
+ - + - + - + - -
+ #### Default - - - + + + - + #### Neon - - - + + + - + #### Custom - - - + + + -
+ + --- @@ -156,41 +158,43 @@ URL Parameter > Theme Default > System Fallback ### Parameter Reference -| Parameter | Type | Required | Default | Description | -| ----------------- | --------- | ---------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `user` | `string` | ✅ **Yes** | — | GitHub username to render | -| `theme` | `string` | No | `dark` | Preset theme name (see below) | -| `bg` | `hex` | No | Theme default | Background color — **without** `#` | -| `accent` | `hex` | No | Theme default | Tower & glow color — **without** `#` | -| `text` | `hex` | No | Theme default | Label & stat text color — **without** `#` | -| `radius` | `number` | No | `8` | Border corner radius in pixels | -| `border` | `string` | No | — | Custom stroke color around the main SVG container — **without** `#` | -| `speed` | `string` | No | `8s` | Radar scan duration (`2s`–`20s`, default `8s`) | -| `scale` | `string` | No | `linear` | Tower height scaling: `linear` or `log` (logarithmic) | -| `size` | `string` | No | `medium` | Badge dimensions: `small` (400×280), `medium` (600×420), `large` (800×560) | -| `font` | `string` | No | CommitPulse default typography | Any **Google Font** name (e.g. `Orbitron`, `Inter`) | -| `refresh` | `boolean` | No | `false` | Bypass cache for real-time data | -| `year` | `string` | No | — | Calendar year to render (e.g. `2023`, `2024`) | -| `hide_title` | `boolean` | No | `false` | Hide GitHub username/title from the SVG badge | -| `hide_background` | `boolean` | No | `false` | Remove the background rect, letting the monolith float on the page | -| `hide_stats` | `boolean` | No | `false` | Hides the bottom row displaying Current Streak, Annual Sync Total, and Peak Streak stats when set to `true` or `1`. | -| `tz` | `string` | No | Omitted = UTC | IANA timezone (e.g. `Asia/Kolkata`, `America/New_York`) — aligns "today" with the user local midnight. Note: `?tz=UTC` is valid but cached separately from omitting `tz`. | -| `lang` | `string` | No | `en` | Language code for labels (`en`, `es`, `hi`, `fr`, `pt`, `ko`, `ja`, `de`, `zh`) | -| `view` | `string` | No | `default` | Rendering mode: `default` (3D Monolith), `monthly` (Compact monthly stats), or `heatmap` (flat 2D contribution heatmap) | -| `entrance` | `string` | No | `rise` | Entrance animation for towers: `rise` (default), `fade`, `slide`, or `none`. | -| `delta_format` | `string` | No | `percent` | Format for month-over-month delta in monthly view: `percent` (e.g. +12%), `absolute` (e.g. +15 commits), or `both` | -| `width` | `number` | No | `300` | Custom width for the SVG canvas (currently only applies to `view=monthly`) | -| `height` | `number` | No | `120` | Custom height for the SVG canvas (currently only applies to `view=monthly`) | -| `grace` | `number` | No | `1` | Grace period in days before a streak resets (0–7). `grace=0` = strict mode (no missed days), `grace=2` = lenient (forgives up to 2 missed days). Default is `1`. | -| `mode` | `string` | No | `commits` | Rendering mode: `commits` (default) or `loc` (Lines of Code landscape) | -| `repo` | `string` | No | — | Render the monolith for a specific repository (e.g. `owner/repo`) instead of the whole profile | -| `org` | `string` | No | — | Organization name to generate a Mega-City for | -| `labels` | `boolean` | No | `false` | Render optional 3D isometric month headers and weekday labels | -| `labelColor` | `hex` | No | — | Custom text color for the isometric labels — **without** `#` | -| `versus` | `string` | No | — | GitHub username of an opponent to compare against in side-by-side versus mode | -| `shading` | `boolean` | No | `false` | Apply intensity-based opacity shading to tower faces so lower intensity levels appear slightly dimmer | -| `opacity` | `number` | No | `1.0` | Global opacity scalar for all tower fill-opacity values (0.1–1.0). `opacity=0.5` = semi-transparent ghost look. `opacity=0.8` = faded, great on light backgrounds. | -| `gradient` | `boolean` | No | `false` | Opt-in to show volumetric gradients on the monolith floor | +> All parameters below are optional except `user`. Append them to the base URL as query string key-value pairs (e.g. `?user=YOUR_USERNAME&theme=neon&size=large`). Boolean parameters accept `true` or `false`. Hex color values are provided **without** the `#` prefix. + +| Parameter | Description | Default | Allowed Values / Constraints | Example | +| ----------------- | ----------------------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------- | ------------------------ | +| `user` | GitHub username to render (Required) | — | Any valid GitHub username | `?user=jhasourav07` | +| `theme` | Preset theme name | `dark` | `auto`, `dark`, `neon`, `dracula`, `github`, `light`, `gruvbox`, `random`, etc. | `?theme=dracula` | +| `bg` | Background color | Theme default | Hex color code (without `#`) | `?bg=0d1117` | +| `accent` | Tower & glow color | Theme default | Hex color code (without `#`) | `?accent=58a6ff` | +| `text` | Label & stat text color | Theme default | Hex color code (without `#`) | `?text=c9d1d9` | +| `radius` | Border corner radius in pixels | `8` | Numeric value | `?radius=16` | +| `border` | Custom stroke color around the SVG container | — | Hex color code (without `#`) | `?border=ff0000` | +| `speed` | Radar scan duration | `8s` | `2s`–`20s` | `?speed=4s` | +| `scale` | Tower height scaling | `linear` | `linear`, `log` | `?scale=log` | +| `size` | Badge dimensions | `medium` | `small`, `medium`, `large` | `?size=large` | +| `font` | Custom font for text | Default typography | Any valid Google Font name | `?font=Orbitron` | +| `refresh` | Bypass cache for real-time data | `false` | `true`, `false` | `?refresh=true` | +| `year` | Calendar year to render | Current year | `2023`, `2024`, etc. | `?year=2023` | +| `hide_title` | Hide GitHub username/title | `false` | `true`, `false` | `?hide_title=true` | +| `hide_background` | Remove the background rect | `false` | `true`, `false` | `?hide_background=true` | +| `hide_stats` | Hide bottom row displaying stats | `false` | `true`, `false` | `?hide_stats=true` | +| `tz` | IANA timezone | `UTC` | Valid IANA timezone | `?tz=Asia/Kolkata` | +| `lang` | Language code for labels | `en` | `en`, `es`, `hi`, `fr`, `pt`, `ko`, `ja`, `de`, `zh` | `?lang=hi` | +| `view` | Rendering mode | `default` | `default`, `monthly`, `heatmap` | `?view=heatmap` | +| `entrance` | Entrance animation for towers | `rise` | `rise`, `fade`, `slide`, `none` | `?entrance=fade` | +| `delta_format` | Month-over-month delta format (`view=monthly`) | `percent` | `percent`, `absolute`, `both` | `?delta_format=absolute` | +| `width` | Custom width for SVG canvas (`view=monthly`) | `300` | Numeric value | `?width=400` | +| `height` | Custom height for SVG canvas (`view=monthly`) | `120` | Numeric value | `?height=150` | +| `grace` | Grace period in days before streak resets (see [Grace Period Examples](#grace-period-examples)) | `1` | `0`–`7` | `?grace=2` | +| `mode` | Base data rendering mode | `commits` | `commits`, `loc` | `?mode=loc` | +| `repo` | Render monolith for a specific repository | — | `owner/repo` | `?repo=vercel/next.js` | +| `org` | Organization name to generate a Mega-City for | — | Valid GitHub organization name | `?org=vercel` | +| `labels` | Render optional isometric month/weekday labels | `false` | `true`, `false` | `?labels=true` | +| `labelColor` | Custom text color for isometric labels | — | Hex color code (without `#`) | `?labelColor=ffffff` | +| `versus` | Compare against an opponent side-by-side | — | Any valid GitHub username | `?versus=octocat` | +| `shading` | Apply intensity-based opacity shading to tower faces | `false` | `true`, `false` | `?shading=true` | +| `opacity` | Global opacity scalar for tower fill | `1.0` | `0.1`–`1.0` | `?opacity=0.8` | +| `gradient` | Show volumetric gradients on the floor | `false` | `true`, `false` | `?gradient=true` | ### Grace Period Examples @@ -565,7 +569,7 @@ Browse theme previews here: [Theme Gallery](THEMES.md) --- -
+
_Built with obsession, shipped with precision._ @@ -573,7 +577,7 @@ _Built with obsession, shipped with precision._ ### This project is an official participant in GSSoC 2026. -
+
--- @@ -581,10 +585,8 @@ _Built with obsession, shipped with precision._ Thanks to all contributors who have helped make CommitPulse better! - - Contributors - + + Contributors + View the [full contributor list →](https://github.com/JhaSourav07/commitpulse/graphs/contributors) - -test commit for PR creation diff --git a/app/api/notify/route.ts b/app/api/notify/route.ts index 0ee80c686..af1c6325b 100644 --- a/app/api/notify/route.ts +++ b/app/api/notify/route.ts @@ -37,7 +37,11 @@ export async function POST(req: NextRequest): Promise ({ import { fetchGitHubContributions } from '../../../lib/github'; import type { ContributionCalendar } from '../../../types'; +import { quotaMonitor } from '@/services/github/quota-monitor'; +import { refreshPolicy } from '@/services/github/refresh-policy'; +import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter'; // Calendar with a known, predictable streak so assertions are deterministic. // Last day (2024-06-16) has commits; "today" in tests is set to that date. @@ -27,17 +30,25 @@ const mockCalendar: ContributionCalendar = { ], }; -function makeRequest(params: Record = {}): Request { +function makeRequest( + params: Record = {}, + headers: Record = {} +): Request { const url = new URL('http://localhost/api/stats'); for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); } - return new Request(url.toString()); + return new Request(url.toString(), { + headers: new Headers(headers), + }); } describe('GET /api/stats', () => { beforeEach(() => { vi.clearAllMocks(); + quotaMonitor.reset(); + refreshPolicy.reset(); + refreshRateLimiter.reset(); vi.mocked(fetchGitHubContributions).mockResolvedValue({ calendar: mockCalendar, repoContributions: [], @@ -110,6 +121,7 @@ describe('GET /api/stats', () => { expect(response.headers.get('Cache-Control')).toBe('no-store, no-cache, must-revalidate'); expect(response.headers.get('Pragma')).toBe('no-cache'); expect(response.headers.get('Expires')).toBe('0'); + expect(response.headers.get('X-Refresh-Status')).toBe('Fresh'); }); it('still returns valid stats data when refresh=true', async () => { @@ -143,6 +155,47 @@ describe('GET /api/stats', () => { expect(fetchGitHubContributions).toHaveBeenCalledWith('testuser', { bypassCache: false }); }); + it('serves cached stats instead of bypassing cache during refresh cooldown', async () => { + await GET(makeRequest({ user: 'testuser', refresh: 'true' })); + + const response = await GET(makeRequest({ user: 'testuser', refresh: 'true' })); + + expect(response.status).toBe(200); + expect(fetchGitHubContributions).toHaveBeenLastCalledWith('testuser', { + bypassCache: false, + }); + expect(response.headers.get('X-Refresh-Status')).toBe('Cooldown-Served-Cached'); + expect(response.headers.get('Cache-Control')).toBe( + 'public, s-maxage=3600, stale-while-revalidate=86400' + ); + }); + + it('returns 429 when stats refresh exceeds the client refresh rate limit', async () => { + refreshRateLimiter.setLimit(1); + + await GET(makeRequest({ user: 'testuser', refresh: 'true' }, { 'x-real-ip': '203.0.113.9' })); + + const response = await GET( + makeRequest({ user: 'octocat', refresh: 'true' }, { 'x-real-ip': '203.0.113.9' }) + ); + + expect(response.status).toBe(429); + const body = await response.json(); + expect(body.error).toContain('Refresh rate limit exceeded'); + expect(response.headers.get('X-RateLimit-Limit')).toBe('1'); + }); + + it('blocks stats refresh when the shared GitHub quota is low', async () => { + quotaMonitor.setQuota(5000, 400, Date.now() + 60_000); + + const response = await GET(makeRequest({ user: 'testuser', refresh: 'true' })); + + expect(response.status).toBe(429); + const body = await response.json(); + expect(body.error).toContain('quota is low'); + expect(fetchGitHubContributions).not.toHaveBeenCalled(); + }); + it('accepts a valid IANA timezone without error', async () => { const response = await GET(makeRequest({ user: 'testuser', tz: 'America/New_York' })); expect(response.status).toBe(200); diff --git a/app/api/stats/route.ts b/app/api/stats/route.ts index 2987f6392..b37da40c8 100644 --- a/app/api/stats/route.ts +++ b/app/api/stats/route.ts @@ -3,6 +3,21 @@ import { NextResponse } from 'next/server'; import { fetchGitHubContributions } from '@/lib/github'; import { calculateStreak } from '@/lib/calculate'; import { statsParamsSchema } from '@/lib/validations'; +import { getClientIp } from '@/utils/getClientIp'; +import { quotaMonitor } from '@/services/github/quota-monitor'; +import { refreshPolicy } from '@/services/github/refresh-policy'; +import { refreshRateLimiter } from '@/services/github/refresh-rate-limiter'; + +function logSecurityEvent(event: string, details: Record) { + console.warn( + JSON.stringify({ + timestamp: new Date().toISOString(), + type: 'SECURITY_EVENT', + event, + ...details, + }) + ); +} /** * GET /api/stats?user=[&refresh=true][&tz=] @@ -20,6 +35,7 @@ import { statsParamsSchema } from '@/lib/validations'; */ export async function GET(request: Request) { const { searchParams } = new URL(request.url); + const ip = getClientIp(request); const parseResult = statsParamsSchema.safeParse(Object.fromEntries(searchParams.entries())); @@ -47,6 +63,54 @@ export async function GET(request: Request) { const { user, refresh, tz } = parseResult.data; + if (refresh && quotaMonitor.isQuotaLow()) { + logSecurityEvent('LOW_QUOTA_STATS_REFRESH_BLOCKED', { + user, + ip, + remainingQuota: quotaMonitor.getQuota().remaining, + }); + return NextResponse.json( + { error: 'GitHub API quota is low. Stats refresh temporarily disabled.' }, + { status: 429 } + ); + } + + if (refresh) { + const rateLimitCheck = refreshRateLimiter.checkLimit(ip); + if (!rateLimitCheck.success) { + logSecurityEvent('STATS_REFRESH_RATE_LIMIT_EXCEEDED', { + user, + ip, + limit: rateLimitCheck.limit, + }); + return NextResponse.json( + { error: 'Refresh rate limit exceeded. Please try again later.' }, + { + status: 429, + headers: { + 'X-RateLimit-Limit': rateLimitCheck.limit.toString(), + 'X-RateLimit-Remaining': rateLimitCheck.remaining.toString(), + 'X-RateLimit-Reset': rateLimitCheck.reset.toString(), + }, + } + ); + } + } + + let shouldBypassCache = refresh; + if (refresh) { + if (!refreshPolicy.isRefreshAllowed(user)) { + logSecurityEvent('STATS_REFRESH_COOLDOWN_VIOLATION', { + user, + ip, + remainingMs: refreshPolicy.getRemainingCooldown(user), + }); + shouldBypassCache = false; + } else { + refreshPolicy.recordRefresh(user); + } + } + // Validate the optional IANA timezone early so callers get a clear 400 // rather than a silent fallback or a 500. let timezone = 'UTC'; @@ -59,7 +123,7 @@ export async function GET(request: Request) { } try { - const userData = await fetchGitHubContributions(user, { bypassCache: refresh }); + const userData = await fetchGitHubContributions(user, { bypassCache: shouldBypassCache }); const calendar = userData.calendar; const stats = calculateStreak(calendar, timezone); const headers = new Headers({ @@ -67,11 +131,15 @@ export async function GET(request: Request) { 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', }); - if (refresh) { + if (shouldBypassCache) { headers.set('Cache-Control', 'no-store, no-cache, must-revalidate'); headers.set('Pragma', 'no-cache'); headers.set('Expires', '0'); } + headers.set( + 'X-Refresh-Status', + shouldBypassCache ? 'Fresh' : refresh ? 'Cooldown-Served-Cached' : 'Cached' + ); return NextResponse.json( { diff --git a/app/api/streak/route.test.ts b/app/api/streak/route.test.ts index b924262af..eaa94329d 100644 --- a/app/api/streak/route.test.ts +++ b/app/api/streak/route.test.ts @@ -943,7 +943,11 @@ describe('GET /api/streak', () => { expect(body.details).not.toBeNull(); }); }); + it('returns 400 when an invalid hex color is passed as bg', async () => { + const response = await GET(makeRequest({ user: 'octocat', bg: '#ZZZZZZ' })); + expect(response.status).toBe(400); + }); describe('hide parameters', () => { it('removes the username title when hide_title=true', async () => { const response = await GET(makeRequest({ user: 'octocat', hide_title: 'true' })); diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index cc2ef7cf0..c0729dc91 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -99,6 +99,7 @@ export async function GET(request: Request) { glow, format, days, + badges, } = parseResult.data; const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse'; const themeName = theme || 'dark'; @@ -182,6 +183,7 @@ export async function GET(request: Request) { disable_particles, glow, animate, + badges, }; let calendar; diff --git a/app/api/track-user/route.ts b/app/api/track-user/route.ts index af350dec6..d3c703c85 100644 --- a/app/api/track-user/route.ts +++ b/app/api/track-user/route.ts @@ -9,7 +9,9 @@ export async function POST(req: Request) { // Get IP for rate limiting securely const ip = getClientIp(req); - if (ip !== '127.0.0.1' && ip !== 'unknown' && !(await trackUserRateLimiter.check(ip))) { + const rateLimitKey = ip === 'unknown' ? 'unknown-client' : ip; + + if (ip !== '127.0.0.1' && !(await trackUserRateLimiter.check(rateLimitKey))) { return NextResponse.json( { success: false, error: 'Too many requests, please try again later.' }, { status: 429 } diff --git a/app/api/user-details/route.test.ts b/app/api/user-details/route.test.ts new file mode 100644 index 000000000..d73b671b7 --- /dev/null +++ b/app/api/user-details/route.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET } from './route'; + +vi.mock('@/lib/github', () => ({ + fetchUserProfile: vi.fn(), + fetchGitHubContributions: vi.fn(), +})); + +import { fetchUserProfile, fetchGitHubContributions } from '@/lib/github'; +import type { ContributionCalendar } from '@/types'; + +const mockCalendar: ContributionCalendar = { + totalContributions: 15, + weeks: [ + { + contributionDays: [ + { contributionCount: 5, date: '2024-06-10' }, + { contributionCount: 5, date: '2024-06-11' }, + { contributionCount: 5, date: '2024-06-12' }, + ], + }, + ], +}; + +const mockProfile = { + login: 'testuser', + name: 'Test User', + avatar_url: 'https://github.com/testuser.png', + public_repos: 12, +}; + +function makeRequest(params: Record = {}): Request { + const url = new URL('http://localhost/api/user-details'); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return new Request(url.toString()); +} + +describe('GET /api/user-details', () => { + beforeEach(() => { + vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fetchUserProfile).mockResolvedValue(mockProfile as any); + vi.mocked(fetchGitHubContributions).mockResolvedValue({ + calendar: mockCalendar, + repoContributions: [], + totalPRs: 0, + totalIssues: 0, + }); + }); + + it('returns 400 when username is missing', async () => { + const response = await GET(makeRequest()); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe('Username is required'); + }); + + it('returns 400 when username format is invalid', async () => { + const response = await GET(makeRequest({ username: 'invalid_user_name_@' })); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe('Invalid username format'); + }); + + it('returns 200 with user details and streak stats on success', async () => { + const response = await GET(makeRequest({ username: 'testuser' })); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual({ + exists: true, + login: 'testuser', + name: 'Test User', + avatar_url: 'https://github.com/testuser.png', + public_repos: 12, + stats: { + currentStreak: 3, + longestStreak: 3, + totalContributions: 15, + }, + }); + }); + + it('returns 404 when user is not found', async () => { + vi.mocked(fetchUserProfile).mockRejectedValue(new Error('User not found')); + const response = await GET(makeRequest({ username: 'missinguser' })); + expect(response.status).toBe(404); + const body = await response.json(); + expect(body.error).toBe('User not found'); + }); + + it('gracefully handles contributions fetch failure and returns profile details', async () => { + vi.mocked(fetchGitHubContributions).mockRejectedValue(new Error('API limit reached')); + const response = await GET(makeRequest({ username: 'testuser' })); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.stats).toEqual({ + currentStreak: 0, + longestStreak: 0, + totalContributions: 0, + }); + }); +}); diff --git a/app/api/user-details/route.ts b/app/api/user-details/route.ts new file mode 100644 index 000000000..1c4d853ce --- /dev/null +++ b/app/api/user-details/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import { fetchUserProfile, fetchGitHubContributions } from '@/lib/github'; +import { calculateStreak } from '@/lib/calculate'; +import { validateGitHubUsername } from '@/lib/validations'; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const username = searchParams.get('username')?.trim(); + + if (!username) { + return NextResponse.json({ error: 'Username is required' }, { status: 400 }); + } + + if (!validateGitHubUsername(username)) { + return NextResponse.json({ error: 'Invalid username format' }, { status: 400 }); + } + + try { + const [profile, contributions] = await Promise.all([ + fetchUserProfile(username), + fetchGitHubContributions(username).catch(() => null), + ]); + + let stats = { currentStreak: 0, longestStreak: 0, totalContributions: 0 }; + if (contributions) { + const calculated = calculateStreak(contributions.calendar); + stats = { + currentStreak: calculated.currentStreak, + longestStreak: calculated.longestStreak, + totalContributions: calculated.totalContributions, + }; + } + + return NextResponse.json({ + exists: true, + login: profile.login, + name: profile.name, + avatar_url: profile.avatar_url, + public_repos: profile.public_repos, + stats, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : ''; + if (message.includes('not found') || message.includes('404')) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + return NextResponse.json({ error: message || 'Failed to fetch user details' }, { status: 500 }); + } +} diff --git a/app/compare/CompareClient.mouse-interactivity.test.tsx b/app/compare/CompareClient.mouse-interactivity.test.tsx new file mode 100644 index 000000000..1e79abe5f --- /dev/null +++ b/app/compare/CompareClient.mouse-interactivity.test.tsx @@ -0,0 +1,259 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import CompareClient from './CompareClient'; +import React, { type ReactNode } from 'react'; + +const replaceMock = vi.fn(); + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + replace: replaceMock, + }), + useSearchParams: () => ({ + get: vi.fn(() => null), + }), +})); + +vi.mock('framer-motion', () => ({ + motion: new Proxy( + {}, + { + get: (_, tag) => { + return ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => + React.createElement(tag as string, props, children); + }, + } + ), + AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +const mockResponse = { + user1: { + profile: { + username: 'userA', + name: 'User A', + avatarUrl: 'avatar-a.png', + isPro: true, + bio: 'Frontend Developer', + location: 'India', + joinedDate: '2023', + developerScore: 90, + stats: { + repositories: 100, + followers: 200, + following: 50, + stars: 500, + }, + }, + stats: { + currentStreak: 50, + peakStreak: 100, + totalContributions: 5000, + codingHabit: 'Night Owl', + totalPRs: 20, + totalIssues: 10, + }, + languages: [ + { + name: 'TypeScript', + color: '#3178c6', + percentage: 80, + }, + ], + activity: [ + { + date: '2026-06-01', + count: 5, + intensity: 2, + locAdditions: 150, + locDeletions: 50, + }, + ], + }, + user2: { + profile: { + username: 'userB', + name: 'User B', + avatarUrl: 'avatar-b.png', + isPro: false, + bio: 'Backend Developer', + location: 'USA', + joinedDate: '2022', + developerScore: 80, + stats: { + repositories: 80, + followers: 100, + following: 40, + stars: 300, + }, + }, + stats: { + currentStreak: 30, + peakStreak: 70, + totalContributions: 3000, + codingHabit: 'Early Bird', + totalPRs: 15, + totalIssues: 5, + }, + languages: [ + { + name: 'JavaScript', + color: '#f7df1e', + percentage: 70, + }, + ], + activity: [ + { + date: '2026-06-01', + count: 2, + intensity: 1, + locAdditions: 80, + locDeletions: 30, + }, + ], + }, +}; + +describe('CompareClient Interactive Tooltips, Cursor Hovers & Touch Event Propagation', () => { + beforeEach(() => { + vi.clearAllMocks(); + + global.fetch = vi.fn( + async () => + ({ + ok: true, + json: async () => mockResponse, + }) as Response + ); + }); + + it('verifies mouse hover styling and hover class application on search and action elements', () => { + render(); + + const user1Input = screen.getByPlaceholderText(/github username #1/i); + const user2Input = screen.getByPlaceholderText(/github username #2/i); + const compareBtn = screen.getByRole('button', { name: /compare/i }); + + // Verify presence of cursor-pointer or transition-colors classes + expect(compareBtn).toHaveClass('transition-colors'); + expect(compareBtn).toHaveClass('hover:bg-zinc-800'); + + // Simulate mouse interaction + fireEvent.mouseEnter(compareBtn); + fireEvent.mouseLeave(compareBtn); + + fireEvent.mouseEnter(user1Input); + fireEvent.mouseLeave(user1Input); + + fireEvent.mouseEnter(user2Input); + fireEvent.mouseLeave(user2Input); + }); + + it('renders stats showdown cards and verifies hover-related border transitions', async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText(/github username #1/i), { + target: { value: 'userA' }, + }); + fireEvent.change(screen.getByPlaceholderText(/github username #2/i), { + target: { value: 'userB' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /compare/i })); + + await waitFor(() => { + expect(screen.getByText(/stats showdown/i)).toBeInTheDocument(); + }); + + // Check StatBattle border elements transitions on mouseEnter / mouseLeave + const repositoryCard = screen.getByText('5,000').closest('div'); + expect(repositoryCard).toBeInTheDocument(); + + fireEvent.mouseEnter(repositoryCard!); + fireEvent.mouseLeave(repositoryCard!); + }); + + it('triggers mouse hover interactions on coding habits cards', async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText(/github username #1/i), { + target: { value: 'userA' }, + }); + fireEvent.change(screen.getByPlaceholderText(/github username #2/i), { + target: { value: 'userB' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /compare/i })); + + await waitFor(() => { + expect(screen.getByText(/coding habits/i)).toBeInTheDocument(); + }); + + const habitCards = screen.getAllByRole('heading', { level: 3 }); + const userAHabit = habitCards.find((c) => c.textContent === 'Night Owl'); + const userBHabit = habitCards.find((c) => c.textContent === 'Early Bird'); + + expect(userAHabit).toBeInTheDocument(); + expect(userBHabit).toBeInTheDocument(); + + // Trigger hover events to verify standard scale and glow hover properties + const containerA = userAHabit!.closest('div'); + const containerB = userBHabit!.closest('div'); + + expect(containerA).toHaveClass('transition-all'); + expect(containerB).toHaveClass('transition-all'); + + fireEvent.mouseEnter(containerA!); + fireEvent.mouseLeave(containerA!); + + fireEvent.mouseEnter(containerB!); + fireEvent.mouseLeave(containerB!); + }); + + it('verifies touch start propagation on controls and action buttons', () => { + render(); + + const user1Input = screen.getByPlaceholderText(/github username #1/i); + const user2Input = screen.getByPlaceholderText(/github username #2/i); + const compareBtn = screen.getByRole('button', { name: /compare/i }); + + // Simulate mobile touch event start to verify they propagate properly without being prevented + const touchStartEvent1 = fireEvent.touchStart(user1Input); + const touchStartEvent2 = fireEvent.touchStart(user2Input); + const touchStartEvent3 = fireEvent.touchStart(compareBtn); + + expect(touchStartEvent1).toBe(true); + expect(touchStartEvent2).toBe(true); + expect(touchStartEvent3).toBe(true); + }); + + it('renders contribution activity heatmap and verifies hover title-tooltips exist', async () => { + render(); + + fireEvent.change(screen.getByPlaceholderText(/github username #1/i), { + target: { value: 'userA' }, + }); + fireEvent.change(screen.getByPlaceholderText(/github username #2/i), { + target: { value: 'userB' }, + }); + + fireEvent.click(screen.getByRole('button', { name: /compare/i })); + + await waitFor(() => { + expect(screen.getByText(/stats showdown/i)).toBeInTheDocument(); + }); + + // Find custom heatmap items having 'contributions' in the title attribute + const allCells = document.querySelectorAll('[title*="contributions"]'); + expect(allCells.length).toBeGreaterThan(0); + + // Verify hover details on a heatmap cell + const sampleCell = allCells[0]; + expect(sampleCell).toHaveAttribute('title'); + expect(sampleCell.getAttribute('title')).toContain('contributions'); + + fireEvent.mouseEnter(sampleCell); + fireEvent.mouseLeave(sampleCell); + }); +}); diff --git a/app/compare/CompareClient.tsx b/app/compare/CompareClient.tsx index 65fa97add..a45dc5c02 100644 --- a/app/compare/CompareClient.tsx +++ b/app/compare/CompareClient.tsx @@ -37,7 +37,7 @@ import { /* ── types ────────────────────────────────────────────────────────────── */ -interface UserProfile { +export interface UserProfile { username: string; name: string; avatarUrl: string; @@ -54,7 +54,7 @@ interface UserProfile { }; } -interface UserStats { +export interface UserStats { currentStreak: number; peakStreak: number; totalContributions: number; @@ -63,13 +63,13 @@ interface UserStats { totalIssues?: number; } -interface LanguageData { +export interface LanguageData { name: string; color: string; percentage: number; } -interface ActivityData { +export interface ActivityData { date: string; count: number; intensity: 0 | 1 | 2 | 3 | 4; @@ -77,14 +77,14 @@ interface ActivityData { locDeletions?: number; } -interface CompareUserData { +export interface CompareUserData { profile: UserProfile; stats: UserStats; languages: LanguageData[]; activity: ActivityData[]; } -interface CompareResponse { +export interface CompareResponse { user1: CompareUserData; user2: CompareUserData; error?: string; diff --git a/app/compare/CompareClient.type-compiler.test.tsx b/app/compare/CompareClient.type-compiler.test.tsx new file mode 100644 index 000000000..62d38ee5d --- /dev/null +++ b/app/compare/CompareClient.type-compiler.test.tsx @@ -0,0 +1,68 @@ +import { describe, expectTypeOf, it } from 'vitest'; + +import type { + ActivityData, + CompareResponse, + CompareUserData, + LanguageData, + UserProfile, + UserStats, +} from './CompareClient'; + +describe('CompareClient Type Compiler Validation', () => { + it('validates UserProfile structure', () => { + expectTypeOf().toEqualTypeOf<{ + username: string; + name: string; + avatarUrl: string; + isPro: boolean; + bio: string; + location: string; + joinedDate: string; + developerScore: number; + stats: { + repositories: number; + followers: number; + following: number; + stars: number; + }; + }>(); + }); + + it('validates UserStats numeric and optional fields', () => { + expectTypeOf().toEqualTypeOf<{ + currentStreak: number; + peakStreak: number; + totalContributions: number; + codingHabit?: string; + totalPRs?: number; + totalIssues?: number; + }>(); + }); + + it('validates LanguageData structure', () => { + expectTypeOf().toEqualTypeOf<{ + name: string; + color: string; + percentage: number; + }>(); + }); + + it('accepts optional fields in ActivityData without compile errors', () => { + expectTypeOf().toEqualTypeOf<{ + date: string; + count: number; + intensity: 0 | 1 | 2 | 3 | 4; + locAdditions?: number; + locDeletions?: number; + }>(); + }); + + it('validates CompareResponse structure', () => { + expectTypeOf().toEqualTypeOf<{ + user1: CompareUserData; + user2: CompareUserData; + error?: string; + }>(); + }); +}); diff --git a/app/compare/page.mock-integrations.test.tsx b/app/compare/page.mock-integrations.test.tsx new file mode 100644 index 000000000..482d5219f --- /dev/null +++ b/app/compare/page.mock-integrations.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./CompareClient', () => ({ + default: () =>
Mock Compare Client
, +})); + +vi.mock('../components/Footer', () => ({ + Footer: () =>
Mock Footer
, +})); + +import ComparePage from './page'; + +describe('ComparePage mock integrations', () => { + it('renders CompareClient through mocked service layer', () => { + render(); + + expect(screen.getByText('Mock Compare Client')).toBeInTheDocument(); + }); + + it('renders Footer component', () => { + render(); + + expect(screen.getByText('Mock Footer')).toBeInTheDocument(); + }); + + it('renders CompareClient only once', () => { + render(); + + expect(screen.getAllByText('Mock Compare Client')).toHaveLength(1); + }); + + it('renders Footer only once', () => { + render(); + + expect(screen.getAllByText('Mock Footer')).toHaveLength(1); + }); + + it('renders page layout with both mocked integrations', () => { + render(); + + expect(screen.getByText('Mock Compare Client')).toBeInTheDocument(); + expect(screen.getByText('Mock Footer')).toBeInTheDocument(); + }); +}); diff --git a/app/compare/page.theme-contrast.test.tsx b/app/compare/page.theme-contrast.test.tsx new file mode 100644 index 000000000..f34c20e5a --- /dev/null +++ b/app/compare/page.theme-contrast.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi } from 'vitest'; +import CompareClient from './CompareClient'; +import React, { type ReactNode } from 'react'; + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + replace: vi.fn(), + }), + useSearchParams: () => ({ + get: vi.fn(() => null), + }), +})); + +vi.mock('framer-motion', () => ({ + motion: new Proxy( + {}, + { + get: (_, tag) => { + return ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => + React.createElement(tag as string, props, children); + }, + } + ), + AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + RadarChart: () =>
, + Radar: () =>
, + PolarGrid: () =>
, + PolarAngleAxis: () =>
, + PolarRadiusAxis: () =>
, + Tooltip: () =>
, +})); + +describe('ComparePage Theme Contrast (Variation 3)', () => { + it('renders heading with dark and light text contrast classes', () => { + render(); + + const heading = screen.getByRole('heading', { + name: /compare developers/i, + }); + + expect(heading).toHaveClass('text-gray-900'); + expect(heading).toHaveClass('dark:text-white'); + }); + + it('renders username input fields with dark and light theme styling', () => { + render(); + + const user1 = screen.getByPlaceholderText(/github username #1/i); + const user2 = screen.getByPlaceholderText(/github username #2/i); + + expect(user1).toHaveClass('bg-white'); + expect(user1).toHaveClass('dark:bg-[#0a0a0a]'); + expect(user1).toHaveClass('text-gray-900'); + expect(user1).toHaveClass('dark:text-white'); + + expect(user2).toHaveClass('bg-white'); + expect(user2).toHaveClass('dark:bg-[#0a0a0a]'); + expect(user2).toHaveClass('text-gray-900'); + expect(user2).toHaveClass('dark:text-white'); + }); + + it('renders compare button with proper contrast classes for both themes', () => { + render(); + + const compareButton = screen.getByRole('button', { + name: /compare/i, + }); + + expect(compareButton).toHaveClass('bg-black'); + expect(compareButton).toHaveClass('dark:bg-white'); + expect(compareButton).toHaveClass('text-white'); + expect(compareButton).toHaveClass('dark:text-black'); + }); + + it('applies border contrast styling to form controls', () => { + render(); + + const user1 = screen.getByPlaceholderText(/github username #1/i); + + expect(user1).toHaveClass('border-black/10'); + expect(user1).toHaveClass('dark:border-[rgba(255,255,255,0.1)]'); + }); + + it('renders decorative badge with theme-aware background styling', () => { + render(); + + const badge = screen.getByText(/developer showdown/i); + + const container = badge.parentElement; + + expect(container).toHaveClass('bg-gray-100'); + expect(container).toHaveClass('dark:bg-[#111]'); + expect(container).toHaveClass('border-black/10'); + expect(container).toHaveClass('dark:border-[rgba(255,255,255,0.1)]'); + }); +}); diff --git a/app/compare/page.type-compiler.test.tsx b/app/compare/page.type-compiler.test.tsx new file mode 100644 index 000000000..cd9efc724 --- /dev/null +++ b/app/compare/page.type-compiler.test.tsx @@ -0,0 +1,75 @@ +import { describe, it, expect, expectTypeOf } from 'vitest'; + +describe('ComparePage - TypeScript Compiler Validation & Schema Constraints Stability', () => { + it('imports and basic type-testing utilities are available', () => { + expect(expectTypeOf).toBeDefined(); + }); + + it('enforces UserProfile field property configurations', () => { + interface UserProfile { + username: string; + name: string; + avatarUrl: string; + isPro: boolean; + bio: string; + location: string; + joinedDate: string; + developerScore: number; + } + + expectTypeOf().toHaveProperty('username').toBeString(); + expectTypeOf().toHaveProperty('name').toBeString(); + expectTypeOf().toHaveProperty('isPro').toBeBoolean(); + expectTypeOf().toHaveProperty('developerScore').toBeNumber(); + }); + + it('validates UserStats optional fields', () => { + interface UserStats { + currentStreak: number; + peakStreak: number; + totalContributions: number; + codingHabit?: string; + totalPRs?: number; + totalIssues?: number; + } + + const stats: UserStats = { + currentStreak: 10, + peakStreak: 20, + totalContributions: 100, + }; + + expectTypeOf(stats.currentStreak).toEqualTypeOf(); + expect(stats.totalContributions).toBe(100); + }); + + it('accepts optional values without compile errors', () => { + interface ActivityData { + date: string; + count: number; + intensity: 0 | 1 | 2 | 3 | 4; + locAdditions?: number; + locDeletions?: number; + } + + const activity: ActivityData = { + date: '2024-01-01', + count: 5, + intensity: 2, + }; + + expectTypeOf(activity).toMatchTypeOf(); + }); + + it('verifies schema-like constraints for CompareResponse structure', () => { + interface CompareResponse { + user1: unknown; + user2: unknown; + error?: string; + } + + expectTypeOf().toHaveProperty('user1'); + expectTypeOf().toHaveProperty('user2'); + expectTypeOf().toHaveProperty('error').toEqualTypeOf(); + }); +}); diff --git a/app/components/Footer.test.tsx b/app/components/Footer.test.tsx index 555203279..178ce2df2 100644 --- a/app/components/Footer.test.tsx +++ b/app/components/Footer.test.tsx @@ -112,7 +112,7 @@ describe('Footer Component', () => { render(