Skip to content
Open
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
2 changes: 2 additions & 0 deletions app/api/wrapped/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
width,
height,
tz,
lang,

Check failure on line 61 in app/api/wrapped/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Property 'lang' does not exist on type '{ user: string; theme: string; bg: HexColor | undefined; text: HexColor | undefined; accent: HexColor | HexColor[] | undefined; speed: `${number}s`; ... 9 more ...; tz?: string | undefined; }'.
} = parseResult.data;

const year = customYear || new Date().getFullYear().toString();
Expand Down Expand Up @@ -94,6 +95,7 @@
width,
height,
scale: 'linear',
lang,
};

const ip = getClientIp(request);
Expand Down
103 changes: 103 additions & 0 deletions app/api/wrapped/tests/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// app/api/wrapped/tests/i18n.test.ts
import { describe, it, expect } from 'vitest';
import { wrappedParamsSchema } from '@/lib/validations';
import { getLabels } from '@/lib/i18n/badgeLabels';

// ---------------------------------------------------------------------------
// 1. wrappedParamsSchema – lang field validation & defaulting
// ---------------------------------------------------------------------------

describe('wrappedParamsSchema – lang param', () => {
it('defaults lang to "en" when omitted', () => {
const result = wrappedParamsSchema.safeParse({ user: 'octocat' });
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.lang).toBe('en');

Check failure on line 15 in app/api/wrapped/tests/i18n.test.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Property 'lang' does not exist on type '{ user: string; theme: string; bg: HexColor | undefined; text: HexColor | undefined; accent: HexColor | HexColor[] | undefined; speed: `${number}s`; ... 9 more ...; tz?: string | undefined; }'.
}
});

it('accepts every supported locale', () => {
const locales = ['en', 'zh', 'es', 'hi', 'pt', 'ko', 'ja', 'fr', 'ta', 'de'];
for (const lang of locales) {
const result = wrappedParamsSchema.safeParse({ user: 'octocat', lang });
expect(result.success, `locale "${lang}" should be valid`).toBe(true);
if (result.success) {
expect(result.data.lang).toBe(lang);

Check failure on line 25 in app/api/wrapped/tests/i18n.test.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Property 'lang' does not exist on type '{ user: string; theme: string; bg: HexColor | undefined; text: HexColor | undefined; accent: HexColor | HexColor[] | undefined; speed: `${number}s`; ... 9 more ...; tz?: string | undefined; }'.
}
}
});

it('falls back to "en" for an unsupported locale (catch behaviour)', () => {
const result = wrappedParamsSchema.safeParse({ user: 'octocat', lang: 'klingon' });
// .catch('en') means the schema still succeeds but coerces to 'en'
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.lang).toBe('en');

Check failure on line 35 in app/api/wrapped/tests/i18n.test.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Property 'lang' does not exist on type '{ user: string; theme: string; bg: HexColor | undefined; text: HexColor | undefined; accent: HexColor | HexColor[] | undefined; speed: `${number}s`; ... 9 more ...; tz?: string | undefined; }'.
}
});
});

// ---------------------------------------------------------------------------
// 2. getLabels – wrapped-specific keys are present and non-empty
// ---------------------------------------------------------------------------

const WRAPPED_KEYS = [
'TOTAL_CONTRIBUTIONS',
'TOP_LANGUAGE',
'WEEKEND_GRIND',
'PEAK_DAY',
'BUSIEST_MONTH',
] as const;

describe('getLabels – wrapped keys exist for all locales', () => {
const locales = ['en', 'zh', 'es', 'hi', 'pt', 'ko', 'ja', 'fr', 'ta', 'de'];

for (const lang of locales) {
it(`locale "${lang}" has all wrapped keys as non-empty strings`, () => {
const l = getLabels(lang);
for (const key of WRAPPED_KEYS) {
expect(typeof l[key], `${lang}.${key} should be a string`).toBe('string');
expect(l[key].length, `${lang}.${key} should not be empty`).toBeGreaterThan(0);
}
});
}
});

// ---------------------------------------------------------------------------
// 3. Spot-check translations for a handful of non-English locales
// ---------------------------------------------------------------------------

