Skip to content

Commit 19d8569

Browse files
authored
Merge branch 'main' into test/full-year-zero-contributions-edge-case
2 parents 0641ee8 + aae09c2 commit 19d8569

17 files changed

Lines changed: 431 additions & 191 deletions

.env.local.example

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@
2020
# Generate new token (classic)
2121
# Required scope: read:user
2222
# ------------------------------------------------------------
23-
GITHUB_TOKEN=ghp_your_personal_access_token_here
24-
23+
GITHUB_TOKEN=
2524

2625
# ------------------------------------------------------------
2726
# REQUIRED — Public site URL

app/api/stats/route.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,22 @@ export async function GET(request: Request) {
2424
const parseResult = statsParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));
2525

2626
if (!parseResult.success) {
27+
const details = parseResult.error.flatten();
28+
29+
if (details.fieldErrors.tz?.length) {
30+
return NextResponse.json(
31+
{
32+
error: 'Invalid "tz" parameter',
33+
details,
34+
},
35+
{ status: 400 }
36+
);
37+
}
38+
2739
return NextResponse.json(
2840
{
2941
error: 'Invalid parameters',
30-
details: parseResult.error.flatten(),
42+
details,
3143
},
3244
{ status: 400 }
3345
);

app/api/streak/route.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,22 @@ describe('GET /api/streak', () => {
837837
expect(fieldError).toContain('light');
838838
expect(fieldError).toContain('neon');
839839
});
840+
841+
it('accepts capitalized or mixed-case theme parameter like "NEON" and maps it correctly', async () => {
842+
const response = await GET(makeRequest({ user: 'octocat', theme: 'NEON' }));
843+
const body = await response.text();
844+
845+
expect(response.status).toBe(200);
846+
expect(body).toContain('ff00ff'); // Neon theme accent is #ff00ff — confirms the neon theme is applied
847+
});
848+
849+
it('accepts mixed-case "random" or "auto" and resolves correctly', async () => {
850+
const response = await GET(makeRequest({ user: 'octocat', theme: 'aUtO' }));
851+
const body = await response.text();
852+
853+
expect(response.status).toBe(200);
854+
expect(body).toContain('prefers-color-scheme: dark');
855+
});
840856
});
841857

