Skip to content
Open
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
2 changes: 1 addition & 1 deletion app/api/streak/route.error-resilience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ describe('GET /api/streak — error resilience & exception safety', () => {

describe('Cache-Control on error paths not covered by route.test.ts', () => {
it('returns a 400 with no-store caching for validation errors so a corrected reload is never served stale data', async () => {
const response = await GET(makeRequest({ user: 'octocat', theme: 'nonexistent_theme_name' }));
const response = await GET(makeRequest({ user: 'a'.repeat(45) }));

expect(response.status).toBe(400);
expect(response.headers.get('Cache-Control')).toBe('no-store');
Expand Down
24 changes: 10 additions & 14 deletions app/api/streak/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,13 +275,12 @@ describe('GET /api/streak', () => {
expect(response.status).toBe(400);
});

it('returns 400 when an invalid theme value is provided and lists allowed themes', async () => {
const response = await GET(makeRequest({ user: 'octocat', theme: 'nonexistent_theme_name' }));
it('returns 400 when an invalid user parameter is provided', async () => {
const response = await GET(makeRequest({ user: 'a'.repeat(45), theme: 'default' }));
expect(response.status).toBe(400);
const body = await response.text();
expect(body).toContain('<svg');
expect(body).toContain('Invalid theme');
expect(body).toContain('Supported themes:');
expect(body).toContain('cannot exceed 39 characters');
expect(fetchGitHubContributions).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -793,30 +792,27 @@ describe('GET /api/streak', () => {
expect(body).toContain('--cp-bg');
});

it('returns 400 Bad Request listing allowed themes when an invalid theme is provided', async () => {
it('returns 200 OK and applies default theme with a warning header when an unknown theme is provided', async () => {
const response = await GET(makeRequest({ user: 'octocat', theme: 'nonexistent_theme_name' }));
expect(response.status).toBe(400);
expect(response.status).toBe(200);
expect(response.headers.get('X-Theme-Warning')).toBe(
"Unknown theme 'nonexistent_theme_name', falling back to 'default'"
);
const body = await response.text();
expect(body).toContain('<svg');
expect(body).toContain('Invalid theme. Supported themes:');
});

it('returns 400 when theme parameter contains only whitespace', async () => {
it('returns 200 OK and falls back to default theme when theme parameter contains only whitespace', async () => {
const response = await GET(
makeRequest({
user: 'octocat',
theme: ' ',
})
);

expect(response.status).toBe(400);

expect(response.status).toBe(200);
const body = await response.text();
expect(body).toContain('<svg');
expect(body).toContain('Invalid theme');
expect(body).toContain('Supported themes:');

expect(fetchGitHubContributions).not.toHaveBeenCalled();
});

it('accepts capitalized or mixed-case theme parameter like "NEON" and maps it correctly', async () => {
Expand Down
17 changes: 17 additions & 0 deletions app/api/streak/route.theme-contrast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,21 @@ describe('GET /api/streak theme contrast', () => {

expect(body).toContain('--cp-bg');
});

it('falls back to default theme for unknown themes and sets X-Theme-Warning header', async () => {
const response = await GET(
makeRequest({
user: 'octocat',
theme: 'unknown_theme_123',
})
);

expect(response.status).toBe(200);
expect(response.headers.get('X-Theme-Warning')).toBe(
"Unknown theme 'unknown_theme_123', falling back to 'default'"
);

const body = await response.text();
expect(body).toContain('<svg');
});
});
100 changes: 59 additions & 41 deletions app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
calculateStreak,
calculateMonthlyStats,
aggregateCalendars,
convertLocalToUtc,

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

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'convertLocalToUtc' is defined but never used
chunkDaysIntoWeeks,
normalizeCalendarToTimezone,
isLeapYear,

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

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'isLeapYear' is defined but never used
daysInYear,
} from '@/lib/calculate';
import {
Expand Down Expand Up @@ -57,7 +57,7 @@

import { validationCache as _vc, normalizeCacheKey, cachedValidation } from './validation-cache';
// Re-alias so existing usages in this file continue to work.
const validationCache = _vc;

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

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'validationCache' is assigned a value but never used

const SVG_CSP_HEADER =
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";
Expand Down Expand Up @@ -202,7 +202,19 @@
| 'commit_clock'
| 'weekday';
const themeKey = getNormalizedThemeKey(theme);
const themeName = themeKey === 'default' && theme ? theme : themeKey;

let themeWarning: string | null = null;
if (
theme &&
theme.toLowerCase() !== 'auto' &&
theme.toLowerCase() !== 'random' &&
themeKey === 'default' &&
theme.toLowerCase() !== 'default'
) {
themeWarning = `Unknown theme '${theme}', falling back to 'default'`;
}

const themeName = themeKey === 'default' && theme && !themeWarning ? theme : themeKey;

const ip = getClientIp(request);

Expand Down Expand Up @@ -635,26 +647,28 @@
if (ifNoneMatch) {
const etags = ifNoneMatch.split(',').map((e) => e.trim());
if (etags.includes(weakEtag) || etags.includes(`"${etag}"`)) {
const headers: Record<string, string> = {
'Cache-Control': cacheControl,
ETag: weakEtag,
'X-Request-ID': requestId,
};
if (themeWarning) headers['X-Theme-Warning'] = themeWarning;
return new NextResponse(null, {
status: 304,
headers: {
'Cache-Control': cacheControl,
ETag: weakEtag,
'X-Request-ID': requestId,
},
headers,
});
}
}

return new NextResponse(jsonPayload, {
headers: {
'Content-Type': 'application/json',
'Cache-Control': cacheControl,
ETag: weakEtag,
'X-Cache-Status': cacheStatusHeader,
'X-Request-ID': requestId,
},
});
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Cache-Control': cacheControl,
ETag: weakEtag,
'X-Cache-Status': cacheStatusHeader,
'X-Request-ID': requestId,
};
if (themeWarning) headers['X-Theme-Warning'] = themeWarning;
return new NextResponse(jsonPayload, { headers });
}

// ─── SVG output mode (default) ──────────────────────────────────────────
Expand Down Expand Up @@ -730,13 +744,15 @@
if (ifNoneMatch) {
const etags = ifNoneMatch.split(',').map((e) => e.trim());
if (etags.includes(weakEtag) || etags.includes(`"${etag}"`)) {
const headers: Record<string, string> = {
'Cache-Control': cacheControl,
ETag: weakEtag,
'X-Request-ID': requestId,
};
if (themeWarning) headers['X-Theme-Warning'] = themeWarning;
return new NextResponse(null, {
status: 304,
headers: {
'Cache-Control': cacheControl,
ETag: weakEtag,
'X-Request-ID': requestId,
},
headers,
});
}
}
Expand All @@ -749,31 +765,33 @@
});
const pngBuffer = resvg.render().asPng();

return new NextResponse(new Uint8Array(pngBuffer), {
headers: {
'Content-Type': 'image/png',
'Cache-Control': cacheControl,
'X-CommitPulse-Grace-Applied': String(grace),
ETag: weakEtag,
'X-Cache-Status': shouldBypassCache
? `BYPASS, fetched=${new Date().toISOString()}`
: `HIT, cached=${new Date().toISOString()}`,
'X-Request-ID': requestId,
},
});
}

return new NextResponse(svg, {
headers: {
'Content-Type': 'image/svg+xml; charset=utf-8',
const headers: Record<string, string> = {
'Content-Type': 'image/png',
'Cache-Control': cacheControl,
'Content-Security-Policy': SVG_CSP_HEADER,
'X-CommitPulse-Grace-Applied': String(grace),
ETag: weakEtag,
'X-Cache-Status': shouldBypassCache ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
'X-Cache-Status': shouldBypassCache
? `BYPASS, fetched=${new Date().toISOString()}`
: `HIT, cached=${new Date().toISOString()}`,
'X-Request-ID': requestId,
},
});
};
if (themeWarning) headers['X-Theme-Warning'] = themeWarning;

return new NextResponse(new Uint8Array(pngBuffer), { headers });
}

const headers: Record<string, string> = {
'Content-Type': 'image/svg+xml; charset=utf-8',
'Cache-Control': cacheControl,
'Content-Security-Policy': SVG_CSP_HEADER,
'X-CommitPulse-Grace-Applied': String(grace),
ETag: weakEtag,
'X-Cache-Status': shouldBypassCache ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
'X-Request-ID': requestId,
};
if (themeWarning) headers['X-Theme-Warning'] = themeWarning;

return new NextResponse(svg, { headers });
} catch (error: unknown) {
return buildErrorResponse(error, parseResult, requestId);
}
Expand Down
8 changes: 5 additions & 3 deletions app/api/streak/tests/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,14 @@ describe('Streak API - theme parameter integration tests', () => {
expect(body).toContain('<svg');
});

it('should return 400 Bad Request when theme parameter is invalid', async () => {
it('should fall back and return 200 OK when theme parameter is unknown', async () => {
const response = await GET(makeRequest({ user: 'octocat', theme: 'not-a-valid-theme' }));
expect(response.status).toBe(400);
expect(response.status).toBe(200);
expect(response.headers.get('X-Theme-Warning')).toBe(
"Unknown theme 'not-a-valid-theme', falling back to 'default'"
);
const body = await response.text();
expect(body).toContain('<svg');
expect(body).toContain('Invalid theme');
});

it('should produce different SVGs when theme is dark vs light', async () => {
Expand Down
32 changes: 30 additions & 2 deletions app/components/LandingPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { DiscordButton } from '@/components/DiscordButton';

import { WallOfLove } from '@/components/WallOfLove';
import { validateGitHubUsername } from '@/lib/validations';
import { THEME_PRESETS } from '@/lib/svg/themes';

const Icons = {
Github: () => (
Expand Down Expand Up @@ -316,6 +317,7 @@ export default function LandingPageClient() {
username: string;
status: 'loaded' | 'error';
} | null>(null);
const [selectedTheme, setSelectedTheme] = useState<string>('default');
const guideRef = useRef<HTMLDivElement>(null);
const heroRef = useRef<HTMLDivElement>(null);
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand Down Expand Up @@ -372,12 +374,13 @@ export default function LandingPageClient() {
latestPreviewUsernameRef.current = previewUsername;
}, [previewUsername]);

const badgeUrl = `/api/streak?user=${encodeURIComponent(previewUsername)}`;
const themeParam = selectedTheme !== 'default' ? `&theme=${selectedTheme}` : '';
const badgeUrl = `/api/streak?user=${encodeURIComponent(previewUsername)}${themeParam}`;
const siteUrl = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://commitpulse.vercel.app').replace(
/\/$/,
''
);
const markdown = `![CommitPulse](${siteUrl}/api/streak?user=${encodeURIComponent(trimmedUsername)})`;
const markdown = `![CommitPulse](${siteUrl}/api/streak?user=${encodeURIComponent(trimmedUsername)}${themeParam})`;
const DownloadSVG = () => {
const link = document.createElement('a');
link.href = badgeUrl;
Expand Down Expand Up @@ -789,6 +792,31 @@ export default function LandingPageClient() {
</div>
)}

{/* Theme Gallery */}
<div className="flex flex-col gap-3 mt-4 border-t border-zinc-200/5 dark:border-white/5 pt-4">
<div className="flex flex-wrap items-center gap-2.5 text-xs">
<span className="text-zinc-500 font-semibold uppercase tracking-wider text-[9px]">
{t('landing.theme_presets', { defaultValue: 'Theme Presets:' })}
</span>
<div className="flex flex-wrap gap-2">
{THEME_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setSelectedTheme(preset)}
className={`rounded-full border px-3 py-1 text-[11px] font-semibold transition-all duration-300 cursor-pointer capitalize ${
selectedTheme === preset
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-600 dark:text-emerald-400'
: 'border-zinc-200/10 bg-zinc-200/5 hover:bg-zinc-200/10 hover:border-zinc-300/30 text-zinc-600 dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:border-white/20'
}`}
>
{preset}
</button>
))}
</div>
</div>
</div>

{/* Footer Section: Demo & Recents */}
<div className="flex flex-col gap-3 mt-4 border-t border-zinc-200/5 dark:border-white/5 pt-4">
<div className="flex flex-wrap items-center gap-2.5 text-xs">
Expand Down
Binary file added diff.txt
Binary file not shown.
4 changes: 3 additions & 1 deletion lib/svg/themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe('theme count', () => {
// If this fails, either a theme was added to themes.ts without updating
// THEMES.md, or a theme was removed without updating the docs.
// Update this count when intentionally adding/removing themes.
expect(themeNames).toHaveLength(33);
expect(themeNames).toHaveLength(35);
});

it('contains all expected theme keys', () => {
Expand Down Expand Up @@ -81,6 +81,8 @@ describe('theme count', () => {
'monokai',
'midnight_ocean',
'india',
'mono',
'galaxy',
];
for (const key of expectedKeys) {
expect(themeNames).toContain(key);
Expand Down
4 changes: 4 additions & 0 deletions lib/svg/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,12 @@ export const themes: Record<string, BadgeTheme> = {
// India theme — saffron accent (#FF9933), India green negative (#138808)
india: makeTheme('0a0a0a', 'ffffff', 'FF9933', '138808'),
ayu_mirage: makeTheme('212733', 'D9D7CE', 'FFCC66', 'FF3333'),
mono: makeTheme('000000', 'ffffff', 'aaaaaa', '444444'),
galaxy: makeTheme('0b001a', 'e0d4f5', 'a200ff', 'ff00aa'),
};

export const THEME_PRESETS = ['default', 'ocean', 'sunset', 'mono', 'galaxy'] as const;

// Auto-theme pairs: the SVG switches between these two palettes
// using @media (prefers-color-scheme) so the badge adapts to the
// viewer's OS-level light/dark setting without any JavaScript.
Expand Down
Loading
Loading