From f27460bb002a1a46ac59366419355dc115bf0ee1 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Tue, 4 Aug 2026 09:04:03 +0530
Subject: [PATCH 1/3] feat: Add tech stack contribution analysis and
language-aware visualization (#1760)
---
.../streak/png/route.type-compiler.test.ts | 1 +
app/api/streak/route.ts | 18 +-
app/api/tech-stack/route.ts | 177 +++++++++++++
docs/customization.md | 76 +++---
lib/svg/generator.ts | 234 ++++++++++++++++++
lib/svg/languageColors.ts | 82 +++++-
lib/svg/techStackAnalytics.test.ts | 210 ++++++++++++++++
lib/svg/techStackAnalytics.ts | 202 +++++++++++++++
lib/validations.ts | 1 +
middleware.ts | 4 +
models/TechStackAnalytics.ts | 83 +++++++
types/index.ts | 5 +-
12 files changed, 1039 insertions(+), 54 deletions(-)
create mode 100644 app/api/tech-stack/route.ts
create mode 100644 lib/svg/techStackAnalytics.test.ts
create mode 100644 lib/svg/techStackAnalytics.ts
create mode 100644 models/TechStackAnalytics.ts
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/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 `
+`;
+}
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 222d97752..e7e003a00 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 {
@@ -204,5 +206,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';
From 27a97f23b3f7d46c139169e0c02d34b721899fb0 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Wed, 5 Aug 2026 08:47:28 +0530
Subject: [PATCH 2/3] feat: add repository activity heatmap export
functionality (#5090)
---
.../dashboard/ActivityHeatmapPro.test.tsx | 53 +++++
components/dashboard/ActivityHeatmapPro.tsx | 218 ++++++++++--------
components/dashboard/DashboardClient.tsx | 1 +
components/dashboard/Heatmap.test.tsx | 25 +-
components/dashboard/Heatmap.tsx | 94 +++++++-
5 files changed, 289 insertions(+), 102 deletions(-)
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..864260bbc 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,105 +71,22 @@ 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 slicedActivity = useMemo(() => activity.slice(-364), [activity]);
-
- const handleHeatmapKeyDown = (
- e: React.KeyboardEvent,
- index: number,
- total: number
- ) => {
- let targetIndex = index;
- const dIndex = index % 7;
-
- switch (e.key) {
- case 'ArrowUp':
- e.preventDefault();
- if (dIndex > 0) {
- targetIndex = index - 1;
- }
- break;
- case 'ArrowDown':
- e.preventDefault();
- if (dIndex < 6 && index + 1 < total) {
- targetIndex = index + 1;
- }
- break;
- case 'ArrowLeft':
- e.preventDefault();
- if (index >= 7) {
- targetIndex = index - 7;
- }
- break;
- case 'ArrowRight':
- e.preventDefault();
- if (index + 7 < total) {
- targetIndex = index + 7;
- }
- break;
- default:
- return;
- }
-
- if (targetIndex !== index) {
- const targetCell = containerRef.current?.querySelector(
- `[data-index="${targetIndex}"]`
- );
- targetCell?.focus();
- }
- };
-
- // Compute weekly pattern from activity
- const weeklyPattern = useMemo(() => {
- const dayCounts: Record = {};
- dayLabels.forEach((d) => (dayCounts[d] = { total: 0, count: 0 }));
-
- activity.forEach((a) => {
- const date = new Date(a.date);
- const dayName = dayLabels[date.getDay()];
- if (dayCounts[dayName]) {
- dayCounts[dayName].total += a.count;
- dayCounts[dayName].count += 1;
- }
- });
-
- return dayLabels.map((day) => ({
- day,
- total: dayCounts[day].total,
- average:
- dayCounts[day].count > 0 ? Math.round(dayCounts[day].total / dayCounts[day].count) : 0,
- }));
- }, [activity]);
+ const exportFilename = username ? `${username}-activity-heatmap` : 'repository-activity-heatmap';
+ const { exportImage, isExporting } = useExportImage({
+ targetSelector: '[data-export-target="activity-heatmap-pro"]',
+ filename: exportFilename,
+ });
- // Monthly aggregation
- const monthlyData = useMemo(() => {
- const months: Record = {};
-
- activity.forEach((a) => {
- const monthKey = a.date.slice(0, 7); // YYYY-MM
- if (!months[monthKey]) months[monthKey] = { total: 0, days: 0 };
- months[monthKey].total += a.count;
- months[monthKey].days += 1;
- });
-
- return Object.entries(months)
- .sort(([a], [b]) => a.localeCompare(b))
- .slice(-12)
- .map(([key, val]) => {
- const [year, month] = key.split('-');
- return {
- label: `${monthLabels[parseInt(month) - 1]} ${year.slice(2)}`,
- total: val.total,
- average: Math.round(val.total / val.days),
- days: val.days,
- };
- });
- }, [activity]);
+ const slicedActivity = useMemo(() => activity.slice(-364), [activity]);
// Peak activity stats
const stats = useMemo(() => {
@@ -177,6 +105,24 @@ export default function ActivityHeatmapPro({
};
}, [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');
+ }
+ };
+
// Hourly distribution (computed from rawCommits or fallback)
const hourlyData = useMemo(() => {
if (rawCommits && rawCommits.length > 0) {
@@ -231,6 +177,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 +195,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)} />
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+ >
+ )}
+
+
From 2f64eb9a9527d279b5dd794f9b6d432233266c41 Mon Sep 17 00:00:00 2001
From: armanpanigrahi59 <11309arman@gmail.com>
Date: Wed, 5 Aug 2026 09:02:30 +0530
Subject: [PATCH 3/3] fix: resolve type error and sync upstream main in heatmap
export (#5090)
---
components/dashboard/ActivityHeatmapPro.tsx | 92 +++++++++++++++++++++
1 file changed, 92 insertions(+)
diff --git a/components/dashboard/ActivityHeatmapPro.tsx b/components/dashboard/ActivityHeatmapPro.tsx
index 864260bbc..83166a373 100644
--- a/components/dashboard/ActivityHeatmapPro.tsx
+++ b/components/dashboard/ActivityHeatmapPro.tsx
@@ -123,6 +123,98 @@ export default function ActivityHeatmapPro({
}
};
+ const handleHeatmapKeyDown = (
+ e: React.KeyboardEvent,
+ index: number,
+ total: number
+ ) => {
+ let targetIndex = index;
+ const dIndex = index % 7;
+
+ switch (e.key) {
+ case 'ArrowUp':
+ e.preventDefault();
+ if (dIndex > 0) {
+ targetIndex = index - 1;
+ }
+ break;
+ case 'ArrowDown':
+ e.preventDefault();
+ if (dIndex < 6 && index + 1 < total) {
+ targetIndex = index + 1;
+ }
+ break;
+ case 'ArrowLeft':
+ e.preventDefault();
+ if (index >= 7) {
+ targetIndex = index - 7;
+ }
+ break;
+ case 'ArrowRight':
+ e.preventDefault();
+ if (index + 7 < total) {
+ targetIndex = index + 7;
+ }
+ break;
+ default:
+ return;
+ }
+
+ if (targetIndex !== index) {
+ const targetCell = containerRef.current?.querySelector(
+ `[data-index="${targetIndex}"]`
+ );
+ targetCell?.focus();
+ }
+ };
+
+ // Compute weekly pattern from activity
+ const weeklyPattern = useMemo(() => {
+ const dayCounts: Record = {};
+ dayLabels.forEach((d) => (dayCounts[d] = { total: 0, count: 0 }));
+
+ activity.forEach((a) => {
+ const date = new Date(a.date);
+ const dayName = dayLabels[date.getDay()];
+ if (dayCounts[dayName]) {
+ dayCounts[dayName].total += a.count;
+ dayCounts[dayName].count += 1;
+ }
+ });
+
+ return dayLabels.map((day) => ({
+ day,
+ total: dayCounts[day].total,
+ average:
+ dayCounts[day].count > 0 ? Math.round(dayCounts[day].total / dayCounts[day].count) : 0,
+ }));
+ }, [activity]);
+
+ // Monthly aggregation
+ const monthlyData = useMemo(() => {
+ const months: Record = {};
+
+ activity.forEach((a) => {
+ const monthKey = a.date.slice(0, 7); // YYYY-MM
+ if (!months[monthKey]) months[monthKey] = { total: 0, days: 0 };
+ months[monthKey].total += a.count;
+ months[monthKey].days += 1;
+ });
+
+ return Object.entries(months)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .slice(-12)
+ .map(([key, val]) => {
+ const [year, month] = key.split('-');
+ return {
+ label: `${monthLabels[parseInt(month) - 1]} ${year.slice(2)}`,
+ total: val.total,
+ average: Math.round(val.total / val.days),
+ days: val.days,
+ };
+ });
+ }, [activity]);
+
// Hourly distribution (computed from rawCommits or fallback)
const hourlyData = useMemo(() => {
if (rawCommits && rawCommits.length > 0) {