Skip to content

Commit 6b39a6d

Browse files
authored
Merge branch 'main' into feat/historical-trend-view
2 parents da50aef + 039f78a commit 6b39a6d

32 files changed

Lines changed: 2217 additions & 313 deletions

.github/workflows/conflict-notifier.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ jobs:
8181
});
8282
}
8383
84-
// Check existing comments to enforce a 2-hour notification gap
84+
// Check existing comments to enforce a 24-hour notification gap
8585
const { data: comments } = await github.rest.issues.listComments({
8686
owner: context.repo.owner,
8787
repo: context.repo.repo,
@@ -101,8 +101,8 @@ jobs:
101101
const lastComment = conflictComments[conflictComments.length - 1];
102102
const lastCommentTime = new Date(lastComment.created_at).getTime();
103103
const nowTime = new Date().getTime();
104-
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
105-
if (nowTime - lastCommentTime >= TWO_HOURS_MS) {
104+
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
105+
if (nowTime - lastCommentTime >= TWENTY_FOUR_HOURS_MS) {
106106
shouldComment = true;
107107
}
108108
}

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ URL Parameter > Theme Default > System Fallback
189189
| `labelColor` | `hex` | No || Custom text color for the isometric labels — **without** `#` |
190190
| `versus` | `string` | No || GitHub username of an opponent to compare against in side-by-side versus mode |
191191
| `shading` | `boolean` | No | `false` | Apply intensity-based opacity shading to tower faces so lower intensity levels appear slightly dimmer |
192+
| `opacity` | `number` | No | `1.0` | Global opacity scalar for all tower fill-opacity values (0.1–1.0). `opacity=0.5` = semi-transparent ghost look. `opacity=0.8` = faded, great on light backgrounds. |
192193
| `gradient` | `boolean` | No | `false` | Opt-in to show volumetric gradients on the monolith floor |
193194

194195
### Grace Period Examples
@@ -322,6 +323,14 @@ Explore some of the built-in CommitPulse themes and quickly copy the style you l
322323

323324
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&gradient=true&shading=true)
324325

326+
<!-- Semi-transparent ghost city look -->
327+
328+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&opacity=0.5)
329+
330+
<!-- Slightly faded — perfect for light background embeds -->
331+
332+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&opacity=0.8)
333+
325334
<!-- GitHub-style Heatmap View -->
326335

327336
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&view=heatmap)

app/api/compare/route.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { GET } from './route';
3+
4+
vi.mock('@/lib/github', () => ({
5+
getFullDashboardData: vi.fn(),
6+
}));
7+
8+
import { getFullDashboardData } from '@/lib/github';
9+
10+
const makeRequest = (search: string) => new Request(`http://localhost:3000/api/compare?${search}`);
11+
12+
describe('GET /api/compare', () => {
13+
beforeEach(() => {
14+
vi.clearAllMocks();
15+
vi.mocked(getFullDashboardData).mockResolvedValue({
16+
calendar: { totalContributions: 50, weeks: [] },
17+
} as never);
18+
});
19+
20+
// ── Validation ────────────────────────────────────────────────────────────
21+
22+
it('returns 400 when user1 is missing', async () => {
23+
const res = await GET(makeRequest('user2=octocat'));
24+
expect(res.status).toBe(400);
25+
});
26+
27+
it('returns 400 when user2 is missing', async () => {
28+
const res = await GET(makeRequest('user1=octocat'));
29+
expect(res.status).toBe(400);
30+
});
31+
32+
it('returns 400 when both users are missing', async () => {
33+
const res = await GET(makeRequest(''));
34+
expect(res.status).toBe(400);
35+
});
36+
37+
it('returns 400 for invalid GitHub username format for user1', async () => {
38+
const res = await GET(makeRequest('user1=-invalid&user2=octocat'));
39+
expect(res.status).toBe(400);
40+
const data = await res.json();
41+
expect(data.details.fieldErrors.user1).toBeDefined();
42+
});
43+
44+
it('returns 400 for invalid GitHub username format for user2', async () => {
45+
const res = await GET(makeRequest('user1=octocat&user2=-invalid'));
46+
expect(res.status).toBe(400);
47+
const data = await res.json();
48+
expect(data.details.fieldErrors.user2).toBeDefined();
49+
});
50+
51+
it('returns 400 for username exceeding 39 characters', async () => {
52+
const res = await GET(makeRequest(`user1=${'a'.repeat(40)}&user2=octocat`));
53+
expect(res.status).toBe(400);
54+
});
55+
56+
it('returns 400 when comparing a user with themselves', async () => {
57+
const res = await GET(makeRequest('user1=octocat&user2=octocat'));
58+
expect(res.status).toBe(400);
59+
const data = await res.json();
60+
expect(data.details.fieldErrors.user2).toContain('Cannot compare a user with themselves.');
61+
});
62+
63+
it('returns 400 for self-comparison regardless of case', async () => {
64+
const res = await GET(makeRequest('user1=OctoCat&user2=octocat'));
65+
expect(res.status).toBe(400);
66+
});
67+
68+
// ── Success ──────────────────────────────────────────────────────────────
69+
70+
it('returns 200 with comparison data for valid users', async () => {
71+
const res = await GET(makeRequest('user1=alice&user2=bob'));
72+
expect(res.status).toBe(200);
73+
const data = await res.json();
74+
expect(data.user1).toBeDefined();
75+
expect(data.user2).toBeDefined();
76+
});
77+
78+
// ── Error handling ────────────────────────────────────────────────────────
79+
80+
it('returns 404 when user1 is not found on GitHub', async () => {
81+
vi.mocked(getFullDashboardData).mockRejectedValueOnce(new Error('Not found'));
82+
const res = await GET(makeRequest('user1=ghost123&user2=octocat'));
83+
expect(res.status).toBe(404);
84+
});
85+
86+
it('returns 404 when user2 is not found on GitHub', async () => {
87+
vi.mocked(getFullDashboardData)
88+
.mockResolvedValueOnce({ calendar: { totalContributions: 0, weeks: [] } } as never)
89+
.mockRejectedValueOnce(new Error('Not found'));
90+
const res = await GET(makeRequest('user1=octocat&user2=ghost123'));
91+
expect(res.status).toBe(404);
92+
});
93+
});

app/api/compare/route.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
import { NextResponse } from 'next/server';
22
import { getFullDashboardData } from '@/lib/github';
3+
import { compareParamsSchema } from '@/lib/validations';
34

45
export const revalidate = 3600;
56

67
export async function GET(request: Request) {
78
const { searchParams } = new URL(request.url);
8-
const user1 = searchParams.get('user1');
9-
const user2 = searchParams.get('user2');
109

11-
if (!user1 || !user2) {
10+
const parseResult = compareParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));
11+
12+
if (!parseResult.success) {
13+
const fieldErrors = parseResult.error.flatten();
1214
return NextResponse.json(
13-
{ error: 'Both user1 and user2 query parameters are required.' },
15+
{ error: 'Invalid parameters', details: fieldErrors },
1416
{ status: 400 }
1517
);
1618
}
1719

18-
if (user1.toLowerCase() === user2.toLowerCase()) {
19-
return NextResponse.json({ error: 'Cannot compare a user with themselves.' }, { status: 400 });
20-
}
20+
const { user1, user2 } = parseResult.data;
2121

2222
try {
2323
const [result1, result2] = await Promise.allSettled([

0 commit comments

Comments
 (0)