diff --git a/app/api/streak/png/route.type-compiler.test.ts b/app/api/streak/png/route.type-compiler.test.ts index 56475c57a..ca0dadac6 100644 --- a/app/api/streak/png/route.type-compiler.test.ts +++ b/app/api/streak/png/route.type-compiler.test.ts @@ -43,6 +43,7 @@ describe('ApiStreakPngRoute - TypeScript Compiler Validation & Schema Constraint | 'activity_graph' | 'commit_clock' | 'weekday' + | 'punchcard' >(); expectTypeOf().toEqualTypeOf<'linear' | 'log' | 'sqrt'>(); expectTypeOf().toEqualTypeOf<'small' | 'medium' | 'large'>(); diff --git a/app/api/streak/route.ts b/app/api/streak/route.ts index b7a803e6e..2f2cafd62 100644 --- a/app/api/streak/route.ts +++ b/app/api/streak/route.ts @@ -7,6 +7,7 @@ import { getOrgDashboardData, getCircuitTelemetry, fetchCommitHourDistribution, + fetchCommitPunchCard, isAbortError, } from '@/lib/github'; import { @@ -36,6 +37,7 @@ import { generateRadarSVG } from '@/lib/svg/radar'; 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'; @@ -200,7 +202,8 @@ export async function GET(request: Request) { | 'pie' | 'activity_graph' | 'commit_clock' - | 'weekday'; + | 'weekday' + | 'punchcard'; const themeKey = getNormalizedThemeKey(theme); const themeName = themeKey === 'default' && theme ? theme : themeKey; @@ -687,6 +690,11 @@ export async function GET(request: Request) { 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); diff --git a/app/customize/types.ts b/app/customize/types.ts index e4ed1750f..66eba4ed2 100644 --- a/app/customize/types.ts +++ b/app/customize/types.ts @@ -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']; diff --git a/app/customize/types.type-compiler.test.ts b/app/customize/types.type-compiler.test.ts index f53d774d0..851736d7e 100644 --- a/app/customize/types.type-compiler.test.ts +++ b/app/customize/types.type-compiler.test.ts @@ -34,7 +34,7 @@ describe('types - compiler validation', () => { it('validates constrained union types', () => { expectTypeOf().toMatchTypeOf< - 'default' | 'monthly' | 'pulse' | 'skyline' | 'languages' + 'default' | 'monthly' | 'pulse' | 'skyline' | 'languages' | 'punchcard' >(); expectTypeOf().toMatchTypeOf<'percent' | 'absolute' | 'both'>(); diff --git a/lib/github.ts b/lib/github.ts index bc6ac634e..f3f13162c 100644 --- a/lib/github.ts +++ b/lib/github.ts @@ -2899,6 +2899,140 @@ export async function fetchCommitHourDistribution( return hourCounts; } +export async function fetchCommitPunchCard( + username: string, + token?: string, + timezone: string = 'UTC' +): Promise { + // 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( items: T[], limit: number, diff --git a/lib/svg/punchcard.ts b/lib/svg/punchcard.ts new file mode 100644 index 000000000..0de89e9ae --- /dev/null +++ b/lib/svg/punchcard.ts @@ -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 += ` + + + + + `; + } + } + + 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 += `${dayNames[day]}\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 += `${hourNames[i]}\n`; + } + + const rx = params.radius ?? 8; + const titleText = params.hide_title + ? '' + : `${escapeXML(params.custom_title || 'Circadian Rhythm : ' + safeUser)}`; + const totalCommits = stats.totalContributions; + const statsPanel = params.hide_stats + ? '' + : ` + Total Commits: ${totalCommits} + `; + + return ` + CommitPulse Punch Card Heatmap for ${safeUser} + A 3D isometric grid showing ${safeUser}'s commit frequency by day of week and hour of day. + + + + ${params.hideBackground ? '' : ``} + ${titleText} + ${statsPanel} + + ${dayLabels} + ${hourLabels} + ${towers} + +`; +} diff --git a/lib/validations.ts b/lib/validations.ts index 43b02ed9b..13d8d99e0 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -497,6 +497,7 @@ const baseStreakParamsSchema = z.object({ 'activity_graph', 'commit_clock', 'weekday', + 'punchcard', ]) .catch('default') .default('default'), diff --git a/types/index.ts b/types/index.ts index 2d957c754..d59f997a7 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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';