describe('getLabels – wrapped key translations (spot-check)', () => {
it('returns correct Hindi labels', () => {
const l = getLabels('hi');
expect(l.TOTAL_CONTRIBUTIONS).toBe('कुल योगदान');
expect(l.TOP_LANGUAGE).toBe('मुख्य भाषा');
expect(l.WEEKEND_GRIND).toBe('वीकेंड ग्राइंड');
expect(l.PEAK_DAY).toBe('सर्वोच्च दिन');
expect(l.BUSIEST_MONTH).toBe('सबसे व्यस्त माह');
});

it('returns correct Japanese labels', () => {
const l = getLabels('ja');
expect(l.TOTAL_CONTRIBUTIONS).toBe('総コントリビューション');
expect(l.TOP_LANGUAGE).toBe('メイン言語');
expect(l.WEEKEND_GRIND).toBe('週末の活動');
expect(l.PEAK_DAY).toBe('ピーク日');
expect(l.BUSIEST_MONTH).toBe('最も忙しい月');
});

it('returns correct French labels', () => {
const l = getLabels('fr');
expect(l.TOTAL_CONTRIBUTIONS).toBe('CONTRIBUTIONS');
expect(l.TOP_LANGUAGE).toBe('LANGAGE PRINCIPAL');
expect(l.WEEKEND_GRIND).toBe('TRAVAIL DU WEEKEND');
expect(l.PEAK_DAY).toBe('JOUR DE POINTE');
expect(l.BUSIEST_MONTH).toBe('MOIS LE PLUS ACTIF');
});

it('falls back to English for unknown lang', () => {
const l = getLabels('xx');
expect(l.TOTAL_CONTRIBUTIONS).toBe('TOTAL CONTRIBUTIONS');
expect(l.TOP_LANGUAGE).toBe('TOP LANGUAGE');
});
});
56 changes: 56 additions & 0 deletions lib/i18n/badgeLabels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ export interface BadgeLabels {
PEAK_STREAK: string;
COMMITS_THIS_MONTH: string;
VS_LAST_MONTH: string;
// Wrapped / Year-in-Review badge labels
TOTAL_CONTRIBUTIONS: string;
TOP_LANGUAGE: string;
WEEKEND_GRIND: string;
PEAK_DAY: string;
BUSIEST_MONTH: string;
}

