Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/api/streak/png/route.type-compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe('ApiStreakPngRoute - TypeScript Compiler Validation & Schema Constraint
| 'commit_clock'
| 'weekday'
| 'punchcard'
| 'techstack'
>();
expectTypeOf<StreakParams['scale']>().toEqualTypeOf<'linear' | 'log' | 'sqrt'>();
expectTypeOf<StreakParams['size']>().toEqualTypeOf<'small' | 'medium' | 'large'>();
Expand Down
18 changes: 14 additions & 4 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
generateLanguagesSVG,
generateActivityGraphSVG,
buildInlineErrorSVG,
generateTechStackSVG,
} from '@/lib/svg/generator';
import { generateConstellationSVG } from '@/lib/svg/constellation';
import { generateRadarSVG } from '@/lib/svg/radar';
Expand All @@ -49,7 +50,7 @@
} from '@/types';
import { getNormalizedThemeKey, themes, resolveErrorTheme } from '@/lib/svg/themes';
import { streakParamsSchema, coerceQueryParams } from '@/lib/validations';
import { sanitizeHexColor, sanitizeRadius, escapeXML } from '@/lib/svg/sanitizer';

Check warning on line 53 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format Β· Lint Β· Typecheck Β· Test

'escapeXML' is defined but never used. Allowed unused vars must match /^_/u
import { getClientIp } from '@/utils/getClientIp';
import { quotaMonitor } from '@/services/github/quota-monitor';
import { refreshPolicy } from '@/services/github/refresh-policy';
Expand Down Expand Up @@ -188,7 +189,8 @@
| 'activity_graph'
| 'commit_clock'
| 'weekday'
| 'punchcard';
| 'punchcard'
| 'techstack';
const themeKey = getNormalizedThemeKey(theme);
const themeName = themeKey === 'default' && theme ? theme : themeKey;

Expand Down Expand Up @@ -439,7 +441,10 @@
});
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(',')
Expand Down Expand Up @@ -494,7 +499,7 @@
calendar: d.calendar,
}));
repoContributions =
normalizedView === 'languages'
normalizedView === 'languages' || normalizedView === 'techstack'
? successfulData.flatMap((d) => d.repoContributions || [])
: [];
if (hasOfflineFallback) {
Expand All @@ -508,7 +513,10 @@
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;
Expand Down Expand Up @@ -729,6 +737,8 @@
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);
Expand Down
177 changes: 177 additions & 0 deletions app/api/tech-stack/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// app/api/tech-stack/route.ts
// REST endpoint: GET /api/tech-stack?user=<username>[&year=<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 }
);
}
}
53 changes: 53 additions & 0 deletions components/dashboard/ActivityHeatmapPro.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => <div {...filterProps(props)}>{children}</div>,
Expand Down Expand Up @@ -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(<ActivityHeatmapPro {...baseProps} username="testuser" />);

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(<ActivityHeatmapPro {...baseProps} username="testuser" />);

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();
});
});
Loading
Loading