Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/api/streak/png/route.type-compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe('ApiStreakPngRoute - TypeScript Compiler Validation & Schema Constraint
| 'activity_graph'
| 'commit_clock'
| 'weekday'
| 'punchcard'
>();
expectTypeOf<StreakParams['scale']>().toEqualTypeOf<'linear' | 'log' | 'sqrt'>();
expectTypeOf<StreakParams['size']>().toEqualTypeOf<'small' | 'medium' | 'large'>();
Expand Down
10 changes: 9 additions & 1 deletion app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,17 @@
getOrgDashboardData,
getCircuitTelemetry,
fetchCommitHourDistribution,
fetchCommitPunchCard,
isAbortError,
} from '@/lib/github';
import {
calculateStreak,
calculateMonthlyStats,
aggregateCalendars,
convertLocalToUtc,

Check warning on line 17 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'convertLocalToUtc' is defined but never used
chunkDaysIntoWeeks,
normalizeCalendarToTimezone,
isLeapYear,

Check warning on line 20 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'isLeapYear' is defined but never used
daysInYear,
} from '@/lib/calculate';
import {
Expand All @@ -36,6 +37,7 @@
import { generateDoughnutSVG } from '@/lib/svg/doughnut';
import { generateCommitClockSVG } from '@/lib/svg/commitClock';
import { generateWeekdaySVG } from '@/lib/svg/weekday';
import { generatePunchcardSVG } from '@/lib/svg/punchcard';
import { injectStaleWatermark } from '@/lib/svg/staleWatermark';
import { optimizeSVG } from '@/lib/svg/optimizer';
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time';
Expand All @@ -57,7 +59,7 @@

import { validationCache as _vc, normalizeCacheKey, cachedValidation } from './validation-cache';
// Re-alias so existing usages in this file continue to work.
const validationCache = _vc;

Check warning on line 62 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'validationCache' is assigned a value but never used

const SVG_CSP_HEADER =
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";
Expand Down Expand Up @@ -200,7 +202,8 @@
| 'pie'
| 'activity_graph'
| 'commit_clock'
| 'weekday';
| 'weekday'
| 'punchcard';
const themeKey = getNormalizedThemeKey(theme);
const themeName = themeKey === 'default' && theme ? theme : themeKey;

Expand Down Expand Up @@ -687,6 +690,11 @@
new Array(24).fill(0)
);
svg = generateCommitClockSVG(hourCounts, fullStats, params);
} else if (normalizedView === 'punchcard') {
const punchCard = await fetchCommitPunchCard(user, undefined, timezone).catch(() =>
Array.from({ length: 7 }, () => new Array(24).fill(0))
);
svg = generatePunchcardSVG(punchCard, fullStats, params);
} else if (normalizedView === 'weekday') {
const normalizedCalendar = normalizeCalendarToTimezone(calendar, timezone);
svg = generateWeekdaySVG(fullWeekdayStats || fullStats, params, normalizedCalendar);
Expand Down
1 change: 1 addition & 0 deletions app/customize/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const VIEW_MODES = [
{ value: 'pulse', label: 'Heartbeat Pulse' },
{ value: 'skyline', label: 'Skyline Horizon' },
{ value: 'languages', label: 'Top Languages Skyline' },
{ value: 'punchcard', label: 'Punch Card Heatmap' },
] as const satisfies readonly { value: string; label: string }[];

export type ViewMode = (typeof VIEW_MODES)[number]['value'];
Expand Down
2 changes: 1 addition & 1 deletion app/customize/types.type-compiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('types - compiler validation', () => {

it('validates constrained union types', () => {
expectTypeOf<ViewMode>().toMatchTypeOf<
'default' | 'monthly' | 'pulse' | 'skyline' | 'languages'
'default' | 'monthly' | 'pulse' | 'skyline' | 'languages' | 'punchcard'
>();

expectTypeOf<DeltaFormat>().toMatchTypeOf<'percent' | 'absolute' | 'both'>();
Expand Down
134 changes: 134 additions & 0 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2899,6 +2899,140 @@ export async function fetchCommitHourDistribution(
return hourCounts;
}

export async function fetchCommitPunchCard(
username: string,
token?: string,
timezone: string = 'UTC'
): Promise<number[][]> {
// 7 days (Mon-Sun), 24 hours
const punchCard: number[][] = Array.from({ length: 7 }, () => new Array(24).fill(0));

const getDayAndHourInTimezone = (isoDate: string, tz: string): { day: number; hour: number } => {
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
weekday: 'short',
hour: 'numeric',
hour12: false,
hourCycle: 'h23',
}).formatToParts(new Date(isoDate));

const hourPart = parts.find((p) => p.type === 'hour')?.value;
const hour = hourPart ? parseInt(hourPart, 10) % 24 : new Date(isoDate).getUTCHours();

const weekdayPart = parts.find((p) => p.type === 'weekday')?.value;
// Map to 0-6 where 0 = Monday, 6 = Sunday
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
let day = days.indexOf(weekdayPart ?? 'Mon');
if (day === -1) {
const utcDay = new Date(isoDate).getUTCDay();
day = utcDay === 0 ? 6 : utcDay - 1;
}

return { day, hour };
} catch {
const d = new Date(isoDate);
const utcDay = d.getUTCDay();
return {
day: utcDay === 0 ? 6 : utcDay - 1,
hour: d.getUTCHours(),
};
}
};

const query = `
query($login: String!) {
user(login: $login) {
contributionsCollection {
commitContributionsByRepository(maxRepositories: 5) {
repository {
name
owner { login }
}
}
}
}
}
`;

let topRepos: { owner: string; name: string }[] = [];
try {
const res = await fetchGraphQLWithRetry(
GITHUB_GRAPHQL_URL,
{
method: 'POST',
headers: getHeaders(token),
body: JSON.stringify({ query, variables: { login: username } }),
cache: 'no-store',
},
0,
undefined,
token
);
if (res.ok) {
const data = await res.json();
const repos =
data?.data?.user?.contributionsCollection?.commitContributionsByRepository ?? [];
topRepos = repos.map((r: { repository: { owner: { login: string }; name: string } }) => ({
owner: r.repository.owner.login,
name: r.repository.name,
}));
}
} catch {
// silent
}

if (topRepos.length === 0) return punchCard;

const commitQuery = `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
defaultBranchRef {
target {
... on Commit {
history(first: 100) {
nodes {
committedDate
}
}
}
}
}
}
}
`;

await runCappedConcurrency(topRepos, 3, async ({ owner, name }) => {
try {
const res = await fetchGraphQLWithRetry(
GITHUB_GRAPHQL_URL,
{
method: 'POST',
headers: getHeaders(token),
body: JSON.stringify({ query: commitQuery, variables: { owner, name } }),
cache: 'no-store',
},
0,
undefined,
token
);
if (!res.ok) return null;
const data = await res.json();
const nodes: { committedDate: string }[] =
data?.data?.repository?.defaultBranchRef?.target?.history?.nodes ?? [];
for (const node of nodes) {
const { day, hour } = getDayAndHourInTimezone(node.committedDate, timezone);
punchCard[day][hour]++;
}
} catch {
// skip
}
return null;
});

return punchCard;
}

export async function runCappedConcurrency<T, R>(
items: T[],
limit: number,
Expand Down
105 changes: 105 additions & 0 deletions lib/svg/punchcard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { BadgeParams, StreakStats } from '../../types';
import { escapeXML, sanitizeHexColor } from './sanitizer';
import { truncateUsername, getSizeScale } from './generator';

const WIDTH = 800;
const HEIGHT = 400;
const TILE_W_HALF = 12;
const TILE_H_HALF = 6.5;

const ORIGIN_X = WIDTH / 2 - ((24 - 7) * TILE_W_HALF) / 2 - 20;
const ORIGIN_Y = 160;

export function generatePunchcardSVG(
punchCardData: number[][],
stats: StreakStats,
params: BadgeParams
): string {
const sf = getSizeScale(params.size);
const safeUser = escapeXML(truncateUsername(params.user));
const bg = sanitizeHexColor(params.bg, '0d1117');
const text = sanitizeHexColor(Array.isArray(params.accent) ? undefined : params.text, 'c9d1d9');
const accent = sanitizeHexColor(
Array.isArray(params.accent) ? params.accent[params.accent.length - 1] : params.accent,
'58a6ff'
);

let maxCount = 1;
for (const day of punchCardData) {
for (const count of day) {
if (count > maxCount) maxCount = count;
}
}

let towers = '';
// Iterate in back-to-front painter's algorithm order for isometric
for (let day = 0; day < 7; day++) {
for (let hour = 0; hour < 24; hour++) {
const count = punchCardData[day][hour];
if (count === 0) continue;

const ratio = count / maxCount;
const h = 4 + ratio * 46;

const px = ORIGIN_X + (hour - day) * TILE_W_HALF;
const py = ORIGIN_Y + (hour + day) * TILE_H_HALF;

const opacity = (0.3 + 0.7 * ratio).toFixed(2);
const color = `#${accent}`;

towers += `
<g transform="translate(${px.toFixed(1)}, ${py.toFixed(1)})" opacity="${opacity}">
<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"/>
<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"/>
<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"/>
</g>`;
}
}

const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
let dayLabels = '';
for (let day = 0; day < 7; day++) {
const px = ORIGIN_X + (-2 - day) * TILE_W_HALF;
const py = ORIGIN_Y + (-2 + day) * TILE_H_HALF;
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`;
}

let hourLabels = '';
const hoursToLabel = [0, 6, 12, 18];
const hourNames = ['12a', '6a', '12p', '6p'];
for (let i = 0; i < hoursToLabel.length; i++) {
const hour = hoursToLabel[i];
const px = ORIGIN_X + (hour - -1) * TILE_W_HALF;
const py = ORIGIN_Y + (hour + -1) * TILE_H_HALF - 12;
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`;
}

const rx = params.radius ?? 8;
const titleText = params.hide_title
? ''
: `<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>`;
const totalCommits = stats.totalContributions;
const statsPanel = params.hide_stats
? ''
: `
<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>
`;

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">
<title id="cp-punch-title">CommitPulse Punch Card Heatmap for ${safeUser}</title>
<desc id="cp-punch-desc">A 3D isometric grid showing ${safeUser}'s commit frequency by day of week and hour of day.</desc>
<defs>
<style>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&amp;display=swap");
</style>
</defs>
${params.hideBackground ? '' : `<rect width="${WIDTH}" height="${HEIGHT}" fill="#${bg}" rx="${rx}"/>`}
${titleText}
${statsPanel}
<g transform="translate(0, 40)">
${dayLabels}
${hourLabels}
${towers}
</g>
</svg>`;
}
1 change: 1 addition & 0 deletions lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ const baseStreakParamsSchema = z.object({
'activity_graph',
'commit_clock',
'weekday',
'punchcard',
])
.catch('default')
.default('default'),
Expand Down
3 changes: 2 additions & 1 deletion types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,8 @@ export interface BadgeParams {
| 'pie'
| 'activity_graph'
| 'commit_clock'
| 'weekday';
| 'weekday'
| 'punchcard';

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