Skip to content

Commit 7b61821

Browse files
authored
Merge branch 'main' into feat/7794-visual-regression-testing
2 parents fcd1df3 + 5dc4da7 commit 7b61821

194 files changed

Lines changed: 14196 additions & 882 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,27 @@ Transform your GitHub contribution history into a cinematic 3D monolith.
139139
| `monokai` | Classic vibrant dark | `272822` | `a6e22e` | `f8f8f2` |
140140
| `midnight_ocean` | Deep navy bioluminescent | `020c1b` | `0af5ff` | `ccd6f6` |
141141
| `india` | Saffron & India green | `0a0a0a` | `FF9933` | `ffffff` |
142+
| `ocean` | Deep sea teal & navy | `0a192f` | `64ffda` | `ccd6f6` |
143+
| `sunset` | Warm dusk orange | `1a0a0a` | `ff6b35` | `ffd6c0` |
144+
| `forest` | Emerald woodland green | `0d1f0d` | `39d353` | `c8f0c8` |
145+
| `rose` | Blush pink romance | `1f0d14` | `ff6b9d` | `f0c8d4` |
146+
| `nord` | Arctic frost blue | `2e3440` | `88c0d0` | `d8dee9` |
147+
| `synthwave` | 80s retro pink glow | `0d0221` | `ff2d78` | `f8f8f2` |
148+
| `aurora_cyberpunk` | Aurora violet neon | `090B13` | `9D5CFF` | `EAF2FF` |
149+
| `catppuccin_latte` | Pastel latte light | `eff1f5` | `1e66f5` | `4c4f69` |
150+
| `solarized_light` | Warm solarized light | `fdf6e3` | `268bd2` | `586e75` |
151+
| `gruvbox_light` | Retro warm light | `fbf1c7` | `d65d0e` | `3c3836` |
152+
| `nord_light` | Arctic frost light | `eceff4` | `5e81ac` | `2e3440` |
153+
| `cyberpunk` | Neon yellow cyberpunk | `fce22a` | `ff003c` | `111111` |
154+
| `cyberpunk_neon` | Electric cyan neon | `0d0d14` | `ff0055` | `00f3ff` |
155+
| `enterprise` | Professional indigo dark | `1a1a2e` | `6366f1` | `e2e8f0` |
142156

143157
> **`auto` uses CSS `@media (prefers-color-scheme)`** inside the SVG so the badge switches between the `light` and `dark` palettes based on the viewer's OS setting — no JavaScript required. This is ideal for GitHub profile READMEs where visitors may use either mode.
144158
145159
For all URL parameters and configuration possibilities (including grace periods, custom fonts, timezone overrides, versus comparison mode, heatmap view, LOC mode, and layout dimensions), check out the **[🎨 Customization Guide & Parameters](docs/customization.md)**.
146160

