Skip to content

Commit 4399408

Browse files
committed
feat: Add versus mode for multiplayer monoliths
1 parent 5790627 commit 4399408

6 files changed

Lines changed: 261 additions & 1 deletion

File tree

app/api/streak/route.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
import { NextResponse } from 'next/server';
44
import { fetchGitHubContributions, getOrgDashboardData } from '@/lib/github';
55
import { calculateStreak, calculateMonthlyStats } from '@/lib/calculate';
6-
import { generateNotFoundSVG, generateSVG, generateMonthlySVG } from '@/lib/svg/generator';
6+
import {
7+
generateNotFoundSVG,
8+
generateSVG,
9+
generateMonthlySVG,
10+
generateVersusSVG,
11+
} from '@/lib/svg/generator';
712
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '@/utils/time';
813
import type { BadgeParams } from '@/types';
914
import { themes } from '@/lib/svg/themes';
@@ -56,6 +61,7 @@ export async function GET(request: Request) {
5661
org,
5762
labels,
5863
labelColor,
64+
versus,
5965
} = parseResult.data;
6066

6167
const themeName = theme || 'dark';
@@ -122,9 +128,11 @@ export async function GET(request: Request) {
122128
org,
123129
labels,
124130
labelColor,
131+
versus,
125132
};
126133

127134
let calendar;
135+
let versusCalendar;
128136

129137
// Fetch Organization Mega-City Data OR Single User Data
130138
if (org) {
@@ -140,12 +148,24 @@ export async function GET(request: Request) {
140148
from,
141149
to,
142150
});
151+
152+
if (versus) {
153+
versusCalendar = await fetchGitHubContributions(versus, {
154+
bypassCache: refresh,
155+
from,
156+
to,
157+
});
158+
}
143159
}
144160

