Skip to content

Commit 628c799

Browse files
committed
feat(svg): add compact monthly stats view mode
1 parent d28919b commit 628c799

10 files changed

Lines changed: 466 additions & 9 deletions

File tree

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,10 @@ URL Parameter > Theme Default > System Fallback
156156
| `hide_stats` | `boolean` | No | `false` | Hides the bottom row displaying Current Streak, Annual Sync Total, and Peak Streak stats when set to `true` or `1`. |
157157
| `tz` | `string` | No | Omitted = UTC | IANA timezone (e.g. `Asia/Kolkata`, `America/New_York`) — aligns "today" with the user local midnight. Note: `?tz=UTC` is valid but cached separately from omitting `tz`. |
158158
| `lang` | `string` | No | `en` | Language code for labels (`en`, `es`, `hi`, `fr`) |
159+
| `view` | `string` | No | `default` | Rendering mode: `default` (3D Monolith) or `monthly` (Compact monthly stats) |
160+
| `delta_format` | `string` | No | `percent` | Format for month-over-month delta in monthly view: `percent` (e.g. +12%), `absolute` (e.g. +15 commits), or `both` |
161+
| `width` | `number` | No | `300` | Custom width for the SVG canvas (currently only applies to `view=monthly`) |
162+
| `height` | `number` | No | `120` | Custom height for the SVG canvas (currently only applies to `view=monthly`) |
159163

160164
### Theme Presets
161165

@@ -204,6 +208,14 @@ URL Parameter > Theme Default > System Fallback
204208

205209
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&year=2023)
206210

211+
<!-- Compact Monthly Stats View -->
212+
213+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&view=monthly)
214+
215+
<!-- Monthly View with Absolute Delta and Custom Dimensions -->
216+
217+
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&view=monthly&delta_format=absolute&width=400&height=150)
218+
207219
<!-- Hide GitHub username/title -->
208220

209221
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&hide_title=true)

app/api/streak/route.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,4 +411,23 @@ describe('GET /api/streak', () => {
411411
expect(getSecondsUntilMidnightInTimezone).not.toHaveBeenCalled();
412412
});
413413
});
414+
415+
describe('monthly view parameter', () => {
416+
it('returns 200 when view=monthly is given', async () => {
417+
const response = await GET(makeRequest({ user: 'octocat', view: 'monthly' }));
418+
419+
expect(response.status).toBe(200);
420+
const body = await response.text();
421+
expect(body).toContain('COMMITS THIS MONTH');
422+
});
423+
424+
it('defaults to default view when an unknown view is given', async () => {
425+
const response = await GET(makeRequest({ user: 'octocat', view: 'invalid' }));
426+
427+
expect(response.status).toBe(200);
428+
const body = await response.text();
429+
// It should generate the default streak SVG and have "CURRENT_STREAK"
430+
expect(body).toContain('CURRENT_STREAK');
431+
});
432+
});
414433
});

app/api/streak/route.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
// app/api/streak/route.ts
22
import { NextResponse } from 'next/server';
33
import { fetchGitHubContributions } from '../../../lib/github';
4-
import { calculateStreak } from '../../../lib/calculate';
5-
import { generateNotFoundSVG, generateSVG, escapeXML } from '../../../lib/svg/generator';
4+
import { calculateStreak, calculateMonthlyStats } from '../../../lib/calculate';
5+
import {
6+
generateNotFoundSVG,
7+
generateSVG,
8+
generateMonthlySVG,
9+
escapeXML,
10+
} from '../../../lib/svg/generator';
611
import { getSecondsUntilUTCMidnight, getSecondsUntilMidnightInTimezone } from '../../../utils/time';
712
import type { BadgeParams } from '../../../types';
813
import { themes } from '../../../lib/svg/themes';
@@ -40,6 +45,10 @@ export async function GET(request: Request) {
4045
hide_background,
4146
hide_stats,
4247
lang,
48+
view,
49+
delta_format,
50+
width,
51+
height,
4352
} = parseResult.data;
4453

