Skip to content

Commit d0d9790

Browse files
feat: add 2D GitHub-style heatmap view
1 parent 08d6831 commit d0d9790

8 files changed

Lines changed: 517 additions & 31 deletions

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ URL Parameter > Theme Default > System Fallback
176176
| `hide_stats` | `boolean` | No | `false` | Hides the bottom row displaying Current Streak, Annual Sync Total, and Peak Streak stats when set to `true` or `1`. |
177177
| `tz` | `string` | No | Omitted = UTC | IANA timezone (e.g. `Asia/Kolkata`, `America/New_York`) — aligns "today" with the user local midnight. Note: `?tz=UTC` is valid but cached separately from omitting `tz`. |
178178
| `lang` | `string` | No | `en` | Language code for labels (`en`, `es`, `hi`, `fr`, `pt`, `ko`, `ja`, `de`, `zh`) |
179-
| `view` | `string` | No | `default` | Rendering mode: `default` (3D Monolith) or `monthly` (Compact monthly stats) |
179+
| `view` | `string` | No | `default` | Rendering mode: `default` (3D Monolith), `monthly` (Compact monthly stats), or `heatmap` (flat 2D contribution heatmap) |
180180
| `delta_format` | `string` | No | `percent` | Format for month-over-month delta in monthly view: `percent` (e.g. +12%), `absolute` (e.g. +15 commits), or `both` |
181181
| `width` | `number` | No | `300` | Custom width for the SVG canvas (currently only applies to `view=monthly`) |
182182
| `height` | `number` | No | `120` | Custom height for the SVG canvas (currently only applies to `view=monthly`) |
@@ -319,6 +319,14 @@ Explore some of the built-in CommitPulse themes and quickly copy the style you l
319319
<!-- Gradient + shading for extra depth -->
320320