145161
let svg = '';
146162
if (view === 'monthly') {
147163
const stats = calculateMonthlyStats(calendar, timezone);
148164
svg = generateMonthlySVG(stats, params);
165+
} else if (versus && versusCalendar) {
166+
const stats1 = calculateStreak(calendar, timezone, undefined, grace);
167+
const stats2 = calculateStreak(versusCalendar, timezone, undefined, grace);
168+
svg = generateVersusSVG(stats1, stats2, params, calendar, versusCalendar);
149169
} else {
150170
const stats = calculateStreak(calendar, timezone, undefined, grace);
151171
svg = generateSVG(stats, params, calendar);

lib/svg/generator.ts

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,3 +889,230 @@ export function generateNotFoundSVG(
889889
</g>
890890
</svg>`;
891891
}
892+
export function generateVersusSVG(
893+
stats1: StreakStats,
894+
stats2: StreakStats,
895+
params: BadgeParams,
896+
calendar1: ContributionCalendar,
897+
calendar2: ContributionCalendar
898+
): string {
899+
if (params.autoTheme)
900+
return generateAutoThemeVersusSVG(stats1, stats2, params, calendar1, calendar2);
901+
902+
const safeUser1 = escapeXML(params.user || 'User 1');
903+
const safeUser2 = escapeXML(params.versus || 'User 2');
904+
const bg = `#${sanitizeHexColor(params.bg, '0d1117')}`;
905+
const accent = `#${sanitizeHexColor(params.accent, '00ffaa')}`;
906+
const text = `#${sanitizeHexColor(params.text, 'ffffff')}`;
907+
908+
const sanitizedFont = sanitizeFont(params.font);
909+
const predefinedFont = getFontFromMap(sanitizedFont);
910+
const isPredefinedFont = Boolean(predefinedFont);
911+
const selectedFont = isPredefinedFont
912+
? predefinedFont
913+
: sanitizedFont
914+
? `"${sanitizedFont}", sans-serif`
915+
: null;
916+
const statsFont = selectedFont || '"Space Grotesk", sans-serif';
917+
const googleFontUrlPart =
918+
sanitizedFont && !isPredefinedFont ? sanitizeGoogleFontUrl(sanitizedFont) : null;
919+
const googleFontsImport = googleFontUrlPart
920+
? `@import url('https://fonts.googleapis.com/css2?family=${googleFontUrlPart}&display=swap');`
921+
: '';
922+
923+
const sf = getSizeScale(params.size);
924+
const radius = sanitizeRadius(params.radius, 8) * sf;
925+
const labels = getLabels(params.lang);
926+
927+
const singleW = Math.round(SVG_WIDTH * sf);
928+
const W = singleW * 2;
929+
const H = Math.round(SVG_HEIGHT * sf);
930+
931+
const towerData1 = scaleTowerData(
932+
computeTowers(calendar1, params.scale, stats1.todayDate, params.mode),
933+
sf
934+
);
935+
const towerData2 = scaleTowerData(
936+
computeTowers(calendar2, params.scale, stats2.todayDate, params.mode),
937+
sf
938+
);
939+
940+
const towers1 = renderTowers(towerData1, accent, text, sf);
941+
const towers2 = renderTowers(towerData2, accent, text, sf);
942+
943+
const s = createScaler(sf);
944+
const unit = params.mode === 'loc' ? 'lines of code' : 'total contributions';
945+
946+
return `
947+
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none" role="img">
948+
<title>CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
949+
<desc>${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
950+
${renderDefs(sf)}
951+
${renderStyle(selectedFont, statsFont, googleFontsImport, text, accent, sf)}
952+
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" />
953+
954+
<!-- User 1 (Left) -->
955+
<g transform="translate(0, 0)">
956+
<g transform="translate(0, ${Math.round(20 * sf)})">${towers1}</g>
957+
${renderIsometricLabels(calendar1, params, text, sf)}
958+
${renderFooter(stats1, params, labels, safeUser1, accent, sf)}
959+
</g>
960+
961+
<!-- User 2 (Right) -->
962+
<g transform="translate(${singleW}, 0)">
963+
<g transform="translate(0, ${Math.round(20 * sf)})">${towers2}</g>
964+
${renderIsometricLabels(calendar2, params, text, sf)}
965+
${renderFooter(stats2, params, labels, safeUser2, accent, sf)}
966+
</g>
967+
968+
<!-- Divider Line -->
969+
<line x1="${singleW}" y1="${s(40)}" x2="${singleW}" y2="${H - s(40)}" stroke="${text}" stroke-opacity="0.2" stroke-width="2" stroke-dasharray="4 4" />
970+
971+
<!-- VS Badge -->
972+
<g transform="translate(${singleW}, ${H / 2})">
973+
<circle cx="0" cy="0" r="${s(24)}" fill="${bg}" stroke="${accent}" stroke-width="2" />
974+
<text x="0" y="${s(6)}" text-anchor="middle" font-family="${statsFont}" fill="${accent}" font-size="${s(16)}" font-weight="bold">VS</text>
975+
</g>
976+
</svg>`;
977+
}
978+
979+
function generateAutoThemeVersusSVG(
980+
stats1: StreakStats,
981+
stats2: StreakStats,
982+
params: BadgeParams,
983+
calendar1: ContributionCalendar,
984+
calendar2: ContributionCalendar
985+
): string {
986+
const light = AUTO_THEME_LIGHT;
987+
const dark = AUTO_THEME_DARK;
988+
const safeUser1 = escapeXML(params.user || 'User 1');
989+
const safeUser2 = escapeXML(params.versus || 'User 2');
990+
const sanitizedFont = sanitizeFont(params.font);
991+
const selectedFont = sanitizedFont
992+
? getFontFromMap(sanitizedFont) || `"${sanitizedFont}", sans-serif`
993+
: null;
994+
const statsFont = selectedFont || '"Space Grotesk", sans-serif';
995+
const sf = getSizeScale(params.size);
996+
const radius = sanitizeRadius(params.radius, 8) * sf;
997+
const labels = getLabels(params.lang);
998+
999+
const singleW = Math.round(SVG_WIDTH * sf);
1000+
const W = singleW * 2;
1001+
const H = Math.round(SVG_HEIGHT * sf);
1002+
1003+
const towerData1 = scaleTowerData(
1004+
computeTowers(calendar1, params.scale, stats1.todayDate, params.mode),
1005+
sf
1006+
);
1007+
const towerData2 = scaleTowerData(
1008+
computeTowers(calendar2, params.scale, stats2.todayDate, params.mode),
1009+
sf
1010+
);
1011+
1012+
let towers1 = '';
1013+
for (const t of towerData1) {
1014+
const fillClass = t.isGhost ? 'cp-text-fill' : 'cp-accent-fill';
1015+
const strokeColor = t.isGhost ? 'var(--cp-text)' : 'var(--cp-accent)';
1016+
const delay = ((t.row + t.col) * 0.015).toFixed(3);
1017+
towers1 += `
1018+
<g transform="translate(${t.x}, ${t.y})">
1019+
<g class="cp-tower" style="animation-delay: ${delay}s;">
1020+
${t.isTodayWithCommits ? '<animate attributeName="opacity" values="1;0.4;1" dur="1.5s" repeatCount="indefinite" />' : ''}
1021+
<title>${escapeXML(t.tooltip)}</title>
1022+
<path d="M0 ${10 - t.h} L0 10 L-16 0 L-16 ${-t.h} Z" class="${fillClass}" fill-opacity="${t.faceOpacity.left}" stroke="${strokeColor}" stroke-opacity="${t.strokeOpacity}" stroke-width="${t.strokeWidth}" />
1023+
<path d="M0 ${10 - t.h} L0 10 L16 0 L16 ${-t.h} Z" class="${fillClass}" fill-opacity="${t.faceOpacity.right}" stroke="${strokeColor}" stroke-opacity="${t.strokeOpacity}" stroke-width="${t.strokeWidth}" />
1024+
<path d="M0 ${-t.h} L16 ${10 - t.h} L0 ${20 - t.h} L-16 ${10 - t.h} Z" class="${fillClass}" fill-opacity="${t.faceOpacity.top}" stroke="${strokeColor}" stroke-opacity="${t.strokeOpacity}" stroke-width="${t.strokeWidth}" />
1025+
${t.contributionCount > 5 ? `<path d="M0 ${-t.h} L16 ${10 - t.h} L0 ${20 - t.h} L-16 ${10 - t.h} Z" fill="white" fill-opacity="0.2" />` : ''}
1026+
</g>
1027+
</g>`;
1028+
if (t.contributionCount >= 10)
1029+
towers1 += generateParticles(t.x, t.y, t.h, t.contributionCount, sf, true);
1030+
}
1031+
1032+
let towers2 = '';
1033+
for (const t of towerData2) {
1034+
const fillClass = t.isGhost ? 'cp-text-fill' : 'cp-accent-fill';
1035+
const strokeColor = t.isGhost ? 'var(--cp-text)' : 'var(--cp-accent)';
1036+
const delay = ((t.row + t.col) * 0.015).toFixed(3);
1037+
towers2 += `
1038+
<g transform="translate(${t.x}, ${t.y})">
1039+
<g class="cp-tower" style="animation-delay: ${delay}s;">
1040+
${t.isTodayWithCommits ? '<animate attributeName="opacity" values="1;0.4;1" dur="1.5s" repeatCount="indefinite" />' : ''}
1041+
<title>${escapeXML(t.tooltip)}</title>
1042+
<path d="M0 ${10 - t.h} L0 10 L-16 0 L-16 ${-t.h} Z" class="${fillClass}" fill-opacity="${t.faceOpacity.left}" stroke="${strokeColor}" stroke-opacity="${t.strokeOpacity}" stroke-width="${t.strokeWidth}" />
1043+
<path d="M0 ${10 - t.h} L0 10 L16 0 L16 ${-t.h} Z" class="${fillClass}" fill-opacity="${t.faceOpacity.right}" stroke="${strokeColor}" stroke-opacity="${t.strokeOpacity}" stroke-width="${t.strokeWidth}" />
1044+
<path d="M0 ${-t.h} L16 ${10 - t.h} L0 ${20 - t.h} L-16 ${10 - t.h} Z" class="${fillClass}" fill-opacity="${t.faceOpacity.top}" stroke="${strokeColor}" stroke-opacity="${t.strokeOpacity}" stroke-width="${t.strokeWidth}" />
1045+
${t.contributionCount > 5 ? `<path d="M0 ${-t.h} L16 ${10 - t.h} L0 ${20 - t.h} L-16 ${10 - t.h} Z" fill="white" fill-opacity="0.2" />` : ''}
1046+
</g>
1047+
</g>`;
1048+
if (t.contributionCount >= 10)
1049+
towers2 += generateParticles(t.x, t.y, t.h, t.contributionCount, sf, true);
1050+
}
1051+
1052+
const s = createScaler(sf);
1053+
const fs = (n: number): number => Math.round(n * sf * 10) / 10;
1054+
const unit = params.mode === 'loc' ? 'lines of code' : 'total contributions';
1055+
1056+
return `
1057+
<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" fill="none" role="img">
1058+
<title>CommitPulse Versus Stats: ${safeUser1} vs ${safeUser2}</title>
1059+
<desc>${safeUser1} has ${stats1.totalContributions} ${unit}. ${safeUser2} has ${stats2.totalContributions} ${unit}.</desc>
1060+
${renderDefs(sf)}
1061+
1062+
<style>
1063+
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&family=JetBrains+Mono&family=Roboto&family=Syncopate:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
1064+
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; }
1065+
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; } }
1066+
.cp-bg-fill { fill: var(--cp-bg); } .cp-text-fill { fill: var(--cp-text); color: var(--cp-text); } .cp-accent-fill { fill: var(--cp-accent); color: var(--cp-accent); }
1067+
${TOWER_ANIMATION_CSS}
1068+
.scan-line {
1069+
animation: scan-sweep var(--scan-speed, 8s) linear infinite;
1070+
transform-box: fill-box;
1071+
transform-origin: center;
1072+
}
1073+
@keyframes scan-sweep {
1074+
from { transform: translateY(var(--scan-start, ${s(20)}px)); }
1075+
to { transform: translateY(var(--scan-end, ${s(260)}px)); }
1076+
}
1077+
.title { font-family: ${selectedFont || '"Syncopate", sans-serif'}; fill: var(--cp-text); font-size: ${fs(18)}px; letter-spacing: ${fs(6)}px; font-weight: 400; opacity: 0.8; }
1078+
.stats { font-family: ${statsFont}; fill: var(--cp-text); font-size: ${fs(42)}px; font-weight: 500; letter-spacing: 0; }
1079+
.total-val { font-family: ${statsFont}; fill: var(--cp-accent); font-size: ${fs(24)}px; font-weight: 500; }
1080+
.label { font-family: "Roboto", sans-serif; fill: var(--cp-accent); font-size: ${fs(11)}px; font-weight: 400; letter-spacing: ${fs(2)}px; opacity: 0.7; }
1081+
.isometric-label { font-family: ${selectedFont || '"Roboto", sans-serif'}; font-size: ${fs(10)}px; font-weight: 400; letter-spacing: 1px; fill-opacity: 0.6; }
1082+
1083+
@media (prefers-reduced-motion: reduce) {
1084+
.heat-particles { display: none; }
1085+
.scan-line {
1086+
animation: none !important;
1087+
transition: none !important;
1088+
transform: translateY(var(--scan-start, ${s(20)}px)) !important;
1089+
}
1090+
}
1091+
</style>
1092+
1093+
<rect width="${W}" height="${H}" rx="${radius}" class="${params.hideBackground ? '' : 'cp-bg-fill'}" fill="${params.hideBackground ? 'transparent' : ''}" />
1094+
1095+
<!-- User 1 (Left) -->
1096+
<g transform="translate(0, 0)">
1097+
<g transform="translate(0, ${Math.round(20 * sf)})">${towers1}</g>
1098+
${renderIsometricLabels(calendar1, params, '', sf)}
1099+
${renderFooter(stats1, params, labels, safeUser1, '', sf)}
1100+
</g>
1101+
1102+
<!-- User 2 (Right) -->
1103+
<g transform="translate(${singleW}, 0)">
1104+
<g transform="translate(0, ${Math.round(20 * sf)})">${towers2}</g>
1105+
${renderIsometricLabels(calendar2, params, '', sf)}
1106+
${renderFooter(stats2, params, labels, safeUser2, '', sf)}
1107+
</g>
1108+
1109+
<!-- Divider Line -->
1110+
<line x1="${singleW}" y1="${s(40)}" x2="${singleW}" y2="${H - s(40)}" stroke="var(--cp-text)" stroke-opacity="0.2" stroke-width="2" stroke-dasharray="4 4" />
1111+
1112+
<!-- VS Badge -->
1113+
<g transform="translate(${singleW}, ${H / 2})">
1114+
<circle cx="0" cy="0" r="${s(24)}" class="cp-bg-fill" stroke="var(--cp-accent)" stroke-width="2" />
1115+
<text x="0" y="${s(6)}" text-anchor="middle" font-family="${statsFont}" class="cp-accent-fill" font-size="${s(16)}" font-weight="bold">VS</text>
1116+
</g>
1117+
</svg>`;
1118+
}

lib/validations.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,16 @@ export const streakParamsSchema = z.object({
160160
.string()
161161
.optional()
162162
.transform((val) => (val ? sanitizeHexColor(val, '7f8c8d') : undefined)),
163+
versus: z
164+
.string()
165+
.optional()
166+
.refine(
167+
(val) => {
168+
if (!val) return true;
169+
return /^[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9]))*$/.test(val);
170+
},
171+
{ message: 'Invalid versus GitHub username' }
172+
),
163173
});
164174

165175
export const githubParamsSchema = z.object({

pr1.md

2 KB
Binary file not shown.

pr2.md

1.95 KB
Binary file not shown.

types/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,9 @@ export interface BadgeParams {
101101
/** GitHub username whose contribution data will be fetched and rendered. Required. */
102102
user: string;
103103

104+
/** GitHub username of the opponent to compare against. */
105+
versus?: string;
106+
104107
/** Number of grace days before a streak resets (handles timezone edge cases). Defaults to 1. */
105108
grace?: number;
106109

0 commit comments

Comments
 (0)