842858
describe('custom colour overrides', () => {

app/api/streak/route.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export async function GET(request: Request) {
9999
glow,
100100
format,
101101
} = parseResult.data;
102-
102+
const normalizedView = view as 'default' | 'monthly' | 'heatmap' | 'pulse';
103103
const themeName = theme || 'dark';
104104
const from = customFrom
105105
? new Date(customFrom).toISOString()
@@ -161,7 +161,7 @@ export async function GET(request: Request) {
161161
hideBackground: hide_background,
162162
hide_stats,
163163
lang,
164-
view,
164+
view: normalizedView,
165165
delta_format,
166166
width,
167167
height,
@@ -278,17 +278,17 @@ export async function GET(request: Request) {
278278

279279
// ─── SVG output mode (default) ──────────────────────────────────────────
280280
let svg = '';
281-
if (view === 'monthly') {
281+
if (normalizedView === 'monthly') {
282282
const stats = calculateMonthlyStats(
283283
calendar,
284284
timezone,
285285
getMonthlyReferenceDate(year, timezone)
286286
);
287287
svg = generateMonthlySVG(stats, params);
288-
} else if (view === 'heatmap') {
288+
} else if (normalizedView === 'heatmap') {
289289
const stats = calculateStreak(calendar, timezone, undefined, grace);
290290
svg = generateHeatmapSVG(stats, params, calendar);
291-
} else if (view === 'pulse') {
291+
} else if (normalizedView === 'pulse') {
292292
// We still use calculateStreak here to efficiently parse totalContributions for the stat display,
293293
// even though the sparkline generator will extract its own daily 30-day timeline below.
294294
const stats = calculateStreak(calendar, timezone, undefined, grace);

app/components/Footer.test.tsx

Lines changed: 51 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,82 @@
1-
import { describe, it, expect } from 'vitest';
1+
import '@testing-library/jest-dom/vitest';
22
import { render, screen } from '@testing-library/react';
3+
import { describe, expect, it } from 'vitest';
34
import { Footer } from './Footer';
45

56
describe('Footer Component', () => {
67
it('renders community text', () => {
78
render(<Footer />);
89

9-
const text = screen.getByText(/Designed for the elite builder community/i);
10-
11-
expect(text).toBeTruthy();
10+
expect(screen.getByText(/Designed for the elite builder community/i)).toBeInTheDocument();
1211
});
1312

14-
it('renders Documentation link', () => {
13+
it('renders Documentation link with the correct destination', () => {
1514
render(<Footer />);
1615

17-
const docLink = screen.getByText(/Documentation/i);
18-
19-
expect(docLink).toBeTruthy();
16+
const documentationLink = screen.getByRole('link', {
17+
name: /Documentation/i,
18+
});
2019

21-
expect(docLink.closest('a')?.getAttribute('href')).toBe(
20+
expect(documentationLink).toHaveAttribute(
21+
'href',
2222
'https://github.com/JhaSourav07/commitpulse/blob/main/README.md'
2323
);
2424
});
2525

26-
it('opens documentation in new tab', () => {
26+
it('opens Documentation link in a new tab securely', () => {
2727
render(<Footer />);
2828

29-
const docLink = screen.getByText(/Documentation/i);
29+
const documentationLink = screen.getByRole('link', {
30+
name: /Documentation/i,
31+
});
3032

31-
expect(docLink.closest('a')?.getAttribute('target')).toBe('_blank');
33+
expect(documentationLink).toHaveAttribute('target', '_blank');
34+
expect(documentationLink).toHaveAttribute('rel', expect.stringContaining('noopener'));
35+
expect(documentationLink).toHaveAttribute('rel', expect.stringContaining('noreferrer'));
3236
});
3337

3438
it('renders Contributors link', () => {
3539
render(<Footer />);
3640

37-
const contributorsLink = screen.getByText(/Contributors/i);
38-
39-
expect(contributorsLink).toBeTruthy();
41+
expect(
42+
screen.getByRole('link', {
43+
name: /Contributors/i,
44+
})
45+
).toBeInTheDocument();
4046
});
4147

42-
describe('responsive links and footer tag', () => {
43-
it.each([
44-
['mobile', 375],
45-
['desktop', 1280],
46-
])('renders documented links and the footer tag at the %s breakpoint', (_breakpoint, width) => {
47-
window.innerWidth = width;
48-
49-
const { container } = render(<Footer />);
50-
51-
const layout = container.querySelector('.flex.flex-col.md\\:flex-row');
52-
const contributorsLink = screen.getByRole('link', { name: 'Contributors' });
53-
const documentationLink = screen.getByRole('link', { name: 'Documentation' });
54-
const creatorLink = screen.getByRole('link', { name: 'Creator' });
55-
56-
expect(layout).toBeTruthy();
57-
expect(contributorsLink.getAttribute('href')).toBe('/contributors');
58-
expect(documentationLink.getAttribute('href')).toBe(
59-
'https://github.com/JhaSourav07/commitpulse/blob/main/README.md'
60-
);
61-
expect(creatorLink.getAttribute('href')).toBe('https://github.com/jhasourav07');
62-
expect(screen.getByText(/2026 CommitPulse\. All rights reserved\./i)).toBeTruthy();
63-
});
64-
});
65-
});
66-
it('renders CommitPulse heading', () => {
67-
render(<Footer />);
68-
69-
const heading = screen.getByText('CommitPulse');
70-
71-
expect(heading).toBeTruthy();
72-
});
73-
it('renders Creator link', () => {
74-
render(<Footer />);
48+
it('exposes the footer as a semantic contentinfo landmark for screen readers', () => {
49+
render(<Footer />);
7550

76-
const creatorLink = screen.getByText(/Creator/i);
51+
// A semantic <footer> is exposed to assistive technology as the contentinfo landmark.
52+
const footer = screen.getByRole('contentinfo');
7753

78-
expect(creatorLink).toBeTruthy();
79-
});
80-
it('creator link points to GitHub profile', () => {
81-
render(<Footer />);
82-
83-
const creatorLink = screen.getByText(/Creator/i);
54+
expect(footer).toBeInTheDocument();
55+
expect(footer.tagName).toBe('FOOTER');
56+
});
8457

85-
expect(creatorLink.closest('a')?.getAttribute('href')).toBe('https://github.com/jhasourav07');
86-
});
87-
it('renders copyright text', () => {
88-
render(<Footer />);
58+
it('includes responsive layout classes for mobile and desktop breakpoints', () => {
59+
render(<Footer />);
8960

90-
expect(screen.getByText(/© 2026 CommitPulse. All rights reserved./i)).toBeTruthy();
61+
const footer = screen.getByRole('contentinfo');
62+
63+
// JSDOM cannot calculate Tailwind media queries, so this verifies the actual
64+
// responsive utility classes that switch the footer layout at the md breakpoint.
65+
const responsiveLayout = footer.querySelector('.flex.flex-col.md\\:flex-row.md\\:items-start');
66+
67+
expect(responsiveLayout).toBeInTheDocument();
68+
expect(responsiveLayout).toHaveClass(
69+
'mx-auto',
70+
'flex',
71+
'max-w-6xl',
72+
'flex-col',
73+
'items-center',
74+
'justify-between',
75+
'gap-6',
76+
'text-sm',
77+
'md:flex-row',
78+
'md:items-start',
79+
'md:gap-0'
80+
);
81+
});
9182
});

app/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Footer } from '@/app/components/Footer';
1515

1616
import { FeatureCard, FeatureCardsSection } from '@/components/FeatureCards';
1717
import { DiscordButton } from '@/components/DiscordButton';
18+
1819
import { WallOfLove } from '@/components/WallOfLove';
1920

2021
const Icons = {

components/WallOfLove.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useRef, useState, useEffect, type ReactNode } from 'react';
3+
import { useRef, useState, useEffect } from 'react';
44
import gsap from 'gsap';
55
import { ScrollTrigger } from 'gsap/ScrollTrigger';
66
import { motion } from 'framer-motion';

components/dashboard/Heatmap.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,16 @@ export default function Heatmap({
128128
height: (7 * (CELL + GAP) - GAP) * scale,
129129
}}
130130
>
131-
<div className="flex" style={{ gap: GAP }}>
131+
<div className="flex" role="grid" style={{ gap: GAP }}>
132132
{weeks.map((week, wIndex) => (
133-
<div key={wIndex} className="flex flex-col" style={{ gap: GAP }}>
133+
<div key={wIndex} className="flex flex-col" role="row" style={{ gap: GAP }}>
134134
{week.map((day, dIndex) => {
135135
const originalIndex = wIndex * 7 + dIndex;
136136

137137
return (
138138
<div
139139
key={day.date}
140+
role="gridcell"
140141
aria-label={`${getContributionLabel(
141142
day.count
142143
)} on ${formatTooltipDate(day.date)}`}

components/dashboard/StatsCard.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export default function StatsCard({
6565
{showUTCDisclaimer && (
6666
<div className="mt-3 space-y-1">
6767
<p className="text-[11px] text-[#71717A] leading-relaxed">
68-
Streaks are calculated in UTC and may differ from your local timezone.
68+
ℹ Streaks are calculated in UTC and may differ from your local timezone.
6969
</p>
7070

7171
{utcDate && <p className="text-[10px] text-[#52525B]">UTC Date: {utcDate}</p>}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { render } from '@testing-library/react';
4+
import StatsCard from './StatsCard';
5+
6+
vi.mock('framer-motion', () => ({
7+
motion: {
8+
div: ({ children, className, ...props }: any) => {
9+
delete props.initial;
10+
delete props.whileInView;
11+
delete props.viewport;
12+
delete props.whileHover;
13+
delete props.transition;
14+
15+
return (
16+
<div className={className} {...props}>
17+
{children}
18+
</div>
19+
);
20+
},
21+
},
22+
}));
23+
24+
describe('StatsCard UTC disclaimer', () => {
25+
it('renders a clean UTC disclaimer prefix and preserves the UTC date text', () => {
26+
const { container } = render(
27+
<StatsCard
28+
title="Current Streak"
29+
value="12"
30+
description="Consecutive contribution days"
31+
icon="Flame"
32+
showUTCDisclaimer
33+
utcDate="2026-06-01"
34+
/>
35+
);
36+
37+
expect(container.textContent).toContain('ℹ');
38+
expect(container.textContent).not.toContain('\u00E2\u20AC\u2039');
39+
expect(container.textContent).toContain('UTC Date: 2026-06-01');
40+
});
41+
});

0 commit comments

Comments
 (0)