321321
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&gradient=true&shading=true)
322+
323+
<!-- GitHub-style Heatmap View -->
324+
325+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&view=heatmap)
326+
327+
<!-- Heatmap with Neon theme -->
328+
329+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&view=heatmap&theme=neon)
322330
```
323331

324332
---

app/api/streak/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
generateSVG,
1010
generateMonthlySVG,
1111
generateVersusSVG,
12+
generateHeatmapSVG,
1213
} from '@/lib/svg/generator';
1314
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time';
1415
import type { BadgeParams } from '@/types';
@@ -197,6 +198,9 @@ export async function GET(request: Request) {
197198
getMonthlyReferenceDate(year, timezone)
198199
);
199200
svg = generateMonthlySVG(stats, params);
201+
} else if (view === 'heatmap') {
202+
const stats = calculateStreak(calendar, timezone, undefined, grace);
203+
svg = generateHeatmapSVG(stats, params, calendar);
200204
} else if (versus && versusCalendar) {
201205
const stats1 = calculateStreak(calendar, timezone, undefined, grace);
202206
const stats2 = calculateStreak(versusCalendar, timezone, undefined, grace);

app/components/HeroSection.test.tsx

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { render, screen } from '@testing-library/react';
2+
import '@testing-library/jest-dom/vitest';
23
import { describe, expect, it, vi } from 'vitest';
34
import { HeroSection } from './HeroSection';
45

@@ -12,40 +13,52 @@ vi.mock('framer-motion', () => ({
1213
}));
1314

1415
describe('HeroSection', () => {
15-
it('renders the h1 heading', () => {
16+
it('renders the hero heading and content', () => {
1617
render(<HeroSection />);
1718

18-
const heading = screen.getByRole('heading', {
19-
level: 1,
20-
});
21-
22-
expect(heading).toBeDefined();
23-
});
24-
25-
it("heading contains 'Elevate Your'", () => {
26-
render(<HeroSection />);
27-
28-
expect(screen.getByText(/Elevate Your/i)).toBeDefined();
29-
});
30-
31-
it("heading contains 'Contribution Story'", () => {
32-
render(<HeroSection />);
33-
34-
expect(screen.getByText(/Contribution Story/i)).toBeDefined();
19+
expect(
20+
screen.getByRole('heading', {
21+
level: 1,
22+
name: /elevate your contribution story/i,
23+
})
24+
).toBeInTheDocument();
25+
expect(screen.getByText(/isometric/i).textContent).toMatch(/isometric/i);
3526
});
27+
});
3628

37-
it('renders the descriptive paragraph', () => {
38-
render(<HeroSection />);
29+
describe('HeroSection responsive breakpoints', () => {
30+
const renderAtViewport = (width: number) => {
31+
window.innerWidth = width;
32+
window.dispatchEvent(new Event('resize'));
3933

40-
const paragraph = screen.getByText(/isometric/i);
34+
return render(<HeroSection />);
35+
};
4136

42-
expect(paragraph).toBeDefined();
43-
});
37+
it.each([
38+
{ width: 375, label: 'mobile' },
39+
{ width: 768, label: 'tablet' },
40+
{ width: 1280, label: 'desktop' },
41+
])('renders full typography and high contrast background values at $label width', ({ width }) => {
42+
const { container } = renderAtViewport(width);
4443

45-
it("paragraph mentions 'isometric'", () => {
46-
render(<HeroSection />);
44+
const hero = container.firstElementChild;
45+
const heading = screen.getByRole('heading', {
46+
level: 1,
47+
name: /elevate your contribution story/i,
48+
});
4749

48-
expect(screen.getByText(/isometric/i).textContent).toMatch(/isometric/i);
50+
expect(hero?.className).toContain('bg-[radial-gradient');
51+
expect(heading.tagName).toBe('H1');
52+
expect(heading.className).toContain('text-5xl');
53+
expect(heading.className).toContain('md:text-8xl');
54+
expect(heading.className).toContain('from-green-500');
55+
expect(heading.className).toContain('to-purple-600');
56+
expect(screen.getByText(/generate high-fidelity, 3d isometric monoliths/i).className).toContain(
57+
'text-gray-600'
58+
);
59+
expect(screen.getByPlaceholderText(/enter github username/i)).toBeInTheDocument();
60+
expect(screen.getByRole('button', { name: /copy link/i })).toBeInTheDocument();
61+
expect(screen.getByRole('button', { name: /watch dashboard/i })).toBeInTheDocument();
4962
});
5063
});
5164

lib/calculate.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,31 @@ describe('calculateStreak', () => {
160160
expect(result.totalContributions).toBe(13);
161161
});
162162

163+
it('counts weekday-only commits from Monday through Friday without spanning weekend gaps', () => {
164+
// 2024-01-01 is a Monday. Commits happen only on weekdays across two work weeks.
165+
const calendar = buildCalendar([
166+
1,
167+
1,
168+
1,
169+
1,
170+
1,
171+
0,
172+
0, // Mon-Fri active, Sat-Sun inactive
173+
1,
174+
1,
175+
1,
176+
1,
177+
1, // Mon-Fri active again, ending on Friday
178+
]);
179+
180+
const result = calculateStreak(calendar, 'UTC', new Date('2024-01-12T12:00:00Z'));
181+
182+
expect(result.currentStreak).toBe(5);
183+
expect(result.longestStreak).toBe(5);
184+
expect(result.totalContributions).toBe(10);
185+
expect(result.todayDate).toBe('2024-01-12');
186+
});
187+
163188
it('keeps the streak alive via the grace period when only yesterday has contributions', () => {
164189
// Today is 0, but yesterday is 1 — the grace period treats the streak as still active.
165190
const calendar = buildCalendar([

lib/svg/generator.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -965,7 +965,6 @@ describe('generateMonthlySVG', () => {
965965
expect(svg).toContain('width="400"');
966966
expect(svg).toContain('height="200"');
967967
});
968-
969968
it('includes prefers-reduced-motion media query in static monthly SVG output', () => {
970969
const svg = generateMonthlySVG(mockMonthlyStats, {
971970
user: 'octocat',
@@ -987,6 +986,17 @@ describe('generateMonthlySVG', () => {
987986
expect(svg).toContain('transition: none !important');
988987
});
989988

989+
it('includes CSS variables in auto-theme monthly SVG', () => {
990+
const svg = generateMonthlySVG(mockMonthlyStats, {
991+
user: 'octocat',
992+
autoTheme: true,
993+
} as unknown as BadgeParams);
994+
995+
expect(svg).toContain('--cp-bg');
996+
expect(svg).toContain('--cp-accent');
997+
expect(svg).toContain('prefers-color-scheme: dark');
998+
});
999+
9901000
it('supports dynamic Google Fonts for non-predefined fonts in monthly auto-theme mode', () => {
9911001
const svg = generateMonthlySVG(mockMonthlyStats, {
9921002
user: 'octocat',

0 commit comments

Comments
 (0)