4554
const themeName = theme || 'dark';
@@ -83,6 +92,10 @@ export async function GET(request: Request) {
8392
hideBackground: hide_background,
8493
hide_stats,
8594
lang,
95+
view,
96+
delta_format,
97+
width: width ? parseInt(width, 10) : undefined,
98+
height: height ? parseInt(height, 10) : undefined,
8699
size,
87100
};
88101

@@ -91,8 +104,15 @@ export async function GET(request: Request) {
91104
from,
92105
to,
93106
});
94-
const stats = calculateStreak(calendar, timezone);
95-
const svg = generateSVG(stats, params, calendar);
107+
108+
let svg = '';
109+
if (view === 'monthly') {
110+
const stats = calculateMonthlyStats(calendar, timezone);
111+
svg = generateMonthlySVG(stats, params);
112+
} else {
113+
const stats = calculateStreak(calendar, timezone);
114+
svg = generateSVG(stats, params, calendar);
115+
}
96116

97117
const secondsToMidnight = tzParam
98118
? getSecondsUntilMidnightInTimezone(timezone)

lib/calculate.test.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest';
2-
import { calculateStreak } from './calculate';
2+
import { calculateStreak, calculateMonthlyStats } from './calculate';
33
import type { ContributionCalendar } from '../types';
44

55
// Turns a flat array of daily counts into the ContributionCalendar shape,
@@ -259,6 +259,105 @@ describe('calculateStreak — timezone awareness', () => {
259259
});
260260
});
261261

