Skip to content

Commit fc2eefb

Browse files
punchcard feature added
1 parent ded12c8 commit fc2eefb

7 files changed

Lines changed: 253 additions & 2 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'];

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)