Skip to content

Commit 33ab6b2

Browse files
authored
merge: feat(visualizations): Add GitHub Punch Card Heatmap for Enhanced Activity Analysis (#8265)
## Description This PR introduces a new Punch Card Heatmap (Circadian Rhythm View) visualization to CommitPulse, allowing developers to analyze when they are most active throughout the week. Unlike the existing Skyline view, which focuses on daily contribution history, the Punch Card view visualizes commit activity across days of the week and hours of the day, helping users identify their peak coding hours and weekly productivity patterns. Helps developers understand their coding habits and peak productivity hours. Complements existing Skyline and Heatmap visualizations with time-based insights. Provides an engaging and unique way to explore GitHub contribution activity. Encourages users to analyze and improve their coding routines. Fixes #8263 ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [x] 📐 Pillar 2 — Geometric SVG Improvement - [x] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [x] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents b0c6a37 + a714bbd commit 33ab6b2

8 files changed

Lines changed: 254 additions & 3 deletions

File tree

app/api/streak/png/route.type-compiler.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ describe('ApiStreakPngRoute - TypeScript Compiler Validation & Schema Constraint
4343
| 'activity_graph'
4444
| 'commit_clock'
4545
| 'weekday'
46+
| 'punchcard'
4647
>();
4748
expectTypeOf<StreakParams['scale']>().toEqualTypeOf<'linear' | 'log' | 'sqrt'>();
4849
expectTypeOf<StreakParams['size']>().toEqualTypeOf<'small' | 'medium' | 'large'>();

app/api/streak/route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
getOrgDashboardData,
88
getCircuitTelemetry,
99
fetchCommitHourDistribution,
10+
fetchCommitPunchCard,
1011
isAbortError,
1112
} from '@/lib/github';
1213
import {
@@ -36,6 +37,7 @@ import { generateRadarSVG } from '@/lib/svg/radar';
3637
import { generateDoughnutSVG } from '@/lib/svg/doughnut';
3738
import { generateCommitClockSVG } from '@/lib/svg/commitClock';
3839
import { generateWeekdaySVG } from '@/lib/svg/weekday';
40+
import { generatePunchcardSVG } from '@/lib/svg/punchcard';
3941
import { injectStaleWatermark } from '@/lib/svg/staleWatermark';
4042
import { optimizeSVG } from '@/lib/svg/optimizer';
4143
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time';
@@ -200,7 +202,8 @@ export async function GET(request: Request) {
200202
| 'pie'
201203
| 'activity_graph'
202204
| 'commit_clock'
203-
| 'weekday';
205+
| 'weekday'
206+
| 'punchcard';
204207
const themeKey = getNormalizedThemeKey(theme);
205208
const themeName = themeKey === 'default' && theme ? theme : themeKey;
206209

@@ -687,6 +690,11 @@ export async function GET(request: Request) {
687690
new Array(24).fill(0)
688691
);
689692
svg = generateCommitClockSVG(hourCounts, fullStats, params);
693+
} else if (normalizedView === 'punchcard') {
694+
const punchCard = await fetchCommitPunchCard(user, undefined, timezone).catch(() =>
695+
Array.from({ length: 7 }, () => new Array(24).fill(0))
696+
);
697+
svg = generatePunchcardSVG(punchCard, fullStats, params);
690698
} else if (normalizedView === 'weekday') {
691699
const normalizedCalendar = normalizeCalendarToTimezone(calendar, timezone);
692700
svg = generateWeekdaySVG(fullWeekdayStats || fullStats, params, normalizedCalendar);

app/customize/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const VIEW_MODES = [
4242
{ value: 'pulse', label: 'Heartbeat Pulse' },
4343
{ value: 'skyline', label: 'Skyline Horizon' },
4444
{ value: 'languages', label: 'Top Languages Skyline' },
45+
{ value: 'punchcard', label: 'Punch Card Heatmap' },
4546
] as const satisfies readonly { value: string; label: string }[];
4647

4748
export type ViewMode = (typeof VIEW_MODES)[number]['value'];

app/customize/types.type-compiler.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ describe('types - compiler validation', () => {
3434

3535
it('validates constrained union types', () => {
3636
expectTypeOf<ViewMode>().toMatchTypeOf<
37-
'default' | 'monthly' | 'pulse' | 'skyline' | 'languages'
37+
'default' | 'monthly' | 'pulse' | 'skyline' | 'languages' | 'punchcard'
3838
>();
3939

4040
expectTypeOf<DeltaFormat>().toMatchTypeOf<'percent' | 'absolute' | 'both'>();

lib/github.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2899,6 +2899,140 @@ export async function fetchCommitHourDistribution(
28992899
return hourCounts;
29002900
}
29012901

2902+
export async function fetchCommitPunchCard(
2903+
username: string,
2904+
token?: string,
2905+
timezone: string = 'UTC'
2906+
): Promise<number[][]> {
2907+
// 7 days (Mon-Sun), 24 hours
2908+
const punchCard: number[][] = Array.from({ length: 7 }, () => new Array(24).fill(0));
2909+
2910+
const getDayAndHourInTimezone = (isoDate: string, tz: string): { day: number; hour: number } => {
2911+
try {
2912+
const parts = new Intl.DateTimeFormat('en-US', {
2913+
timeZone: tz,
2914+
weekday: 'short',
2915+
hour: 'numeric',
2916+
hour12: false,
2917+
hourCycle: 'h23',
2918+
}).formatToParts(new Date(isoDate));
2919+
2920+
const hourPart = parts.find((p) => p.type === 'hour')?.value;
2921+
const hour = hourPart ? parseInt(hourPart, 10) % 24 : new Date(isoDate).getUTCHours();
2922+
2923+
const weekdayPart = parts.find((p) => p.type === 'weekday')?.value;
2924+
// Map to 0-6 where 0 = Monday, 6 = Sunday
2925+
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
2926+
let day = days.indexOf(weekdayPart ?? 'Mon');
2927+
if (day === -1) {
2928+
const utcDay = new Date(isoDate).getUTCDay();
2929+
day = utcDay === 0 ? 6 : utcDay - 1;
2930+
}
2931+
2932+
return { day, hour };
2933+
} catch {
2934+
const d = new Date(isoDate);
2935+
const utcDay = d.getUTCDay();
2936+
return {
2937+
day: utcDay === 0 ? 6 : utcDay - 1,
2938+
hour: d.getUTCHours(),
2939+
};
2940+
}
2941+
};
2942+
2943+
const query = `
2944+
query($login: String!) {
2945+
user(login: $login) {
2946+
contributionsCollection {
2947+
commitContributionsByRepository(maxRepositories: 5) {
2948+
repository {
2949+
name
2950+
owner { login }
2951+
}
2952+
}
2953+
}
2954+
}
2955+
}
2956+
`;
2957+
2958+
let topRepos: { owner: string; name: string }[] = [];
2959+
try {
2960+
const res = await fetchGraphQLWithRetry(
2961+
GITHUB_GRAPHQL_URL,
2962+
{
2963+
method: 'POST',
2964+
headers: getHeaders(token),
2965+
body: JSON.stringify({ query, variables: { login: username } }),
2966+
cache: 'no-store',
2967+
},
2968+
0,
2969+
undefined,
2970+
token
2971+
);
2972+
if (res.ok) {
2973+
const data = await res.json();
2974+
const repos =
2975+
data?.data?.user?.contributionsCollection?.commitContributionsByRepository ?? [];
2976+
topRepos = repos.map((r: { repository: { owner: { login: string }; name: string } }) => ({
2977+
owner: r.repository.owner.login,
2978+
name: r.repository.name,
2979+
}));
2980+
}
2981+
} catch {
2982+
// silent
2983+
}
2984+
2985+
if (topRepos.length === 0) return punchCard;
2986+
2987+
const commitQuery = `
2988+
query($owner: String!, $name: String!) {
2989+
repository(owner: $owner, name: $name) {
2990+
defaultBranchRef {
2991+
target {
2992+
... on Commit {
2993+
history(first: 100) {
2994+
nodes {
2995+
committedDate
2996+
}
2997+
}
2998+
}
2999+
}
3000+
}
3001+
}
3002+
}
3003+
`;
3004+
3005+
await runCappedConcurrency(topRepos, 3, async ({ owner, name }) => {
3006+
try {
3007+
const res = await fetchGraphQLWithRetry(
3008+
GITHUB_GRAPHQL_URL,
3009+
{
3010+
method: 'POST',
3011+
headers: getHeaders(token),
3012+
body: JSON.stringify({ query: commitQuery, variables: { owner, name } }),
3013+
cache: 'no-store',
3014+
},
3015+
0,
3016+
undefined,
3017+
token
3018+
);
3019+
if (!res.ok) return null;
3020+
const data = await res.json();
3021+
const nodes: { committedDate: string }[] =
3022+
data?.data?.repository?.defaultBranchRef?.target?.history?.nodes ?? [];
3023+
for (const node of nodes) {
3024+
const { day, hour } = getDayAndHourInTimezone(node.committedDate, timezone);
3025+
punchCard[day][hour]++;
3026+
}
3027+
} catch {
3028+
// skip
3029+
}
3030+
return null;
3031+
});
3032+
3033+
return punchCard;
3034+
}
3035+
29023036
export async function runCappedConcurrency<T, R>(
29033037
items: T[],
29043038
limit: number,

lib/svg/punchcard.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { BadgeParams, StreakStats } from '../../types';
2+
import { escapeXML, sanitizeHexColor } from './sanitizer';
3+
import { truncateUsername, getSizeScale } from './generator';
4+
5+
const WIDTH = 800;
6+
const HEIGHT = 400;
7+
const TILE_W_HALF = 12;
8+
const TILE_H_HALF = 6.5;
9+
10+
const ORIGIN_X = WIDTH / 2 - ((24 - 7) * TILE_W_HALF) / 2 - 20;
11+
const ORIGIN_Y = 160;
12+
13+
export function generatePunchcardSVG(
14+
punchCardData: number[][],
15+
stats: StreakStats,
16+
params: BadgeParams
17+
): string {
18+
const sf = getSizeScale(params.size);
19+
const safeUser = escapeXML(truncateUsername(params.user));
20+
const bg = sanitizeHexColor(params.bg, '0d1117');
21+
const text = sanitizeHexColor(Array.isArray(params.accent) ? undefined : params.text, 'c9d1d9');
22+
const accent = sanitizeHexColor(
23+
Array.isArray(params.accent) ? params.accent[params.accent.length - 1] : params.accent,
24+
'58a6ff'
25+
);
26+
27+
let maxCount = 1;
28+
for (const day of punchCardData) {
29+
for (const count of day) {
30+
if (count > maxCount) maxCount = count;
31+
}
32+
}
33+
34+
let towers = '';
35+
// Iterate in back-to-front painter's algorithm order for isometric
36+
for (let day = 0; day < 7; day++) {
37+
for (let hour = 0; hour < 24; hour++) {
38+
const count = punchCardData[day][hour];
39+
if (count === 0) continue;
40+
41+
const ratio = count / maxCount;
42+
const h = 4 + ratio * 46;
43+
44+
const px = ORIGIN_X + (hour - day) * TILE_W_HALF;
45+
const py = ORIGIN_Y + (hour + day) * TILE_H_HALF;
46+
47+
const opacity = (0.3 + 0.7 * ratio).toFixed(2);
48+
const color = `#${accent}`;
49+
50+
towers += `
51+
<g transform="translate(${px.toFixed(1)}, ${py.toFixed(1)})" opacity="${opacity}">
52+
<polygon points="0,${-h} ${TILE_W_HALF},${-h + TILE_H_HALF} 0,${-h + 2 * TILE_H_HALF} ${-TILE_W_HALF},${-h + TILE_H_HALF}" fill="${color}" opacity="0.9"/>
53+
<polygon points="${-TILE_W_HALF},${-h + TILE_H_HALF} 0,${-h + 2 * TILE_H_HALF} 0,${2 * TILE_H_HALF} ${-TILE_W_HALF},${TILE_H_HALF}" fill="${color}" opacity="0.7"/>
54+
<polygon points="0,${-h + 2 * TILE_H_HALF} ${TILE_W_HALF},${-h + TILE_H_HALF} ${TILE_W_HALF},${TILE_H_HALF} 0,${2 * TILE_H_HALF}" fill="${color}" opacity="0.5"/>
55+
</g>`;
56+
}
57+
}
58+
59+
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
60+
let dayLabels = '';
61+
for (let day = 0; day < 7; day++) {
62+
const px = ORIGIN_X + (-2 - day) * TILE_W_HALF;
63+
const py = ORIGIN_Y + (-2 + day) * TILE_H_HALF;
64+
dayLabels += `<text x="${px.toFixed(1)}" y="${py.toFixed(1)}" fill="#${text}" font-family="'Inter',sans-serif" font-size="11" opacity="0.6" text-anchor="end" font-weight="600">${dayNames[day]}</text>\n`;
65+
}
66+
67+
let hourLabels = '';
68+
const hoursToLabel = [0, 6, 12, 18];
69+
const hourNames = ['12a', '6a', '12p', '6p'];
70+
for (let i = 0; i < hoursToLabel.length; i++) {
71+
const hour = hoursToLabel[i];
72+
const px = ORIGIN_X + (hour - -1) * TILE_W_HALF;
73+
const py = ORIGIN_Y + (hour + -1) * TILE_H_HALF - 12;
74+
hourLabels += `<text x="${px.toFixed(1)}" y="${py.toFixed(1)}" fill="#${text}" font-family="'Inter',sans-serif" font-size="11" opacity="0.6" text-anchor="middle" font-weight="600">${hourNames[i]}</text>\n`;
75+
}
76+
77+
const rx = params.radius ?? 8;
78+
const titleText = params.hide_title
79+
? ''
80+
: `<text x="40" y="50" fill="#${text}" font-family="'Inter',sans-serif" font-size="18" font-weight="700">${escapeXML(params.custom_title || 'Circadian Rhythm : ' + safeUser)}</text>`;
81+
const totalCommits = stats.totalContributions;
82+
const statsPanel = params.hide_stats
83+
? ''
84+
: `
85+
<text x="40" y="74" fill="#${text}" font-family="'Inter',sans-serif" font-size="13" opacity="0.7">Total Commits: <tspan font-weight="700" fill="#${accent}">${totalCommits}</tspan></text>
86+
`;
87+
88+
return `<svg style="max-width: 100%; height: auto;" xmlns="http://www.w3.org/2000/svg" width="${Math.round(WIDTH * sf)}" height="${Math.round(HEIGHT * sf)}" viewBox="0 0 ${WIDTH} ${HEIGHT}" role="img" aria-labelledby="cp-punch-title" aria-describedby="cp-punch-desc">
89+
<title id="cp-punch-title">CommitPulse Punch Card Heatmap for ${safeUser}</title>
90+
<desc id="cp-punch-desc">A 3D isometric grid showing ${safeUser}'s commit frequency by day of week and hour of day.</desc>
91+
<defs>
92+
<style>
93+
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&amp;display=swap");
94+
</style>
95+
</defs>
96+
${params.hideBackground ? '' : `<rect width="${WIDTH}" height="${HEIGHT}" fill="#${bg}" rx="${rx}"/>`}
97+
${titleText}
98+
${statsPanel}
99+
<g transform="translate(0, 40)">
100+
${dayLabels}
101+
${hourLabels}
102+
${towers}
103+
</g>
104+
</svg>`;
105+
}

lib/validations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ const baseStreakParamsSchema = z.object({
497497
'activity_graph',
498498
'commit_clock',
499499
'weekday',
500+
'punchcard',
500501
])
501502
.catch('default')
502503
.default('default'),

types/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,8 @@ export interface BadgeParams {
298298
| 'pie'
299299
| 'activity_graph'
300300
| 'commit_clock'
301-
| 'weekday';
301+
| 'weekday'
302+
| 'punchcard';
302303

303304
/** Format for the monthly delta indicator. 'percent' shows %, 'absolute' shows raw count, 'both' shows both. */
304305
delta_format?: 'percent' | 'absolute' | 'both';

0 commit comments

Comments
 (0)