161+
For advanced usage examples including custom gradient backgrounds, multi-user comparisons, organization dashboards, and custom date ranges, see the **[🚀 Advanced Usage Examples](docs/customization.md#-advanced-usage-examples)** section in the customization guide.
162+
147163
---
148164

149165
## ✨ Features

THEMES.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ https://commitpulse.vercel.app/api/streak?user=YOUR_USERNAME&theme=<slug>
4141
| monokai | `#272822` | `#f8f8f2` | `#a6e22e` |
4242
| retro-terminal | `#000000` | `#00ff41` | `#00ff41` |
4343
| midnight_ocean | `#020c1b` | `#ccd6f6` | `#0af5ff` |
44+
| ayu_mirage | `#212733` | `#D9D7CE` | `#FFCC66` |
4445

4546
---
4647

@@ -394,6 +395,16 @@ https://commitpulse.vercel.app/api/streak?user=YOUR_USERNAME&theme=<slug>
394395

395396
---
396397

398+
### Ayu Mirage
399+
400+
![ayu_mirage](https://commitpulse.vercel.app/api/streak?user=jhasourav07&theme=ayu_mirage)
401+
402+
| Parameter | Value |
403+
| --------- | ------ |
404+
| `bg` | 212733 |
405+
| `text` | D9D7CE |
406+
| `accent` | FFCC66 |
407+
397408
## Custom Theme
398409

399410
Not finding what you want? Build your own using raw color parameters - all values are hex codes **without** the `#` prefix:
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, it, expect, vi } from 'vitest';
3+
import React, { Component, ReactNode, ErrorInfo } from 'react';
4+
import DashboardLayout from './layout';
5+
6+
vi.mock('sonner', () => ({
7+
Toaster: () => <div data-testid="toaster" />,
8+
}));
9+
10+
class TestErrorBoundary extends Component<{ children: ReactNode }, { hasError: boolean }> {
11+
constructor(props: { children: ReactNode }) {
12+
super(props);
13+
this.state = { hasError: false };
14+
}
15+
static getDerivedStateFromError() {
16+
return { hasError: true };
17+
}
18+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
19+
// catch silently for tests
20+
}
21+
render() {
22+
if (this.state.hasError) {
23+
return <div>Test Fallback UI</div>;
24+
}
25+
return this.props.children;
26+
}
27+
}
28+
29+
const ThrowError = () => {
30+
throw new Error('Hydration error simulated');
31+
};
32+
33+
describe('DashboardLayout - Error Resilience', () => {
34+
it('prevents layout structural crash when child components throw errors', () => {
35+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
36+
37+
render(
38+
<TestErrorBoundary>
39+
<DashboardLayout>
40+
<ThrowError />
41+
</DashboardLayout>
42+
</TestErrorBoundary>
43+
);
44+
45+
expect(screen.getByText('Test Fallback UI')).toBeInTheDocument();
46+
47+
consoleErrorSpy.mockRestore();
48+
});
49+
50+
it('renders gracefully with fallback UI placeholders during hydration', () => {
51+
render(
52+
<DashboardLayout>
53+
<div data-testid="hydration-fallback">Loading layout skeleton...</div>
54+
</DashboardLayout>
55+
);
56+
57+
expect(screen.getByTestId('hydration-fallback')).toBeInTheDocument();
58+
expect(screen.getByTestId('toaster')).toBeInTheDocument();
59+
});
60+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { render, screen, act } from '@testing-library/react';
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3+
import DashboardLayout from './layout';
4+
import React from 'react';
5+
6+
vi.mock('sonner', () => ({
7+
Toaster: () => <div data-testid="toaster" />,
8+
}));
9+
10+
describe('DashboardLayout - mock integrations', () => {
11+
beforeEach(() => {
12+
vi.useFakeTimers();
13+
});
14+
15+
afterEach(() => {
16+
vi.clearAllTimers();
17+
vi.useRealTimers();
18+
});
19+
20+
it('renders children asynchronously resolving from a mocked service layer', async () => {
21+
const MockAsyncChild = () => {
22+
const [data, setData] = React.useState<string | null>(null);
23+
React.useEffect(() => {
24+
setTimeout(() => setData('Async Data Loaded'), 100);
25+
}, []);
26+
return <div data-testid="async-child">{data || 'Loading...'}</div>;
27+
};
28+
29+
render(
30+
<DashboardLayout>
31+
<MockAsyncChild />
32+
</DashboardLayout>
33+
);
34+
35+
expect(screen.getByTestId('async-child')).toHaveTextContent('Loading...');
36+
37+
await act(async () => {
38+
vi.advanceTimersByTime(100);
39+
});
40+
41+
expect(screen.getByTestId('async-child')).toHaveTextContent('Async Data Loaded');
42+
});
43+
44+
it('renders correctly with a local cache stub', () => {
45+
const cachedData = { user: 'Test User', cached: true };
46+
render(
47+
<DashboardLayout>
48+
<div data-testid="cache-child">{cachedData.user}</div>
49+
</DashboardLayout>
50+
);
51+
expect(screen.getByTestId('cache-child')).toHaveTextContent('Test User');
52+
expect(screen.getByTestId('toaster')).toBeInTheDocument();
53+
});
54+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3+
import DashboardLayout from './layout';
4+
import { mockTimezone, restoreTimezone } from '@/test-utils/timezone-mock';
5+
6+
vi.mock('sonner', () => ({
7+
Toaster: () => <div data-testid="toaster" />,
8+
}));
9+
10+
describe('DashboardLayout - Timezone Boundaries', () => {
11+
beforeEach(() => {
12+
vi.useFakeTimers();
13+
});
14+
15+
afterEach(() => {
16+
vi.clearAllTimers();
17+
vi.useRealTimers();
18+
restoreTimezone();
19+
});
20+
21+
it('maintains structural integrity across varying timezones', () => {
22+
const timezones = ['America/New_York', 'Asia/Kolkata', 'Asia/Tokyo'] as const;
23+
24+
for (const tz of timezones) {
25+
mockTimezone(tz);
26+
27+
const { unmount } = render(
28+
<DashboardLayout>
29+
<div>Timezone Content: {tz}</div>
30+
</DashboardLayout>
31+
);
32+
33+
expect(screen.getByText(`Timezone Content: ${tz}`)).toBeInTheDocument();
34+
expect(screen.getByRole('main')).toHaveClass('max-w-[1600px]', 'mx-auto', 'min-h-screen');
35+
36+
unmount();
37+
restoreTimezone();
38+
}
39+
});
40+
41+
it('handles leap year boundaries correctly', () => {
42+
vi.setSystemTime(new Date('2024-02-29T12:00:00Z'));
43+
44+
render(
45+
<DashboardLayout>
46+
<div>Leap Year Content</div>
47+
</DashboardLayout>
48+
);
49+
50+
expect(screen.getByText('Leap Year Content')).toBeInTheDocument();
51+
expect(screen.getByTestId('toaster')).toBeInTheDocument();
52+
});
53+
});

app/api/articles/route.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { NextResponse } from 'next/server';
2+
import { fetchLatestArticles } from '@/lib/rss';
3+
import { generateArticlesSVG } from '@/lib/svg/articles';
4+
import { getNormalizedThemeKey, themes } from '@/lib/svg/themes';
5+
import { sanitizeHexColor } from '@/lib/svg/sanitizer';
6+
7+
export async function GET(request: Request) {
8+
try {
9+
const { searchParams } = new URL(request.url);
10+
const user = searchParams.get('user');
11+
const platformParam = searchParams.get('platform');
12+
13+
if (!user) {
14+
return new NextResponse('Missing user parameter', { status: 400 });
15+
}
16+
17+
const platform = platformParam === 'hashnode' ? 'hashnode' : 'devto';
18+
19+
// Fetch RSS articles
20+
const articles = await fetchLatestArticles(platform, user);
21+
22+
// Apply theme
23+
const themeKey = getNormalizedThemeKey(searchParams.get('theme') || 'default');
24+
const theme = themes[themeKey] || themes.default;
25+
26+
// Allow custom colors
27+
const bg = searchParams.get('bg')
28+
? sanitizeHexColor(searchParams.get('bg')!, theme.bg)
29+
: theme.bg;
30+
const text = searchParams.get('text')
31+
? sanitizeHexColor(searchParams.get('text')!, theme.text)
32+
: theme.text;
33+
let accent = theme.accent;
34+
const accentParam = searchParams.get('accent');
35+
if (accentParam) {
36+
// support comma separated list, just take the first
37+
const accents = accentParam.split(',').map((c) => sanitizeHexColor(c.trim(), theme.accent));
38+
if (accents.length > 0 && accents[0]) {
39+
accent = accents[0];
40+
}
41+
}
42+
43+
const radius = searchParams.get('radius') || '4';
44+
const size = searchParams.get('size') || '1';
45+
46+
const params = {
47+
user,
48+
platform,
49+
bg,
50+
text,
51+
accent,
52+
radius,
53+
size,
54+
};
55+
56+
const svg = generateArticlesSVG(articles, params);
57+
58+
return new NextResponse(svg, {
59+
headers: {
60+
'Content-Type': 'image/svg+xml',
61+
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
62+
},
63+
});
64+
} catch (error) {
65+
console.error('Error generating articles SVG:', error);
66+
return new NextResponse('Internal Server Error', { status: 500 });
67+
}
68+
}

app/api/compare/route.empty-fallback.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe('GET /api/compare - Empty & Missing Input Fallbacks', () => {
6666
const res = await GET(makeRequest('user1=alice&user2=bob'));
6767
expect(res.status).toBe(200);
6868
expect(res.headers.get('ETag')).toBeTruthy();
69-
expect(res.headers.get('Cache-Control')).toBe('public, s-maxage=3600');
69+
expect(res.headers.get('Cache-Control')).toBe('public, s-maxage=1');
7070
});
7171

7272
it('returns 200 when If-None-Match is an empty string instead of crashing', async () => {

app/api/compare/route.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ describe('GET /api/compare', () => {
181181
const res = await GET(makeRequest('user1=alice&user2=bob'));
182182
expect(res.status).toBe(200);
183183
expect(res.headers.get('ETag')).toBeTruthy();
184-
expect(res.headers.get('Cache-Control')).toBe('public, s-maxage=3600');
184+
expect(res.headers.get('Cache-Control')).toBe('public, s-maxage=1');
185185
});
186186

187187
it('returns 304 Not Modified when If-None-Match matches weak ETag', async () => {
@@ -199,7 +199,7 @@ describe('GET /api/compare', () => {
199199
expect(res2.status).toBe(304);
200200
expect(res2.body).toBeNull();
201201
expect(res2.headers.get('ETag')).toBe(etag);
202-
expect(res2.headers.get('Cache-Control')).toBe('public, s-maxage=3600');
202+
expect(res2.headers.get('Cache-Control')).toBe('public, s-maxage=1');
203203
});
204204

205205
it('returns 304 Not Modified when If-None-Match matches strong ETag', async () => {

app/api/compare/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export async function GET(request: Request) {
103103
const etag = crypto.createHash('sha1').update(jsonPayload).digest('hex');
104104
const weakEtag = `W/"${etag}"`;
105105
const ifNoneMatch = request.headers.get('if-none-match');
106-
const cacheControl = 'public, s-maxage=3600';
106+
const cacheControl = 'public, s-maxage=1';
107107

108108
if (ifNoneMatch) {
109109
const etags = ifNoneMatch.split(',').map((e) => e.trim());

app/api/github/route.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,14 @@ describe('Standard route behavior', () => {
245245
expect(response.status).toBe(500);
246246
expect(body.error).toBe('Circular cause root');
247247
});
248+
249+
it('parses valid org parameter and passes it to getFullDashboardData', async () => {
250+
const response = await GET(makeRequest({ username: 'octocat', org: 'github' }));
251+
expect(response.status).toBe(200);
252+
expect(getFullDashboardData).toHaveBeenCalledWith('octocat', {
253+
bypassCache: false,
254+
org: 'github',
255+
signal: expect.any(AbortSignal),
256+
});
257+
});
248258
});

0 commit comments

Comments
 (0)