diff --git a/app/api/streak/png/route.type-compiler.test.ts b/app/api/streak/png/route.type-compiler.test.ts index ca0dadac6..f565e9a50 100644 --- a/app/api/streak/png/route.type-compiler.test.ts +++ b/app/api/streak/png/route.type-compiler.test.ts @@ -44,6 +44,7 @@ describe('ApiStreakPngRoute - TypeScript Compiler Validation & Schema Constraint | 'commit_clock' | 'weekday' | 'punchcard' + | 'techstack' >(); expectTypeOf().toEqualTypeOf<'linear' | 'log' | 'sqrt'>(); expectTypeOf().toEqualTypeOf<'small' | 'medium' | 'large'>(); diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index df37962c8..e2159978f 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -30,6 +30,7 @@ import { generateLanguagesSVG, generateActivityGraphSVG, buildInlineErrorSVG, + generateTechStackSVG, } from '@/lib/svg/generator'; import { generateConstellationSVG } from '@/lib/svg/constellation'; import { generateRadarSVG } from '@/lib/svg/radar'; @@ -188,7 +189,8 @@ export async function GET(request: Request) { | 'activity_graph' | 'commit_clock' | 'weekday' - | 'punchcard'; + | 'punchcard' + | 'techstack'; const themeKey = getNormalizedThemeKey(theme); const themeName = themeKey === 'default' && theme ? theme : themeKey; @@ -439,7 +441,10 @@ export async function GET(request: Request) { }); calendar = orgData.calendar; individualCalendars = orgData.individualCalendars; - repoContributions = normalizedView === 'languages' ? orgData.repoContributions || [] : []; + repoContributions = + normalizedView === 'languages' || normalizedView === 'techstack' + ? orgData.repoContributions || [] + : []; } else if (user.includes(',')) { const users = user .split(',') @@ -494,7 +499,7 @@ export async function GET(request: Request) { calendar: d.calendar, })); repoContributions = - normalizedView === 'languages' + normalizedView === 'languages' || normalizedView === 'techstack' ? successfulData.flatMap((d) => d.repoContributions || []) : []; if (hasOfflineFallback) { @@ -508,7 +513,10 @@ export async function GET(request: Request) { signal: controller.signal, }); calendar = userData.calendar; - repoContributions = normalizedView === 'languages' ? userData.repoContributions || [] : []; + repoContributions = + normalizedView === 'languages' || normalizedView === 'techstack' + ? userData.repoContributions || [] + : []; if (userData.isOfflineFallback) { params.isOfflineFallback = true; servedFromStaleCache = true; @@ -729,6 +737,8 @@ export async function GET(request: Request) { Array.from({ length: 7 }, () => new Array(24).fill(0)) ); svg = generatePunchcardSVG(punchCard, fullStats, params); + } else if (normalizedView === 'techstack') { + svg = generateTechStackSVG(fullStats, params, calendar, repoContributions); } else if (normalizedView === 'weekday') { const normalizedCalendar = normalizeCalendarToTimezone(calendar, timezone); svg = generateWeekdaySVG(fullWeekdayStats || fullStats, params, normalizedCalendar); diff --git a/app/api/tech-stack/route.ts b/app/api/tech-stack/route.ts new file mode 100644 index 000000000..cbd1be9ce --- /dev/null +++ b/app/api/tech-stack/route.ts @@ -0,0 +1,177 @@ +// app/api/tech-stack/route.ts +// REST endpoint: GET /api/tech-stack?user=[&year=] +// Returns JSON analytics of a user's tech stack derived from their GitHub contributions. +// Caches results in MongoDB with a 24-hour TTL to minimize repeated API calls. + +import 'server-only'; +import { NextResponse } from 'next/server'; +import { z } from 'zod'; +import { fetchGitHubContributions } from '@/lib/github'; +import { aggregateTechStack } from '@/lib/svg/techStackAnalytics'; +import { GITHUB_USERNAME_REGEX } from '@/lib/validations'; +import logger from '@/lib/logger'; + +// ── Optional MongoDB caching ─────────────────────────────────────────────────── +// If MongoDB is not configured, the endpoint gracefully skips caching. + +async function tryGetCached(username: string, year: string) { + try { + const dbConnect = (await import('@/lib/mongodb')).default; + const TechStackAnalytics = (await import('@/models/TechStackAnalytics')).default; + await dbConnect(); + const doc = await TechStackAnalytics.findOne({ username: username.toLowerCase(), year }).lean(); + return doc ?? null; + } catch { + return null; + } +} + +async function trySetCached( + username: string, + year: string, + data: { + techStack: Array<{ language: string; commits: number; percentage: number; color: string }>; + allLanguages: Array<{ language: string; commits: number; percentage: number; color: string }>; + dominantLanguage: string | null; + archetype: string; + totalCommits: number; + } +) { + try { + const dbConnect = (await import('@/lib/mongodb')).default; + const { default: TechStackAnalytics, buildTtlExpiry } = + await import('@/models/TechStackAnalytics'); + await dbConnect(); + await TechStackAnalytics.findOneAndUpdate( + { username: username.toLowerCase(), year }, + { + ...data, + username: username.toLowerCase(), + year, + computedAt: new Date(), + ttlExpiry: buildTtlExpiry(), + }, + { upsert: true, new: true } + ); + } catch { + // Non-fatal — caching is best-effort + } +} + +// ── Request schema ──────────────────────────────────────────────────────────── + +const requestSchema = z.object({ + user: z + .string({ error: 'Missing user parameter' }) + .trim() + .min(1, { message: 'Missing user parameter' }) + .max(39) + .regex(GITHUB_USERNAME_REGEX, { message: 'Invalid GitHub username' }), + + year: z + .string() + .optional() + .refine( + (val) => { + if (!val) return true; + const y = parseInt(val, 10); + return !isNaN(y) && y >= 2008 && y <= new Date().getUTCFullYear(); + }, + { message: 'year must be a valid 4-digit year (2008–present)' } + ) + .default('all'), +}); + +// ── Handler ─────────────────────────────────────────────────────────────────── + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + + const parseResult = requestSchema.safeParse({ + user: searchParams.get('user') ?? undefined, + year: searchParams.get('year') ?? undefined, + }); + + if (!parseResult.success) { + const errors = parseResult.error.flatten(); + const firstError = + Object.values(errors.fieldErrors).flat()[0] ?? errors.formErrors[0] ?? 'Invalid parameters'; + return NextResponse.json({ error: firstError }, { status: 400 }); + } + + const { user, year } = parseResult.data; + + try { + // ── Try MongoDB cache ────────────────────────────────────────────────── + const cached = await tryGetCached(user, year); + if (cached) { + return NextResponse.json( + { + username: user, + year, + techStack: cached.techStack, + allLanguages: cached.allLanguages, + dominantLanguage: cached.dominantLanguage, + archetype: cached.archetype, + totalCommits: cached.totalCommits, + cachedAt: (cached as { computedAt?: Date }).computedAt?.toISOString() ?? null, + source: 'cache', + }, + { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + 'X-Cache-Status': 'HIT', + }, + } + ); + } + + // ── Fetch live from GitHub ───────────────────────────────────────────── + const fromTo = + year !== 'all' ? { from: `${year}-01-01T00:00:00Z`, to: `${year}-12-31T23:59:59Z` } : {}; + + const data = await fetchGitHubContributions(user, fromTo); + const summary = aggregateTechStack(data.repoContributions ?? []); + + const payload = { + techStack: summary.topLanguages, + allLanguages: summary.allLanguages, + dominantLanguage: summary.dominantLanguage, + archetype: summary.archetype, + totalCommits: summary.totalCommits, + }; + + // ── Persist to MongoDB cache (non-blocking) ──────────────────────────── + void trySetCached(user, year, payload); + + return NextResponse.json( + { + username: user, + year, + ...payload, + cachedAt: null, + source: 'live', + }, + { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', + 'X-Cache-Status': 'MISS', + }, + } + ); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Internal server error'; + logger.error('tech-stack API error', { user, year, error: message }); + + const isNotFound = + message.toLowerCase().includes('not found') || + message.toLowerCase().includes('"' + user + '" not found'); + + return NextResponse.json( + { error: isNotFound ? `GitHub user "${user}" not found` : 'Failed to fetch tech stack data' }, + { status: isNotFound ? 404 : 500 } + ); + } +} diff --git a/components/dashboard/ActivityHeatmapPro.test.tsx b/components/dashboard/ActivityHeatmapPro.test.tsx index 4360682f7..4f37e0cda 100644 --- a/components/dashboard/ActivityHeatmapPro.test.tsx +++ b/components/dashboard/ActivityHeatmapPro.test.tsx @@ -3,6 +3,26 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import ActivityHeatmapPro from './ActivityHeatmapPro'; +const mockExportImage = vi.fn(); +vi.mock('@/hooks/useExportImage', () => ({ + useExportImage: () => ({ + exportImage: mockExportImage, + isExporting: false, + error: null, + }), +})); + +vi.mock('@/utils/clipboard', () => ({ + copyToClipboard: vi.fn().mockResolvedValue(true), +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + vi.mock('framer-motion', () => ({ motion: { div: ({ children, ...props }: any) =>
{children}
, @@ -165,4 +185,37 @@ describe('ActivityHeatmapPro Component', () => { fireEvent.keyDown(cell7, { key: 'ArrowLeft' }); expect(document.activeElement).toBe(cell0); }); + + it('renders export button and triggers image export actions', async () => { + render(); + + const exportBtn = screen.getByRole('button', { name: /export activity heatmap/i }); + expect(exportBtn).toBeDefined(); + + fireEvent.click(exportBtn); + + const pngOption = screen.getByRole('menuitem', { name: /download png/i }); + const pdfOption = screen.getByRole('menuitem', { name: /download pdf/i }); + const svgOption = screen.getByRole('menuitem', { name: /download svg/i }); + + expect(pngOption).toBeDefined(); + expect(pdfOption).toBeDefined(); + expect(svgOption).toBeDefined(); + + fireEvent.click(pngOption); + expect(mockExportImage).toHaveBeenCalledWith('png'); + }); + + it('copies share snapshot when share snapshot option is clicked', async () => { + const { copyToClipboard } = await import('@/utils/clipboard'); + render(); + + const exportBtn = screen.getByRole('button', { name: /export activity heatmap/i }); + fireEvent.click(exportBtn); + + const shareOption = screen.getByRole('menuitem', { name: /share snapshot/i }); + fireEvent.click(shareOption); + + expect(copyToClipboard).toHaveBeenCalled(); + }); }); diff --git a/components/dashboard/ActivityHeatmapPro.tsx b/components/dashboard/ActivityHeatmapPro.tsx index f3a7beece..83166a373 100644 --- a/components/dashboard/ActivityHeatmapPro.tsx +++ b/components/dashboard/ActivityHeatmapPro.tsx @@ -14,13 +14,24 @@ import { Sunset, Globe, MapPin, + Download, + Share2, + FileImage, + FileText, + Code, + Check, + Loader2, } from 'lucide-react'; import { getAuthorLocalHour, getViewerLocalHour } from '@/utils/dateHelpers'; +import { useExportImage } from '@/hooks/useExportImage'; +import { copyToClipboard } from '@/utils/clipboard'; +import { toast } from 'sonner'; interface ActivityHeatmapProProps { activity: Array<{ date: string; count: number; intensity: 0 | 1 | 2 | 3 | 4 }>; commitClock?: Array<{ day: string; commits: number }>; rawCommits?: string[]; + username?: string; } type ViewMode = 'heatmap' | 'hourly' | 'weekly' | 'monthly'; @@ -60,14 +71,58 @@ export default function ActivityHeatmapPro({ activity, commitClock, rawCommits, + username, }: ActivityHeatmapProProps) { const [viewMode, setViewMode] = useState('heatmap'); const [timeMode, setTimeMode] = useState<'author' | 'viewer'>('author'); const [announcement, setAnnouncement] = useState(''); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [shareCopied, setShareCopied] = useState(false); const containerRef = useRef(null); + const exportFilename = username ? `${username}-activity-heatmap` : 'repository-activity-heatmap'; + const { exportImage, isExporting } = useExportImage({ + targetSelector: '[data-export-target="activity-heatmap-pro"]', + filename: exportFilename, + }); + const slicedActivity = useMemo(() => activity.slice(-364), [activity]); + // Peak activity stats + const stats = useMemo(() => { + if (activity.length === 0) + return { total: 0, peak: 0, peakDate: '', average: 0, activeDays: 0 }; + const total = activity.reduce((sum, a) => sum + a.count, 0); + const activeDays = activity.filter((a) => a.count > 0).length; + const peak = Math.max(...activity.map((a) => a.count)); + const peakDate = activity.find((a) => a.count === peak)?.date || ''; + return { + total, + peak, + peakDate, + average: activeDays > 0 ? Math.round(total / activeDays) : 0, + activeDays, + }; + }, [activity]); + + const handleShareSnapshot = async () => { + const summary = + `📊 Repository Activity Heatmap Snapshot${username ? ` (@${username})` : ''}\n` + + `• Total Contributions: ${stats.total.toLocaleString()}\n` + + `• Active Days: ${stats.activeDays}\n` + + `• Peak Activity: ${stats.peak} contributions (${stats.peakDate || 'N/A'})\n` + + `• Daily Average: ${stats.average}\n` + + `Exported via CommitPulse`; + try { + await copyToClipboard(summary); + setShareCopied(true); + toast.success('Analytics snapshot copied to clipboard!'); + setTimeout(() => setShareCopied(false), 2000); + } catch { + toast.error('Failed to copy analytics snapshot'); + } + }; + const handleHeatmapKeyDown = ( e: React.KeyboardEvent, index: number, @@ -160,23 +215,6 @@ export default function ActivityHeatmapPro({ }); }, [activity]); - // Peak activity stats - const stats = useMemo(() => { - if (activity.length === 0) - return { total: 0, peak: 0, peakDate: '', average: 0, activeDays: 0 }; - const total = activity.reduce((sum, a) => sum + a.count, 0); - const activeDays = activity.filter((a) => a.count > 0).length; - const peak = Math.max(...activity.map((a) => a.count)); - const peakDate = activity.find((a) => a.count === peak)?.date || ''; - return { - total, - peak, - peakDate, - average: activeDays > 0 ? Math.round(total / activeDays) : 0, - activeDays, - }; - }, [activity]); - // Hourly distribution (computed from rawCommits or fallback) const hourlyData = useMemo(() => { if (rawCommits && rawCommits.length > 0) { @@ -231,6 +269,7 @@ export default function ActivityHeatmapPro({ initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} + data-export-target="activity-heatmap-pro" className="rounded-2xl border border-black/10 bg-white/80 backdrop-blur-xl p-6 dark:border-[rgba(255,255,255,0.08)] dark:bg-[rgba(17,17,17,0.8)]" role="region" aria-label="Activity Heatmap Pro" @@ -248,6 +287,93 @@ export default function ActivityHeatmapPro({

+ + {/* Export & Share Dropdown */} +
+ + + {dropdownOpen && !isExporting && ( + <> +
setDropdownOpen(false)} /> +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • +
  • + +
  • +
+ + )} +
{/* View Mode Tabs */} diff --git a/components/dashboard/DashboardClient.tsx b/components/dashboard/DashboardClient.tsx index 3bec67218..f9e15af2d 100644 --- a/components/dashboard/DashboardClient.tsx +++ b/components/dashboard/DashboardClient.tsx @@ -757,6 +757,7 @@ export default function DashboardClient({ activity={initialData.activity} commitClock={initialData.commitClock} rawCommits={initialData.rawCommits} + username={username} /> diff --git a/components/dashboard/Heatmap.test.tsx b/components/dashboard/Heatmap.test.tsx index f08d4ed81..7240100f5 100644 --- a/components/dashboard/Heatmap.test.tsx +++ b/components/dashboard/Heatmap.test.tsx @@ -13,6 +13,15 @@ beforeAll(() => { }; }); +const mockExportImage = vi.fn(); +vi.mock('@/hooks/useExportImage', () => ({ + useExportImage: () => ({ + exportImage: mockExportImage, + isExporting: false, + error: null, + }), +})); + // 2. Mock framer-motion with inline types to prevent hoisting errors vi.mock('framer-motion', () => ({ motion: { @@ -78,11 +87,19 @@ describe('Heatmap', () => { expect(() => render()).not.toThrow(); }); - it('renders without crashing with 365 days of data', () => { - const data = generateMockData(365); + it('renders export button and triggers format export', async () => { + const fireEvent = (await import('@testing-library/react')).fireEvent; + render(); + + const exportBtn = screen.getByRole('button', { name: /export heatmap/i }); + expect(exportBtn).toBeDefined(); + + fireEvent.click(exportBtn); - render(); + const pngOption = screen.getByRole('menuitem', { name: /download png/i }); + expect(pngOption).toBeDefined(); - expect(screen.getAllByRole('gridcell')).toHaveLength(data.length); + fireEvent.click(pngOption); + expect(mockExportImage).toHaveBeenCalledWith('png'); }); }); diff --git a/components/dashboard/Heatmap.tsx b/components/dashboard/Heatmap.tsx index cdb0fb5ed..15d212b70 100644 --- a/components/dashboard/Heatmap.tsx +++ b/components/dashboard/Heatmap.tsx @@ -2,10 +2,12 @@ import { useEffect, useRef, useState, type SyntheticEvent } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; +import { Download, FileImage, FileText, Code, Loader2 } from 'lucide-react'; import type { ActivityData } from '@/types/dashboard'; import { getIntensityColor } from './heatmapUtils'; import VisualizationTooltip from './VisualizationTooltip'; import { useTranslation } from '@/context/TranslationContext'; +import { useExportImage } from '@/hooks/useExportImage'; import { formatTooltipDate, getActivityInsight, @@ -31,6 +33,7 @@ interface HeatmapProps { subtitle?: string; emptyMessage?: string; timeZone?: string; + username?: string; } export default function Heatmap({ @@ -39,13 +42,21 @@ export default function Heatmap({ subtitle, emptyMessage, timeZone = 'UTC', + username, }: HeatmapProps) { const containerRef = useRef(null); const [scale, setScale] = useState(1); const [tooltip, setTooltip] = useState(null); const [announcement, setAnnouncement] = useState(''); + const [dropdownOpen, setDropdownOpen] = useState(false); const { t } = useTranslation(); + const exportFilename = username ? `${username}-heatmap` : 'heatmap-activity'; + const { exportImage, isExporting } = useExportImage({ + targetSelector: '[data-export-target="heatmap-card"]', + filename: exportFilename, + }); + const effectiveTimeZone = timeZone || 'UTC'; const getTimeZoneDateLabel = (input: string | Date) => { @@ -194,6 +205,7 @@ export default function Heatmap({ {/* Header */} -

- {displayTitle} -

+
+

+ {displayTitle} +

+ +
+ + + {dropdownOpen && !isExporting && ( + <> +
setDropdownOpen(false)} /> +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ + )} +
+
diff --git a/docs/customization.md b/docs/customization.md index c3298984e..fcbf197fe 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -59,44 +59,44 @@ URL Parameter > Theme Default > System Fallback > 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`, `sqrt` | `?scale=sqrt` | -| `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` | -| `custom_title` | Custom header title text | — | Any text string (XML-escaped) | `?custom_title=My%20Pulse` | -| `custom_subtitle` | Custom subtitle text right below title | — | Any text string (XML-escaped) | `?custom_subtitle=Dev` | -| `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`, `pulse`, `skyline`, `languages`, `constellation`, `weekday`, `radar`, `doughnut`, `pie`, `activity_graph`, `commit_clock` | `?view=commit_clock` | | `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`, `pulse`, `skyline`) | `300` | Numeric value | `?width=400` | -| `height` | Custom height for SVG canvas (`view=monthly`, `pulse`, `skyline`) | `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` | -| `dim_weekends` | Dim weekend towers (Saturdays and Sundays) | `false` | `true`, `false` | `?dim_weekends=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` | -| `minify` | Enable SVG minification and payload optimization | `true` | `true`, `false` | `?minify=false` | +| 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`, `sqrt` | `?scale=sqrt` | +| `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` | +| `custom_title` | Custom header title text | — | Any text string (XML-escaped) | `?custom_title=My%20Pulse` | +| `custom_subtitle` | Custom subtitle text right below title | — | Any text string (XML-escaped) | `?custom_subtitle=Dev` | +| `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`, `pulse`, `skyline`, `languages`, `constellation`, `weekday`, `radar`, `doughnut`, `pie`, `activity_graph`, `commit_clock` | `?view=commit_clock` | | `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`, `pulse`, `skyline`) | `300` | Numeric value | `?width=400` | +| `height` | Custom height for SVG canvas (`view=monthly`, `pulse`, `skyline`) | `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` | +| `dim_weekends` | Dim weekend towers (Saturdays and Sundays) | `false` | `true`, `false` | `?dim_weekends=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` | +| `minify` | Enable SVG minification and payload optimization | `true` | `true`, `false` | `?minify=false` | --- diff --git a/lib/svg/generator.ts b/lib/svg/generator.ts index 3c3a1a2f4..6c6991bb8 100644 --- a/lib/svg/generator.ts +++ b/lib/svg/generator.ts @@ -27,6 +27,11 @@ import { sanitizeSpeed, sanitizeCustomText, } from './sanitizer'; +import { + aggregateTechStack, + buildLanguageColorPalette, + type TechStackSummary, +} from './techStackAnalytics'; import { GRID_ORIGIN_X, @@ -4046,3 +4051,232 @@ export function buildInlineErrorSVG(text: string, options?: ErrorSVGOptions): st } `; } + +// ─── Tech Stack View ────────────────────────────────────────────────────────── + +/** + * Renders the SVG legend for the tech stack view. + * Shows top languages as colored swatches with name + percentage labels. + */ +function renderTechStackLegend( + summary: TechStackSummary, + sf: number, + text: string, + bg: string, + statsFont: string +): string { + if (summary.topLanguages.length === 0) return ''; + + const fs = (n: number): number => Math.round(n * sf * 10) / 10; + const s = createScaler(sf); + + const LEGEND_X = s(430); + const LEGEND_Y = s(90); + const ROW_H = s(20); + const SWATCH_SIZE = s(10); + const CORNER_R = s(2); + const PANEL_W = s(155); + const PANEL_H = s(22 + summary.topLanguages.length * 20 + 10); + + const isLightBg = getLuminance(bg) > 0.5; + const panelFill = isLightBg ? '#00000015' : '#ffffff10'; + const panelStroke = isLightBg ? '#00000030' : '#ffffff20'; + + let rows = ''; + summary.topLanguages.forEach((lang, i) => { + const y = LEGEND_Y + s(22) + i * ROW_H; + const hexColor = lang.color.startsWith('#') ? lang.color : `#${lang.color}`; + rows += ` + + ${escapeXML(lang.language)} + ${lang.percentage}%`; + }); + + return ` + + + TECH STACK + ${rows} + `; +} + +/** + * Renders an archetype badge below the legend. + */ +function renderArchetypeBadge( + archetype: string, + sf: number, + text: string, + accentColor: string, + statsFont: string, + legendYOffset: number +): string { + const fs = (n: number): number => Math.round(n * sf * 10) / 10; + const s = createScaler(sf); + + const BADGE_X = s(432); + const BADGE_Y = s(legendYOffset); + const BADGE_W = s(150); + const BADGE_H = s(18); + const CORNER_R = s(9); + + const hexAccent = accentColor.startsWith('#') ? accentColor : `#${accentColor}`; + + return ` + + + ${escapeXML(archetype)} + `; +} + +/** + * Generates the language-aware isometric monolith SVG — `view=techstack`. + * + * Tower colors are driven by the contributor's dominant programming language. + * A legend panel on the right side lists the top languages with color swatches. + * A developer archetype badge is displayed below the legend. + * + * @param stats - Streak stats for the user + * @param params - Badge rendering params + * @param calendar - Contribution calendar data + * @param repoContributions - Repository contributions with language data + */ +export function generateTechStackSVG( + stats: import('../../types').StreakStats, + params: BadgeParams, + calendar: ContributionCalendar, + repoContributions: import('../../types').RepoContribution[] +): string { + if (params.autoTheme) { + // Delegate to auto-theme variant with techstack accent colors injected + const summary = aggregateTechStack(repoContributions); + const palette = buildLanguageColorPalette(summary); + const techParams: BadgeParams = { + ...params, + accent: palette as import('../../types').HexColor[], + }; + return generateAutoThemeSVG(stats, techParams, calendar); + } + + const rawBorderWidth = String(params.border || '').trim(); + let safeBorderWidth: string | number = 0; + if (/^\d+$/.test(rawBorderWidth)) { + safeBorderWidth = parseInt(rawBorderWidth, 10); + } else if ( + /^#[0-9a-fA-F]{3,6}$/.test(rawBorderWidth) || + /^[0-9a-fA-F]{3,6}$/.test(rawBorderWidth) + ) { + safeBorderWidth = 2; + } else if (['none', 'thin', 'medium', 'thick'].includes(rawBorderWidth.toLowerCase())) { + safeBorderWidth = rawBorderWidth.toLowerCase(); + } + + const animate = params.animate ?? true; + const safeUser = escapeXML(params.user || 'GitHub User'); + const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`; + const bgFill = + params.bgType === 'linear' || params.bgType === 'radial' ? 'url(#canvas-gradient)' : bg; + + // ── Build tech stack data ────────────────────────────────────────────────── + const techSummary = aggregateTechStack(repoContributions); + const langPalette = buildLanguageColorPalette(techSummary, 5); + + // Use tech stack colors as multi-accent if available, otherwise fall back to params accent + const accent: string | string[] = + langPalette.length > 0 + ? langPalette + : Array.isArray(params.accent) + ? params.accent.map((c) => sanitizeHexColor(c, '00ffaa')) + : sanitizeHexColor(params.accent, '00ffaa'); + + const text = `#${sanitizeHexColor(params.text, 'ffffff')}`; + + const borderAttr = safeBorderWidth + ? `stroke="#${sanitizeHexColor(params.border, '000000')}" stroke-width="${safeBorderWidth}"` + : ''; + + const sanitizedFont = sanitizeFont(params.font); + const selectedFont = resolveFont(sanitizedFont); + const isPredefinedFont = isBundledFont(sanitizedFont); + const statsFont = selectedFont || '"Space Grotesk", sans-serif'; + const googleFontUrlPart = + sanitizedFont && !isPredefinedFont ? sanitizeGoogleFontUrl(sanitizedFont) : null; + const googleFontsImport = googleFontUrlPart + ? `@import url('https://fonts.googleapis.com/css2?family=${googleFontUrlPart}&display=swap');` + : ''; + + const sf = getSizeScale(params.size); + const radius = sanitizeRadius(params.radius, 8) * sf; + const labels = getLabels(params.lang); + const labelVisible = params.label !== false; + const W = Math.round(SVG_WIDTH * sf); + const H = Math.round((labelVisible ? SVG_HEIGHT : SVG_HEIGHT - 40) * sf); + const yOffset = params.label === false ? -40 : 0; + + const towerData = scaleTowerData( + computeTowers(calendar, params.scale, stats.todayDate, params.mode), + sf + ); + + if (params.gradient) { + generateCustomGradients(params); + } + + // Dominant accent for particles/glow + const mainAccentHex = + langPalette.length > 0 + ? (langPalette[langPalette.length - 1] ?? '#00ffaa') + : Array.isArray(accent) + ? (accent[accent.length - 1] ?? '#00ffaa') + : typeof accent === 'string' + ? accent.startsWith('#') + ? accent + : `#${accent}` + : '#00ffaa'; + + const towers = renderTowers( + towerData, + params, + accent, + text, + sf, + false, + params.opacity ?? 1.0, + animate, + false + ); + + const safeId = safeUser.replace(/[^a-zA-Z0-9-]/g, '_').toLowerCase(); + + // ── Legend & badge Y positioning ────────────────────────────────────────── + const legendTopY = 90; + const legendBottomY = legendTopY + 22 + techSummary.topLanguages.length * 20 + 18; + + const legend = renderTechStackLegend(techSummary, sf, text, bg, statsFont); + const archetypeBadge = + techSummary.dominantLanguage !== null + ? renderArchetypeBadge( + techSummary.archetype, + sf, + text, + mainAccentHex, + statsFont, + legendBottomY + ) + : ''; + + return ` + + CommitPulse Tech Stack for ${safeUser} + ${safeUser} — ${techSummary.archetype}. Dominant language: ${escapeXML(techSummary.dominantLanguage ?? 'Unknown')}. ${stats.totalContributions} total contributions. + ${renderDefs(sf, params)} + ${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg, params.entrance || 'rise')} + ${renderBackgroundRect(params.hideBackground ? 'transparent' : bgFill, radius, borderAttr || undefined)} + ${towers} + ${renderIsometricLabels(calendar, params, text, sf)} + ${legend} + ${archetypeBadge} + ${renderFooter(stats, params, labels, safeUser, mainAccentHex, sf)} + ${renderMilestoneBadges(stats, params, sf)} +`; +} diff --git a/lib/svg/languageColors.ts b/lib/svg/languageColors.ts index 6214c99f0..0d26bc9be 100644 --- a/lib/svg/languageColors.ts +++ b/lib/svg/languageColors.ts @@ -1,26 +1,88 @@ +// lib/svg/languageColors.ts +// Comprehensive language color map sourced from github-linguist / github/linguist +// Used for tech stack visualization and tower color-coding. export const LANGUAGE_COLORS: Record = { + // Web / Frontend TypeScript: '#3178c6', JavaScript: '#f1e05a', + HTML: '#e34c26', + CSS: '#563d7c', + SCSS: '#c6538c', + Sass: '#a53b70', + Less: '#1d365d', + Vue: '#41b883', + Svelte: '#ff3e00', + CoffeeScript: '#244776', + Handlebars: '#f7931e', + Liquid: '#67b8de', + Pug: '#a86454', + EJS: '#a91e50', + Astro: '#ff5a03', + WebAssembly: '#04133b', + + // Backend / Systems Python: '#3572A5', Java: '#b07219', 'C++': '#f34b7d', - HTML: '#e34c26', - CSS: '#563d7c', - Go: '#00ADD8', - Rust: '#dea584', C: '#555555', 'C#': '#178600', - PHP: '#4F5D95', + Go: '#00ADD8', + Rust: '#dea584', Ruby: '#701516', + PHP: '#4F5D95', Swift: '#F05138', Kotlin: '#A97BFF', Dart: '#00B4AB', - Lua: '#000080', - R: '#198CE7', Scala: '#c22d40', - Perl: '#0298c3', + Clojure: '#db5855', Haskell: '#5e5086', Elixir: '#6e4a7e', - Vue: '#41b883', - Svelte: '#ff3e00', + Erlang: '#B83998', + Ocaml: '#ef7a08', + 'F#': '#b845fc', + Crystal: '#000100', + Nim: '#ffc200', + Zig: '#ec915c', + V: '#4f87c4', + Odin: '#3882D0', + + // Scripting / Shell + Shell: '#89e051', + Bash: '#89e051', + PowerShell: '#012456', + Lua: '#000080', + Perl: '#0298c3', + Awk: '#c30e9b', + Tcl: '#e4cc98', + Groovy: '#e69f56', + + // Data / ML / Scientific + R: '#198CE7', + Julia: '#a270ba', + MATLAB: '#e16737', + Jupyter: '#DA5B0B', + Fortran: '#4d41b1', + COBOL: '#0101ff', + 'Common Lisp': '#3fb68b', + Scheme: '#1e4aec', + Prolog: '#74283c', + + // DevOps / Config / Markup + Dockerfile: '#384d54', + HCL: '#844fba', + Nix: '#7e7eff', + CMake: '#DA3434', + Makefile: '#427819', + YAML: '#cb171e', + TOML: '#9c4121', + Jsonnet: '#0064bd', + + // Mobile + 'Objective-C': '#438eff', + 'Objective-C++': '#6866fb', + + // Database / Query + PLpgSQL: '#336791', + PLSQL: '#dad8d8', + TSQL: '#e9e8e8', }; diff --git a/lib/svg/techStackAnalytics.test.ts b/lib/svg/techStackAnalytics.test.ts new file mode 100644 index 000000000..b8a102124 --- /dev/null +++ b/lib/svg/techStackAnalytics.test.ts @@ -0,0 +1,210 @@ +// lib/svg/techStackAnalytics.test.ts +import { describe, it, expect } from 'vitest'; +import { + aggregateTechStack, + detectDeveloperArchetype, + getDominantLanguageColor, + buildLanguageColorPalette, + type TechStackEntry, +} from './techStackAnalytics'; +import type { RepoContribution } from '../../types'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function makeRepo(name: string, language: string | null, count: number): RepoContribution { + return { + repository: { + name, + primaryLanguage: language ? { name: language } : null, + }, + contributions: { totalCount: count }, + }; +} + +// ── aggregateTechStack ──────────────────────────────────────────────────────── + +describe('aggregateTechStack', () => { + it('returns empty summary for empty input', () => { + const summary = aggregateTechStack([]); + expect(summary.topLanguages).toEqual([]); + expect(summary.allLanguages).toEqual([]); + expect(summary.dominantLanguage).toBeNull(); + expect(summary.archetype).toBe('GitHub Developer'); + expect(summary.totalCommits).toBe(0); + }); + + it('returns empty summary when all repos have no language', () => { + const repos = [makeRepo('repo1', null, 50), makeRepo('repo2', null, 30)]; + const summary = aggregateTechStack(repos); + expect(summary.topLanguages).toEqual([]); + expect(summary.dominantLanguage).toBeNull(); + }); + + it('correctly aggregates single language', () => { + const repos = [makeRepo('a', 'TypeScript', 100), makeRepo('b', 'TypeScript', 50)]; + const summary = aggregateTechStack(repos); + expect(summary.topLanguages).toHaveLength(1); + expect(summary.topLanguages[0]!.language).toBe('TypeScript'); + expect(summary.topLanguages[0]!.commits).toBe(150); + expect(summary.topLanguages[0]!.percentage).toBe(100); + expect(summary.dominantLanguage).toBe('TypeScript'); + }); + + it('sorts languages by commit count descending', () => { + const repos = [ + makeRepo('a', 'Go', 10), + makeRepo('b', 'Python', 200), + makeRepo('c', 'TypeScript', 100), + ]; + const summary = aggregateTechStack(repos); + expect(summary.topLanguages[0]!.language).toBe('Python'); + expect(summary.topLanguages[1]!.language).toBe('TypeScript'); + expect(summary.topLanguages[2]!.language).toBe('Go'); + }); + + it('limits topLanguages to 5', () => { + const repos = [ + makeRepo('a', 'TypeScript', 100), + makeRepo('b', 'Python', 90), + makeRepo('c', 'Go', 80), + makeRepo('d', 'Rust', 70), + makeRepo('e', 'Java', 60), + makeRepo('f', 'Ruby', 50), + ]; + const summary = aggregateTechStack(repos); + expect(summary.topLanguages).toHaveLength(5); + expect(summary.allLanguages).toHaveLength(6); + // Ruby is #6, excluded from top 5 but in allLanguages + expect(summary.allLanguages.at(-1)!.language).toBe('Ruby'); + }); + + it('computes percentages correctly', () => { + const repos = [makeRepo('a', 'TypeScript', 75), makeRepo('b', 'Python', 25)]; + const summary = aggregateTechStack(repos); + const ts = summary.topLanguages.find((l) => l.language === 'TypeScript'); + const py = summary.topLanguages.find((l) => l.language === 'Python'); + expect(ts!.percentage).toBe(75); + expect(py!.percentage).toBe(25); + }); + + it('assigns known LANGUAGE_COLORS', () => { + const repos = [makeRepo('a', 'TypeScript', 100)]; + const summary = aggregateTechStack(repos); + expect(summary.topLanguages[0]!.color).toBe('#3178c6'); + }); + + it('assigns fallback color for unknown languages', () => { + const repos = [makeRepo('a', 'BrainFuck', 100)]; + const summary = aggregateTechStack(repos); + expect(summary.topLanguages[0]!.color).toBe('#8B949E'); + }); + + it('computes totalCommits correctly', () => { + const repos = [makeRepo('a', 'Go', 40), makeRepo('b', 'Rust', 60)]; + const summary = aggregateTechStack(repos); + expect(summary.totalCommits).toBe(100); + }); +}); + +// ── detectDeveloperArchetype ────────────────────────────────────────────────── + +describe('detectDeveloperArchetype', () => { + it('returns GitHub Developer for empty input', () => { + expect(detectDeveloperArchetype([])).toBe('GitHub Developer'); + }); + + it('detects AI / ML Engineer', () => { + const stack: TechStackEntry[] = [ + { language: 'Python', commits: 300, percentage: 75, color: '#3572A5' }, + { language: 'Julia', commits: 100, percentage: 25, color: '#a270ba' }, + ]; + expect(detectDeveloperArchetype(stack)).toBe('AI / ML Engineer'); + }); + + it('detects Systems Programmer', () => { + const stack: TechStackEntry[] = [ + { language: 'Rust', commits: 300, percentage: 70, color: '#dea584' }, + { language: 'C', commits: 100, percentage: 30, color: '#555555' }, + ]; + expect(detectDeveloperArchetype(stack)).toBe('Systems Programmer'); + }); + + it('detects Frontend Developer', () => { + const stack: TechStackEntry[] = [ + { language: 'TypeScript', commits: 400, percentage: 80, color: '#3178c6' }, + { language: 'CSS', commits: 100, percentage: 20, color: '#563d7c' }, + ]; + expect(detectDeveloperArchetype(stack)).toBe('Frontend Developer'); + }); + + it('detects Mobile Developer', () => { + const stack: TechStackEntry[] = [ + { language: 'Swift', commits: 300, percentage: 60, color: '#F05138' }, + { language: 'Kotlin', commits: 200, percentage: 40, color: '#A97BFF' }, + ]; + expect(detectDeveloperArchetype(stack)).toBe('Mobile Developer'); + }); + + it('detects a plausible archetype for mixed web+backend stack', () => { + const stack: TechStackEntry[] = [ + { language: 'TypeScript', commits: 200, percentage: 40, color: '#3178c6' }, + { language: 'Python', commits: 200, percentage: 40, color: '#3572A5' }, + { language: 'CSS', commits: 100, percentage: 20, color: '#563d7c' }, + ]; + const result = detectDeveloperArchetype(stack); + // TypeScript (40%) qualifies for Frontend; Python (40%) qualifies for AI/ML. + // All three are legitimate archetypes for this mixed profile. + expect(['Frontend Developer', 'Full-Stack Developer', 'AI / ML Engineer']).toContain(result); + }); + + it('falls back to Polyglot Developer for unusual stacks', () => { + const stack: TechStackEntry[] = [ + { language: 'Prolog', commits: 50, percentage: 25, color: '#74283c' }, + { language: 'COBOL', commits: 50, percentage: 25, color: '#0101ff' }, + { language: 'Fortran', commits: 50, percentage: 25, color: '#4d41b1' }, + { language: 'Awk', commits: 50, percentage: 25, color: '#c30e9b' }, + ]; + expect(detectDeveloperArchetype(stack)).toBe('Polyglot Developer'); + }); +}); + +// ── getDominantLanguageColor ────────────────────────────────────────────────── + +describe('getDominantLanguageColor', () => { + it('returns fallback color for empty summary', () => { + const summary = aggregateTechStack([]); + expect(getDominantLanguageColor(summary, '#aabbcc')).toBe('#aabbcc'); + }); + + it('returns dominant language color', () => { + const repos = [makeRepo('a', 'TypeScript', 100), makeRepo('b', 'Go', 20)]; + const summary = aggregateTechStack(repos); + expect(getDominantLanguageColor(summary)).toBe('#3178c6'); + }); +}); + +// ── buildLanguageColorPalette ───────────────────────────────────────────────── + +describe('buildLanguageColorPalette', () => { + it('returns fallback for empty summary', () => { + const summary = aggregateTechStack([]); + expect(buildLanguageColorPalette(summary, 5, '#00ffaa')).toEqual(['#00ffaa']); + }); + + it('returns up to N colors', () => { + const repos = [ + makeRepo('a', 'TypeScript', 100), + makeRepo('b', 'Python', 90), + makeRepo('c', 'Go', 80), + makeRepo('d', 'Rust', 70), + makeRepo('e', 'Java', 60), + makeRepo('f', 'Ruby', 50), + ]; + const summary = aggregateTechStack(repos); + const palette = buildLanguageColorPalette(summary, 3); + expect(palette).toHaveLength(3); + expect(palette[0]).toBe('#3178c6'); // TypeScript + expect(palette[1]).toBe('#3572A5'); // Python + expect(palette[2]).toBe('#00ADD8'); // Go + }); +}); diff --git a/lib/svg/techStackAnalytics.ts b/lib/svg/techStackAnalytics.ts new file mode 100644 index 000000000..f5ade37de --- /dev/null +++ b/lib/svg/techStackAnalytics.ts @@ -0,0 +1,202 @@ +// lib/svg/techStackAnalytics.ts +// Pure utility for aggregating tech stack data from repository contributions. +// This module has NO server-side imports and is safe to use in both client and server contexts. + +import type { RepoContribution } from '../../types'; +import { LANGUAGE_COLORS } from './languageColors'; + +export interface TechStackEntry { + /** Programming language name */ + language: string; + /** Total commit contributions to repos using this language */ + commits: number; + /** Percentage of total contributions (0–100) */ + percentage: number; + /** Hex color for this language (includes '#' prefix) */ + color: string; +} + +export interface TechStackSummary { + /** Top-5 languages by contribution count */ + topLanguages: TechStackEntry[]; + /** Full list of all detected languages */ + allLanguages: TechStackEntry[]; + /** Single dominant language name, or null if no data */ + dominantLanguage: string | null; + /** Developer archetype string derived from the tech stack */ + archetype: string; + /** Total contribution count across all repos */ + totalCommits: number; +} + +// ─── Archetype definitions ──────────────────────────────────────────────────── + +const ARCHETYPES: Array<{ + name: string; + languages: string[]; + minShare: number; // minimum % of one matching language to qualify +}> = [ + { + name: 'AI / ML Engineer', + languages: ['Python', 'Julia', 'R', 'MATLAB', 'Jupyter', 'Fortran'], + minShare: 30, + }, + { + name: 'Systems Programmer', + languages: ['Rust', 'C', 'C++', 'Zig', 'Go', 'Assembly'], + minShare: 30, + }, + { + name: 'Mobile Developer', + languages: ['Swift', 'Kotlin', 'Dart', 'Objective-C', 'Objective-C++', 'Java'], + minShare: 30, + }, + { + name: 'Backend Developer', + languages: ['Go', 'Java', 'Kotlin', 'Rust', 'Python', 'Ruby', 'PHP', 'Scala', 'Elixir', 'C#'], + minShare: 40, + }, + { + name: 'Frontend Developer', + languages: ['TypeScript', 'JavaScript', 'HTML', 'CSS', 'Vue', 'Svelte', 'Astro'], + minShare: 40, + }, + { + name: 'Full-Stack Developer', + languages: ['TypeScript', 'JavaScript', 'Python', 'Go', 'Ruby', 'PHP', 'Java', 'Kotlin', 'C#'], + minShare: 15, + }, + { + name: 'Data Engineer', + languages: ['Python', 'Scala', 'SQL', 'R', 'Julia', 'MATLAB'], + minShare: 30, + }, + { + name: 'DevOps Engineer', + languages: ['Shell', 'Bash', 'Python', 'Go', 'HCL', 'Dockerfile', 'PowerShell', 'Makefile'], + minShare: 25, + }, + { + name: 'Functional Programmer', + languages: ['Haskell', 'Elixir', 'Clojure', 'F#', 'Ocaml', 'Erlang', 'Scheme', 'Common Lisp'], + minShare: 20, + }, +]; + +// ─── Core aggregation ───────────────────────────────────────────────────────── + +/** + * Aggregate repository contributions into a sorted tech stack summary. + * + * @param repoContributions - Raw repository contribution data from the GitHub API + * @returns Full TechStackSummary with percentages, colors, and archetype + */ +export function aggregateTechStack(repoContributions: RepoContribution[]): TechStackSummary { + const langCounts: Record = {}; + + for (const contrib of repoContributions) { + const lang = contrib.repository.primaryLanguage?.name; + if (lang && lang.trim()) { + langCounts[lang] = (langCounts[lang] ?? 0) + contrib.contributions.totalCount; + } + } + + const totalCommits = Object.values(langCounts).reduce((sum, n) => sum + n, 0); + + if (totalCommits === 0) { + return { + topLanguages: [], + allLanguages: [], + dominantLanguage: null, + archetype: 'GitHub Developer', + totalCommits: 0, + }; + } + + const allLanguages: TechStackEntry[] = Object.entries(langCounts) + .map(([language, commits]) => ({ + language, + commits, + percentage: Math.round((commits / totalCommits) * 100), + color: (LANGUAGE_COLORS as Record)[language] ?? '#8B949E', + })) + .sort((a, b) => b.commits - a.commits); + + const topLanguages = allLanguages.slice(0, 5); + const dominantLanguage = topLanguages[0]?.language ?? null; + const archetype = detectDeveloperArchetype(allLanguages); + + return { + topLanguages, + allLanguages, + dominantLanguage, + archetype, + totalCommits, + }; +} + +/** + * Determine a developer archetype label based on the language distribution. + * Scans archetype definitions in priority order and returns the first match. + * Falls back to 'Polyglot Developer' if nothing matches, or 'GitHub Developer' + * when there is no language data at all. + * + * @param allLanguages - Sorted (desc) list of TechStackEntry objects + */ +export function detectDeveloperArchetype(allLanguages: TechStackEntry[]): string { + if (allLanguages.length === 0) return 'GitHub Developer'; + + for (const archetype of ARCHETYPES) { + const matchingLangs = allLanguages.filter((l) => archetype.languages.includes(l.language)); + const matchingShare = matchingLangs.reduce((sum, l) => sum + l.percentage, 0); + + // At least one language from this archetype must meet the threshold + const topMatchShare = matchingLangs[0]?.percentage ?? 0; + if (topMatchShare >= archetype.minShare || matchingShare >= archetype.minShare * 1.5) { + return archetype.name; + } + } + + // Fallback: Full-Stack if multiple language families represented + const hasWeb = allLanguages.some((l) => + ['TypeScript', 'JavaScript', 'HTML', 'CSS'].includes(l.language) + ); + const hasBackend = allLanguages.some((l) => + ['Python', 'Go', 'Java', 'Ruby', 'PHP', 'Rust', 'C#', 'Kotlin'].includes(l.language) + ); + if (hasWeb && hasBackend) return 'Full-Stack Developer'; + + return 'Polyglot Developer'; +} + +/** + * Get the dominant language color for a tech stack. + * Returns the hex color (with '#' prefix) for the most-used language, + * or the fallback if no language data is present. + * + * @param summary - Aggregated tech stack summary + * @param fallbackColor - Hex color string (with '#' prefix) used when no language found + */ +export function getDominantLanguageColor( + summary: TechStackSummary, + fallbackColor: string = '#00ffaa' +): string { + return summary.topLanguages[0]?.color ?? fallbackColor; +} + +/** + * Build a color palette of the top N languages for use in multi-accent tower rendering. + * Returns an array of hex color strings (with '#' prefix). + * + * @param summary - Aggregated tech stack summary + * @param count - Maximum number of colors to return (default 5) + * @param fallbackColor - Accent fallback when fewer languages exist + */ +export function buildLanguageColorPalette( + summary: TechStackSummary, + count: number = 5, + fallbackColor: string = '#00ffaa' +): string[] { + if (summary.topLanguages.length === 0) return [fallbackColor]; + return summary.topLanguages.slice(0, count).map((l) => l.color); +} diff --git a/lib/validations.ts b/lib/validations.ts index a30dbea37..0034e0aa3 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -503,6 +503,7 @@ const baseStreakParamsSchema = z.object({ 'commit_clock', 'weekday', 'punchcard', + 'techstack', ]) .catch('default') .default('default'), diff --git a/middleware.ts b/middleware.ts index 900253fd3..7b1343355 100644 --- a/middleware.ts +++ b/middleware.ts @@ -60,6 +60,8 @@ const ROUTES_WITH_OWN_RATE_LIMITING = [ '/api/learning-curve', '/api/org', '/api/spotify', // Added here in case it has its own rate limiter + '/api/languages', + '/api/tech-stack', ]; function addSecurityHeaders(response: NextResponse): NextResponse { @@ -205,5 +207,7 @@ export const config = { '/api/user-repos/:path*', '/api/webhook/:path*', '/api/webhooks/:path*', + '/api/languages/:path*', + '/api/tech-stack/:path*', ], }; diff --git a/models/TechStackAnalytics.ts b/models/TechStackAnalytics.ts new file mode 100644 index 000000000..fd13d6e99 --- /dev/null +++ b/models/TechStackAnalytics.ts @@ -0,0 +1,83 @@ +// models/TechStackAnalytics.ts +// MongoDB/Mongoose schema for caching processed tech stack analytics. +// Reduces repeated GitHub API calls and computation costs. +import mongoose, { Document, Model, Schema } from 'mongoose'; + +export interface ITechStackEntry { + language: string; + commits: number; + percentage: number; + color: string; +} + +export interface ITechStackAnalytics extends Document { + /** GitHub username (lowercased) */ + username: string; + /** Year the analytics cover, e.g. "2024". "all" for full-history. */ + year: string; + /** Top languages sorted by contribution count (desc) */ + techStack: ITechStackEntry[]; + /** Full language list (not just top-5) */ + allLanguages: ITechStackEntry[]; + /** Dominant language name */ + dominantLanguage: string | null; + /** Developer archetype label */ + archetype: string; + /** Total commits counted when the analytics were computed */ + totalCommits: number; + /** ISO timestamp when the analytics were computed */ + computedAt: Date; + /** When this cache entry expires — used for TTL index */ + ttlExpiry: Date; +} + +const TechStackEntrySchema = new Schema( + { + language: { type: String, required: true }, + commits: { type: Number, required: true }, + percentage: { type: Number, required: true }, + color: { type: String, required: true }, + }, + { _id: false } +); + +const TechStackAnalyticsSchema = new Schema( + { + username: { type: String, required: true, lowercase: true, trim: true }, + year: { type: String, required: true }, + techStack: { type: [TechStackEntrySchema], default: [] }, + allLanguages: { type: [TechStackEntrySchema], default: [] }, + dominantLanguage: { type: String, default: null }, + archetype: { type: String, required: true, default: 'GitHub Developer' }, + totalCommits: { type: Number, required: true, default: 0 }, + computedAt: { type: Date, required: true, default: Date.now }, + ttlExpiry: { type: Date, required: true }, + }, + { + timestamps: false, + collection: 'techstack_analytics', + } +); + +// Unique compound index: one document per (username, year) pair +TechStackAnalyticsSchema.index({ username: 1, year: 1 }, { unique: true }); + +// MongoDB TTL index: automatically delete expired documents +TechStackAnalyticsSchema.index({ ttlExpiry: 1 }, { expireAfterSeconds: 0 }); + +/** 24-hour cache TTL in milliseconds */ +export const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * Build the TTL expiry date for a new cache entry. + * @param ttlMs - Time-to-live in milliseconds (defaults to CACHE_TTL_MS = 24h) + */ +export function buildTtlExpiry(ttlMs: number = CACHE_TTL_MS): Date { + return new Date(Date.now() + ttlMs); +} + +const TechStackAnalytics: Model = + mongoose.models.TechStackAnalytics || + mongoose.model('TechStackAnalytics', TechStackAnalyticsSchema); + +export default TechStackAnalytics; diff --git a/types/index.ts b/types/index.ts index 2e3406eaf..b3233d3d1 100644 --- a/types/index.ts +++ b/types/index.ts @@ -284,7 +284,7 @@ export interface BadgeParams { /** Language/locale code for stat labels (e.g. 'en', 'fr', 'ja'). Defaults to 'en'. */ lang?: string; - /** Badge layout variant. 'default' shows the isometric monolith; 'monthly' shows month-over-month stats; 'heatmap' shows a flat 2D contribution heatmap; 'pulse' shows a heartbeat sparkline; 'skyline' shows a city skyline; 'languages' shows a 3D isometric city of top programming languages; 'constellation' shows a celestial star-map SVG visualization; 'radar' shows a radar chart of contribution metrics. */ + /** Badge layout variant. 'default' shows the isometric monolith; 'monthly' shows month-over-month stats; 'heatmap' shows a flat 2D contribution heatmap; 'pulse' shows a heartbeat sparkline; 'skyline' shows a city skyline; 'languages' shows a 3D isometric city of top programming languages; 'constellation' shows a celestial star-map SVG visualization; 'radar' shows a radar chart of contribution metrics; 'techstack' shows a language-aware isometric monolith with a tech stack legend. */ view?: | 'default' | 'monthly' @@ -299,7 +299,8 @@ export interface BadgeParams { | 'activity_graph' | 'commit_clock' | 'weekday' - | 'punchcard'; + | 'punchcard' + | 'techstack'; /** Format for the monthly delta indicator. 'percent' shows %, 'absolute' shows raw count, 'both' shows both. */ delta_format?: 'percent' | 'absolute' | 'both';