export const labels: Record<string, BadgeLabels> = {
Expand All @@ -13,69 +19,119 @@ export const labels: Record<string, BadgeLabels> = {
PEAK_STREAK: 'PEAK_STREAK',
COMMITS_THIS_MONTH: 'COMMITS THIS MONTH',
VS_LAST_MONTH: 'vs last month',
TOTAL_CONTRIBUTIONS: 'TOTAL CONTRIBUTIONS',
TOP_LANGUAGE: 'TOP LANGUAGE',
WEEKEND_GRIND: 'WEEKEND GRIND',
PEAK_DAY: 'PEAK DAY',
BUSIEST_MONTH: 'BUSIEST MONTH',
},
zh: {
CURRENT_STREAK: '当前连续记录',
ANNUAL_SYNC_TOTAL: '年度总计',
PEAK_STREAK: '最长连续记录',
COMMITS_THIS_MONTH: '本月提交次数',
VS_LAST_MONTH: '较上个月',
TOTAL_CONTRIBUTIONS: '总贡献数',
TOP_LANGUAGE: '主要语言',
WEEKEND_GRIND: '周末奋斗',
PEAK_DAY: '最高单日',
BUSIEST_MONTH: '最忙月份',
},
es: {
CURRENT_STREAK: 'RACHA_ACTUAL',
ANNUAL_SYNC_TOTAL: 'TOTAL_ANUAL',
PEAK_STREAK: 'RACHA_MÁXIMA',
COMMITS_THIS_MONTH: 'COMMITS ESTE MES',
VS_LAST_MONTH: 'vs mes anterior',
TOTAL_CONTRIBUTIONS: 'CONTRIBUCIONES',
TOP_LANGUAGE: 'IDIOMA PRINCIPAL',
WEEKEND_GRIND: 'FIN DE SEMANA',
PEAK_DAY: 'DÍA PICO',
BUSIEST_MONTH: 'MES MÁS ACTIVO',
},
hi: {
CURRENT_STREAK: 'वर्तमान_स्ट्रीक',
ANNUAL_SYNC_TOTAL: 'वार्षिक_कुल',
PEAK_STREAK: 'अधिकतम_स्ट्रीक',
COMMITS_THIS_MONTH: 'इस महीने के कमिट्स',
VS_LAST_MONTH: 'पिछले महीने की तुलना में',
TOTAL_CONTRIBUTIONS: 'कुल योगदान',
TOP_LANGUAGE: 'मुख्य भाषा',
WEEKEND_GRIND: 'वीकेंड ग्राइंड',
PEAK_DAY: 'सर्वोच्च दिन',
BUSIEST_MONTH: 'सबसे व्यस्त माह',
},
pt: {
CURRENT_STREAK: 'SÉRIE_ATUAL',
ANNUAL_SYNC_TOTAL: 'TOTAL_ANUAL',
PEAK_STREAK: 'SÉRIE_MÁXIMA',
COMMITS_THIS_MONTH: 'COMMITS ESTE MÊS',
VS_LAST_MONTH: 'vs mês passado',
TOTAL_CONTRIBUTIONS: 'CONTRIBUIÇÕES',
TOP_LANGUAGE: 'LINGUAGEM PRINCIPAL',
WEEKEND_GRIND: 'FOCO NO FIM DE SEMANA',
PEAK_DAY: 'DIA DE PICO',
BUSIEST_MONTH: 'MÊS MAIS ATIVO',
},
ko: {
CURRENT_STREAK: '현재_연속',
ANNUAL_SYNC_TOTAL: '연간_총계',
PEAK_STREAK: '최고_연속',
COMMITS_THIS_MONTH: '이번 달 커밋',
VS_LAST_MONTH: '지난달 대비',
TOTAL_CONTRIBUTIONS: '총 기여 수',
TOP_LANGUAGE: '주요 언어',
WEEKEND_GRIND: '주말 작업',
PEAK_DAY: '최고 활동일',
BUSIEST_MONTH: '가장 바쁜 달',
},
ja: {
CURRENT_STREAK: '現在のストリーク',
ANNUAL_SYNC_TOTAL: '年間合計',
PEAK_STREAK: '最高ストリーク',
COMMITS_THIS_MONTH: '今月のコミット数',
VS_LAST_MONTH: '先月比',
TOTAL_CONTRIBUTIONS: '総コントリビューション',
TOP_LANGUAGE: 'メイン言語',
WEEKEND_GRIND: '週末の活動',
PEAK_DAY: 'ピーク日',
BUSIEST_MONTH: '最も忙しい月',
},
fr: {
CURRENT_STREAK: 'SÉRIE_ACTUELLE',
ANNUAL_SYNC_TOTAL: 'TOTAL_ANNUEL',
PEAK_STREAK: 'SÉRIE_MAXIMALE',
COMMITS_THIS_MONTH: 'COMMITS CE MOIS',
VS_LAST_MONTH: 'vs mois dernier',
TOTAL_CONTRIBUTIONS: 'CONTRIBUTIONS',
TOP_LANGUAGE: 'LANGAGE PRINCIPAL',
WEEKEND_GRIND: 'TRAVAIL DU WEEKEND',
PEAK_DAY: 'JOUR DE POINTE',
BUSIEST_MONTH: 'MOIS LE PLUS ACTIF',
},
ta: {
CURRENT_STREAK: 'தற்போதைய_தொடர்',
ANNUAL_SYNC_TOTAL: 'ஆண்டு_மொத்தம்',
PEAK_STREAK: 'உச்ச_தொடர்',
COMMITS_THIS_MONTH: 'இம்மாத கமிட்கள்',
VS_LAST_MONTH: 'கடந்த மாதத்துடன்',
TOTAL_CONTRIBUTIONS: 'மொத்த பங்களிப்புகள்',
TOP_LANGUAGE: 'முக்கிய மொழி',
WEEKEND_GRIND: 'வார இறுதி உழைப்பு',
PEAK_DAY: 'உச்ச நாள்',
BUSIEST_MONTH: 'மிக பரபரப்பான மாதம்',
},
de: {
CURRENT_STREAK: 'AKTUELLE_SERIE',
ANNUAL_SYNC_TOTAL: 'JAHRES_GESAMT',
PEAK_STREAK: 'SPITZEN_SERIE',
COMMITS_THIS_MONTH: 'COMMITS DIESEN MONAT',
VS_LAST_MONTH: 'im Vgl. zum Vormonat',
TOTAL_CONTRIBUTIONS: 'BEITRÄGE GESAMT',
TOP_LANGUAGE: 'HAUPTSPRACHE',
WEEKEND_GRIND: 'WOCHENEND-ARBEIT',
PEAK_DAY: 'SPITZENTAG',
BUSIEST_MONTH: 'AKTIVSTER MONAT',
},
};