262+
describe('calculateMonthlyStats', () => {
263+
it('calculates monthly stats correctly when both months have commits', () => {
264+
const calendar = {
265+
totalContributions: 15,
266+
weeks: [
267+
{
268+
contributionDays: [
269+
{ contributionCount: 5, date: '2024-05-15' },
270+
{ contributionCount: 10, date: '2024-06-10' },
271+
],
272+
},
273+
],
274+
};
275+
const now = new Date('2024-06-15T12:00:00Z');
276+
const result = calculateMonthlyStats(calendar, 'UTC', now);
277+
278+
expect(result.currentMonthTotal).toBe(10);
279+
expect(result.previousMonthTotal).toBe(5);
280+
expect(result.deltaAbsolute).toBe(5);
281+
expect(result.deltaPercentage).toBe(100);
282+
expect(result.currentMonthName).toBe('June');
283+
});
284+
285+
it('handles zero previous month contributions', () => {
286+
const calendar = {
287+
totalContributions: 10,
288+
weeks: [
289+
{
290+
contributionDays: [{ contributionCount: 10, date: '2024-06-10' }],
291+
},
292+
],
293+
};
294+
const now = new Date('2024-06-15T12:00:00Z');
295+
const result = calculateMonthlyStats(calendar, 'UTC', now);
296+
297+
expect(result.previousMonthTotal).toBe(0);
298+
expect(result.currentMonthTotal).toBe(10);
299+
expect(result.deltaPercentage).toBe(100);
300+
});
301+
302+
it('handles zero current month contributions', () => {
303+
const calendar = {
304+
totalContributions: 5,
305+
weeks: [
306+
{
307+
contributionDays: [{ contributionCount: 5, date: '2024-05-10' }],
308+
},
309+
],
310+
};
311+
const now = new Date('2024-06-15T12:00:00Z');
312+
const result = calculateMonthlyStats(calendar, 'UTC', now);
313+
314+
expect(result.previousMonthTotal).toBe(5);
315+
expect(result.currentMonthTotal).toBe(0);
316+
expect(result.deltaPercentage).toBe(-100);
317+
});
318+
319+
it('handles negative delta correctly', () => {
320+
const calendar = {
321+
totalContributions: 15,
322+
weeks: [
323+
{
324+
contributionDays: [
325+
{ contributionCount: 10, date: '2024-05-10' },
326+
{ contributionCount: 5, date: '2024-06-10' },
327+
],
328+
},
329+
],
330+
};
331+
const now = new Date('2024-06-15T12:00:00Z');
332+
const result = calculateMonthlyStats(calendar, 'UTC', now);
333+
334+
expect(result.previousMonthTotal).toBe(10);
335+
expect(result.currentMonthTotal).toBe(5);
336+
expect(result.deltaPercentage).toBe(-50);
337+
expect(result.deltaAbsolute).toBe(-5);
338+
});
339+
340+
it('handles year boundary correctly (Jan vs Dec)', () => {
341+
const calendar = {
342+
totalContributions: 15,
343+
weeks: [
344+
{
345+
contributionDays: [
346+
{ contributionCount: 10, date: '2023-12-15' },
347+
{ contributionCount: 5, date: '2024-01-15' },
348+
],
349+
},
350+
],
351+
};
352+
const now = new Date('2024-01-15T12:00:00Z');
353+
const result = calculateMonthlyStats(calendar, 'UTC', now);
354+
355+
expect(result.previousMonthTotal).toBe(10);
356+
expect(result.currentMonthTotal).toBe(5);
357+
expect(result.currentMonthName).toBe('January');
358+
});
359+
});
360+
262361
describe('calculateStreak — empty and sparse year edge cases', () => {
263362
it('returns stable output when all weeks have zero-contribution days', () => {
264363
const calendar = buildCalendar([

lib/calculate.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// lib/calculate.ts
2-
import type { ContributionCalendar, StreakStats } from '../types';
2+
import type { ContributionCalendar, StreakStats, MonthlyStats } from '../types';
33

44
export function calculateStreak(
55
calendar: ContributionCalendar,
@@ -76,3 +76,62 @@ export function calculateStreak(
7676
todayDate,
7777
};
7878
}
79+
80+
export function calculateMonthlyStats(
81+
calendar: ContributionCalendar,
82+
timezone: string = 'UTC',
83+
now: Date = new Date()
84+
): MonthlyStats {
85+
const days = calendar.weeks.flatMap((week) => week.contributionDays);
86+
87+
const localTodayStr = new Intl.DateTimeFormat('en-CA', { timeZone: timezone }).format(now);
88+
const [currentYearStr, currentMonthStr] = localTodayStr.split('-');
89+
const currentYear = parseInt(currentYearStr, 10);
90+
const currentMonth = parseInt(currentMonthStr, 10);
91+
92+
let prevMonth = currentMonth - 1;
93+
let prevYear = currentYear;
94+
if (prevMonth === 0) {
95+
prevMonth = 12;
96+
prevYear -= 1;
97+
}
98+
99+
const currentMonthPrefix = `${currentYear}-${currentMonth.toString().padStart(2, '0')}`;
100+
const prevMonthPrefix = `${prevYear}-${prevMonth.toString().padStart(2, '0')}`;
101+
102+
let currentMonthTotal = 0;
103+
let previousMonthTotal = 0;
104+
105+
for (const day of days) {
106+
if (day.date.startsWith(currentMonthPrefix)) {
107+
currentMonthTotal += day.contributionCount;
108+
} else if (day.date.startsWith(prevMonthPrefix)) {
109+
previousMonthTotal += day.contributionCount;
110+
}
111+
}
112+
113+
const currentMonthName = new Intl.DateTimeFormat('en-US', {
114+
timeZone: timezone,
115+
month: 'long',
116+
}).format(now);
117+
118+
const deltaAbsolute = currentMonthTotal - previousMonthTotal;
119+
let deltaPercentage = 0;
120+
121+
if (previousMonthTotal === 0) {
122+
if (currentMonthTotal > 0) {
123+
deltaPercentage = 100;
124+
}
125+
} else {
126+
deltaPercentage = Math.round((deltaAbsolute / previousMonthTotal) * 100);
127+
if (deltaPercentage === -0) deltaPercentage = 0;
128+
}
129+
130+
return {
131+
currentMonthTotal,
132+
previousMonthTotal,
133+
deltaPercentage,
134+
deltaAbsolute,
135+
currentMonthName,
136+
};
137+
}

lib/i18n/badgeLabels.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,38 @@ export interface BadgeLabels {
22
CURRENT_STREAK: string;
33
ANNUAL_SYNC_TOTAL: string;
44
PEAK_STREAK: string;
5+
COMMITS_THIS_MONTH: string;
6+
VS_LAST_MONTH: string;
57
}
68

79
export const labels: Record<string, BadgeLabels> = {
810
en: {
911
CURRENT_STREAK: 'CURRENT_STREAK',
1012
ANNUAL_SYNC_TOTAL: 'ANNUAL_SYNC_TOTAL',
1113
PEAK_STREAK: 'PEAK_STREAK',
14+
COMMITS_THIS_MONTH: 'COMMITS THIS MONTH',
15+
VS_LAST_MONTH: 'vs last month',
1216
},
1317
es: {
1418
CURRENT_STREAK: 'RACHA_ACTUAL',
1519
ANNUAL_SYNC_TOTAL: 'TOTAL_ANUAL',
1620
PEAK_STREAK: 'RACHA_MÁXIMA',
21+
COMMITS_THIS_MONTH: 'COMMITS ESTE MES',
22+
VS_LAST_MONTH: 'vs mes anterior',
1723
},
1824
hi: {
1925
CURRENT_STREAK: 'वर्तमान_स्ट्रीक',
2026
ANNUAL_SYNC_TOTAL: 'वार्षिक_कुल',
2127
PEAK_STREAK: 'अधिकतम_स्ट्रीक',
28+
COMMITS_THIS_MONTH: 'इस महीने के कमिट्स',
29+
VS_LAST_MONTH: 'पिछले महीने की तुलना में',
2230
},
2331
fr: {
2432
CURRENT_STREAK: 'SÉRIE_ACTUELLE',
2533
ANNUAL_SYNC_TOTAL: 'TOTAL_ANNUEL',
2634
PEAK_STREAK: 'SÉRIE_MAXIMALE',
35+
COMMITS_THIS_MONTH: 'COMMITS CE MOIS',
36+
VS_LAST_MONTH: 'vs mois dernier',
2737
},
2838
};
2939

lib/svg/generator.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from 'vitest';
2-
import { generateSVG } from './generator';
3-
import type { BadgeParams, ContributionCalendar, StreakStats } from '../../types';
2+
import { generateSVG, generateMonthlySVG } from './generator';
3+
import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } from '../../types';
44

55
describe('generateSVG', () => {
66
const mockStats: StreakStats = {
@@ -378,3 +378,49 @@ describe('generateSVG', () => {
378378
});
379379
});
380380
});
381+
382+
describe('generateMonthlySVG', () => {
383+
const mockMonthlyStats: MonthlyStats = {
384+
currentMonthTotal: 42,
385+
previousMonthTotal: 30,
386+
deltaPercentage: 40,
387+
deltaAbsolute: 12,
388+
currentMonthName: 'June',
389+
};
390+
391+
it('renders monthly stats correctly with absolute delta', () => {
392+
const svg = generateMonthlySVG(mockMonthlyStats, {
393+
user: 'octocat',
394+
delta_format: 'absolute',
395+
} as unknown as BadgeParams);
396+
expect(svg).toContain('JUNE');
397+
expect(svg).toContain('42');
398+
expect(svg).toContain('+12 commits');
399+
});
400+
401+
it('renders monthly stats correctly with percentage delta', () => {
402+
const svg = generateMonthlySVG(mockMonthlyStats, {
403+
user: 'octocat',
404+
delta_format: 'percent',
405+
} as unknown as BadgeParams);
406+
expect(svg).toContain('+40%');
407+
});
408+
409+
it('renders monthly stats correctly with both delta formats', () => {
410+
const svg = generateMonthlySVG(mockMonthlyStats, {
411+
user: 'octocat',
412+
delta_format: 'both',
413+
} as unknown as BadgeParams);
414+
expect(svg).toContain('+40% (+12)');
415+
});
416+
417+
it('respects custom width and height parameters', () => {
418+
const svg = generateMonthlySVG(mockMonthlyStats, {
419+
user: 'octocat',
420+
width: 400,
421+
height: 200,
422+
} as unknown as BadgeParams);
423+
expect(svg).toContain('width="400"');
424+
expect(svg).toContain('height="200"');
425+
});
426+
});

0 commit comments

Comments
 (0)