Expand Down
12 changes: 6 additions & 6 deletions lib/svg/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1121,7 +1121,7 @@ export function generateWrappedSVG(
const isPredefinedFont = isBundledFont(sanitizedFont);
const statsFont = selectedFont || '"Space Grotesk", sans-serif';
const radius = sanitizeRadius(params.radius, 8);

const wrappedLabels = getLabels(params.lang);
const width = params.width || 420;
const height = params.height || 260;

Expand Down Expand Up @@ -1309,19 +1309,19 @@ export function generateWrappedSVG(

<g transform="translate(25, 120)">
<text x="0" y="15" class="total-commits" ${accentClass}${glowAttr}>${stats.totalContributions}</text>
<text x="2" y="38" class="total-label" ${textClass}>TOTAL CONTRIBUTIONS</text>
<text x="2" y="38" class="total-label" ${textClass}>${wrappedLabels.TOTAL_CONTRIBUTIONS}</text>
</g>

<line x1="185" y1="80" x2="185" y2="230" stroke="${params.autoTheme ? 'var(--cp-accent)' : accent}" stroke-opacity="0.12" stroke-width="1" stroke-dasharray="3 3" />

<g transform="translate(210, 80)">
<g transform="translate(0, 20)">
<text x="0" y="0" class="grid-label" ${textClass}>TOP LANGUAGE</text>
<text x="0" y="0" class="grid-label" ${textClass}>${wrappedLabels.TOP_LANGUAGE}</text>
<text x="0" y="20" class="grid-val" ${accentClass}>${escapeXML(stats.topLanguage || 'Unknown')}</text>
</g>

<g transform="translate(130, 20)">
<text x="0" y="0" class="grid-label" ${textClass}>WEEKEND GRIND</text>
<text x="0" y="0" class="grid-label" ${textClass}>${wrappedLabels.WEEKEND_GRIND}</text>
<g transform="translate(25, 24)">
<circle cx="0" cy="0" r="14" stroke="${params.autoTheme ? 'var(--cp-text)' : text}" stroke-opacity="0.1" stroke-width="2.5" fill="none" />
<circle cx="0" cy="0" r="14" stroke="${params.autoTheme ? 'var(--cp-accent)' : accent}" stroke-width="3" fill="none"
Expand All @@ -1333,15 +1333,15 @@ export function generateWrappedSVG(
</g>

<g transform="translate(210, 150)">
<text x="0" y="0" class="grid-label" ${textClass}>PEAK DAY</text>
<text x="0" y="0" class="grid-label" ${textClass}>${wrappedLabels.PEAK_DAY}</text>
<text x="0" y="20" class="grid-val" ${textClass}>
${stats.highestDailyCount} COMMITS
<tspan font-size="10.5" font-weight="500" ${accentClass} opacity="0.8">ON ${escapeXML(formattedPeakDate.toUpperCase())}</tspan>
</text>
</g>

<g transform="translate(210, 205)">
<text x="0" y="0" class="grid-label" ${textClass}>BUSIEST MONTH</text>
<text x="0" y="0" class="grid-label" ${textClass}>${wrappedLabels.BUSIEST_MONTH}</text>
<text x="0" y="20" class="grid-val" ${textClass}>
${escapeXML(monthName)}
<tspan font-size="11" font-weight="500" ${accentClass}>🔥</tspan>
Expand Down
1 change: 1 addition & 0 deletions lib/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,7 @@
.max(39, { message: 'GitHub username cannot exceed 39 characters' })
.regex(GITHUB_USERNAME_REGEX, {
message: 'Invalid GitHub username',
lang: z.enum(supportedLanguages).catch('en').default('en'),

Check failure on line 607 in lib/validations.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

Object literal may only specify known properties, and 'lang' does not exist in type '{ abort?: boolean | undefined; error?: string | $ZodErrorMap<$ZodIssueInvalidStringFormat> | undefined; message?: string | undefined; }'.
}),
year: z
.string()
Expand Down
Loading