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
99 changes: 99 additions & 0 deletions docs/customization.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,3 +453,102 @@ Analyze contributions for specific time periods using `from`, `to`, `start_date`
```md
![](https://commitpulse.vercel.app/api/streak?user=jhasourav07&opacity=0.8)
```

---

## ❓ Troubleshooting & FAQ

Below are solutions to common issues and questions when embedding CommitPulse SVGs into GitHub Profile READMEs or web pages.

### πŸ”„ GitHub Camo Proxy & Caching Issues

#### Why are my latest contributions or updated stats not showing on my GitHub Profile README?

GitHub routes all external images in profile READMEs through an anonymizing proxy called **Camo** (`camo.githubusercontent.com`). GitHub Camo aggressively caches image assets to ensure fast page loads and user privacy.

- **Cache TTL**: GitHub's Camo proxy caches images for several hours (typically up to 24 hours).
- **Bypassing the Proxy Cache**:
- **CommitPulse Cache Invalidation**: Add `&refresh=true` to your URL parameter list to bypass CommitPulse's internal cache:
```md
![](https://commitpulse.vercel.app/api/streak?user=YOUR_USERNAME&refresh=true)
```
- **GitHub Camo Invalidation**: If GitHub Camo continues to serve a cached image after using `refresh=true`, append a version or cache-buster query parameter to force Camo to treat it as a new URL:
```md
![](https://commitpulse.vercel.app/api/streak?user=YOUR_USERNAME&v=2)
```
- **Daily Invalidation**: CommitPulse automatically invalidates stored data at UTC midnight to align with GitHub's daily contribution cycles.

---

### πŸ“ Layout, Alignment & Responsiveness

#### How do I center the CommitPulse SVG badge in my profile README?

Standard Markdown image syntax `![]()` does not support alignment attributes. Use HTML alignment elements like `<p align="center">` or `<div align="center">`:

```html
<p align="center">
<img
src="https://commitpulse.vercel.app/api/streak?user=YOUR_USERNAME&theme=neon"
alt="CommitPulse Monolith"
/>
</p>
```

#### How can I prevent the SVG badge from overflowing on smaller or mobile screens?

Set explicit percentage widths and CSS responsive styling using an HTML `<img>` tag inside your Markdown:

```html
<p align="center">
<img
src="https://commitpulse.vercel.app/api/streak?user=YOUR_USERNAME&theme=dracula"
alt="CommitPulse Stats"
width="100%"
max-width="600"
/>
</p>
```

#### How do I place CommitPulse side-by-side with other profile stats badges?

Use an HTML `<table>` layout or place image tags within the same paragraph block:

```html
<p align="center">
<img
src="https://commitpulse.vercel.app/api/streak?user=YOUR_USERNAME&theme=dark&size=small"
width="48%"
/>
<img
src="https://github-readme-stats.vercel.app/api?username=YOUR_USERNAME&theme=dark"
width="48%"
/>
</p>
```

---

### πŸ” Parameter Syntax & Troubleshooting Checklist

If your monolith falls back to default dark styling or displays missing parameters, run through this quick verification checklist:

1. **Hex Colors (No `#` Prefix)**:
- ❌ `?bg=#0d1117&accent=#58a6ff` (The `#` symbol acts as an anchor fragment in URLs and breaks query parameters)
- βœ… `?bg=0d1117&accent=58a6ff`

2. **Mandatory `user` Parameter**:
- ❌ `https://commitpulse.vercel.app/api/streak?theme=neon`
- βœ… `https://commitpulse.vercel.app/api/streak?user=YOUR_USERNAME&theme=neon`

3. **Boolean Values**:
- Boolean parameters accept `true` / `false` or `1` / `0`.
- βœ… `hide_stats=true`, `gradient=1`, `labels=true`, `shading=true`

4. **URL-Encoding Special Characters & Custom Fonts**:
- Google Font names with spaces or special characters must be URL-encoded.
- ❌ `?font=Press Start 2P&custom_title=My Stats`
- βœ… `?font=Press%20Start%202P&custom_subtitle=Dev%20Dashboard`

5. **Parameter Spelling Check**:
- Ensure parameter names match exact keys listed in the [Parameter Reference](#-parameter-reference) table (e.g. `dim_weekends`, `hide_background`, `delta_format`).
16 changes: 8 additions & 8 deletions lib/svg/generator.getUsernameFontSize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@ describe('getUsernameFontSize', () => {
});

it('scales down font size for usernames with length > 12', () => {
// len 13 -> 18 - (13-12)*0.5 = 17.5
expect(getUsernameFontSize('a'.repeat(13))).toBe(17.5);
// len 20 -> 18 - (20-12)*0.5 = 14
expect(getUsernameFontSize('a'.repeat(20))).toBe(14);
// len 23 (max truncated length) -> 18 - (23-12)*0.5 = 12.5
expect(getUsernameFontSize('a'.repeat(23))).toBe(12.5);
// len 13 -> 18 - (13-12)*0.3 = 17.7
expect(getUsernameFontSize('a'.repeat(13))).toBe(17.7);
// len 20 -> 18 - (20-12)*0.3 = 15.6
expect(getUsernameFontSize('a'.repeat(20))).toBe(15.6);
// len 39 (max GitHub username length) -> 18 - (39-12)*0.3 = 9.9 -> clamped to 10
expect(getUsernameFontSize('a'.repeat(39))).toBe(10);
});

it('clamps to a minimum font size of 10px', () => {
// len 39 -> 18 - (39-12)*0.5 = 18 - 13.5 = 4.5 -> clamped to 10
expect(getUsernameFontSize('a'.repeat(39))).toBe(10);
// len 50 -> 18 - (50-12)*0.3 = 6.6 -> clamped to 10
expect(getUsernameFontSize('a'.repeat(50))).toBe(10);
});
});
20 changes: 10 additions & 10 deletions lib/svg/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1131,17 +1131,17 @@ describe('generateSVG', () => {
});

it('verify boundary robustness of username length truncator (Variation 4)', () => {
const extendedLongUsername = 'abcdefghijklmnopqrstuvwxyz1234567890';
const extendedLongUsername = 'abcdefghijklmnopqrstuvwxyz12345678901234567890';
const extendedParams = {
user: extendedLongUsername,
hide_title: false,
} as unknown as BadgeParams;

const svg = generateSVG(mockStats, extendedParams, mockCalendar);

expect(extendedLongUsername.length).toBeGreaterThan(30);
expect(svg).toContain('ABCDEFGHIJKLMNOPQRST...');
expect(svg).not.toContain('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
expect(extendedLongUsername.length).toBeGreaterThan(39);
expect(svg).toContain('ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890123...');
expect(svg).not.toContain(extendedLongUsername.toUpperCase());
});
});

Expand Down Expand Up @@ -1825,8 +1825,8 @@ describe('Radar Scan Line Animation Alignment', () => {
expect(result.endsWith('...')).toBe(true);

// 4. Assert: Verify the string was actually truncated
// If it caps at 30 chars and adds '...', the max length is 33.
expect(result.length).toBeLessThanOrEqual(33);
// If it caps at 39 chars and adds '...', the max length is 42.
expect(result.length).toBeLessThanOrEqual(42);

// 5. Assert: Ensure the original string was actually modified
expect(result).not.toEqual(longUsername);
Expand Down Expand Up @@ -1888,7 +1888,7 @@ describe('Radar Scan Line Animation Alignment', () => {
// 1. Arrange: Create usernames (one short baseline, one strictly > 30 chars)
const shortUsername = 'avi';
const longUsername = 'ThisIsAVeryLongUsernameThatExceedsThirtyCharacters';
const expectedTruncated = longUsername.slice(0, 20) + '...';
const expectedTruncated = longUsername.slice(0, 39) + '...';

const paramsBaseline = {
user: shortUsername,
Expand Down Expand Up @@ -1933,9 +1933,9 @@ describe('Radar Scan Line Animation Alignment', () => {
expect(geometryLong).toEqual(geometryBaseline);
});

it('truncates usernames longer than 20 characters and adds an ellipsis in generateSVG', () => {
const longUsername = 'averylongusernamethatexceeds20chars'; // 35 characters
const expectedTruncated = 'AVERYLONGUSERNAMETHA...'; // 20 characters + '...' (in uppercase)
it('truncates usernames longer than 39 characters and adds an ellipsis in generateSVG', () => {
const longUsername = 'averylongusernamethatexceeds39characterlimit'; // 44 characters
const expectedTruncated = 'AVERYLONGUSERNAMETHATEXCEEDS39CHARACTER...'; // 39 characters + '...' (in uppercase)

const svg = generateSVG(
mockStats,
Expand Down
17 changes: 10 additions & 7 deletions lib/svg/generator.truncateUsername.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,26 @@ import { describe, expect, it } from 'vitest';
import { truncateUsername } from './generator';

describe('truncateUsername', () => {
it('returns original username when length is less than 20', () => {
it('returns original username when length is less than 39', () => {
expect(truncateUsername('sonalkathuria')).toBe('sonalkathuria');
});

it('returns original username when length is exactly 20', () => {
expect(truncateUsername('abcdefghijklmnopqrst')).toBe('abcdefghijklmnopqrst');
it('returns original username when length is exactly 39', () => {
const user39 = 'abcdefghijklmnopqrstuvwxyz0123456789123';
expect(truncateUsername(user39)).toBe(user39);
});

it('truncates username longer than 20 characters with ellipsis', () => {
expect(truncateUsername('abcdefghijklmnopqrstuvwxyz')).toBe('abcdefghijklmnopqrst...');
it('truncates username longer than 39 characters with ellipsis', () => {
const user42 = 'abcdefghijklmnopqrstuvwxyz0123456789123456';
expect(truncateUsername(user42)).toBe('abcdefghijklmnopqrstuvwxyz0123456789123...');
});

it('handles empty string input', () => {
expect(truncateUsername('')).toBe('');
});

it('preserves spaces and special characters in the first 20 chars before ellipsis', () => {
expect(truncateUsername('john doe_user+tag12345678')).toBe('john doe_user+tag123...');
it('preserves spaces and special characters in the first 39 chars before ellipsis', () => {
const longString = 'john doe_user+tag123456789012345678901234567890123';
expect(truncateUsername(longString)).toBe(longString.slice(0, 39) + '...');
});
});
2 changes: 1 addition & 1 deletion lib/svg/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function truncateLabel(label: string): string {
export function getUsernameFontSize(username: string): number {
const len = username.length;
if (len <= 12) return 18;
return Math.max(10, 18 - (len - 12) * 0.5);
return Math.max(10, Number((18 - (len - 12) * 0.3).toFixed(1)));
}

/**
Expand Down
27 changes: 9 additions & 18 deletions lib/svg/generatorConstants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,20 +201,12 @@ describe('MAX_USERNAME_DISPLAY_LENGTH', () => {
expect(Number.isInteger(MAX_USERNAME_DISPLAY_LENGTH)).toBe(true);
});

it('equals 20 β€” the increased value supporting longer usernames', () => {
expect(MAX_USERNAME_DISPLAY_LENGTH).toBe(20);
it('equals 39 β€” supporting full GitHub usernames', () => {
expect(MAX_USERNAME_DISPLAY_LENGTH).toBe(39);
});

it('is less than GitHub max username length of 39 characters', () => {
// Sanity: display truncation must be shorter than the max possible username
expect(MAX_USERNAME_DISPLAY_LENGTH).toBeLessThan(39);
});

it('is coordinated with SVG_WIDTH β€” truncation prevents title overflow', () => {
// At Syncopate 18px with letter-spacing 6px, each character is ~24px wide.
// MAX_USERNAME_DISPLAY_LENGTH * 24 should be safely within SVG_WIDTH.
const estimatedTextWidth = MAX_USERNAME_DISPLAY_LENGTH * 24;
expect(estimatedTextWidth).toBeLessThan(SVG_WIDTH);
it('matches GitHub max username length of 39 characters', () => {
expect(MAX_USERNAME_DISPLAY_LENGTH).toBe(39);
});
});

Expand All @@ -239,7 +231,7 @@ describe('truncateUsername β€” uses MAX_USERNAME_DISPLAY_LENGTH constant', () =>
};

it('username exactly at MAX_USERNAME_DISPLAY_LENGTH is not truncated', () => {
const exactLengthUser = 'a'.repeat(MAX_USERNAME_DISPLAY_LENGTH); // 'aaaaaaaaaaaa'
const exactLengthUser = 'a'.repeat(MAX_USERNAME_DISPLAY_LENGTH);
const svg = generateSVG(
mockStats,
{ user: exactLengthUser } as unknown as BadgeParams,
Expand All @@ -250,24 +242,23 @@ describe('truncateUsername β€” uses MAX_USERNAME_DISPLAY_LENGTH constant', () =>
});

it('username one character over MAX_USERNAME_DISPLAY_LENGTH is truncated with ...', () => {
const longUser = 'a'.repeat(MAX_USERNAME_DISPLAY_LENGTH + 1); // 13 chars
const longUser = 'a'.repeat(MAX_USERNAME_DISPLAY_LENGTH + 1);
const svg = generateSVG(mockStats, { user: longUser } as unknown as BadgeParams, mockCalendar);
expect(svg).toContain('...');
// The displayed portion should be exactly MAX_USERNAME_DISPLAY_LENGTH chars
const truncated = 'A'.repeat(MAX_USERNAME_DISPLAY_LENGTH) + '...';
expect(svg).toContain(truncated);
});

it('very long GitHub username (39 chars) is truncated to MAX_USERNAME_DISPLAY_LENGTH', () => {
it('very long GitHub username (39 chars) is not truncated', () => {
const maxGitHubUser = 'a'.repeat(39);
const svg = generateSVG(
mockStats,
{ user: maxGitHubUser } as unknown as BadgeParams,
mockCalendar
);
expect(svg).toContain('...');
// Should show exactly 12 chars + '...'
expect(svg).toContain('A'.repeat(MAX_USERNAME_DISPLAY_LENGTH) + '...');
expect(svg).toContain('A'.repeat(39));
expect(svg).not.toContain('...');
});

it('short username (under MAX_USERNAME_DISPLAY_LENGTH) is never truncated', () => {
Expand Down
11 changes: 4 additions & 7 deletions lib/svg/generatorConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,8 @@ export function isFontKey(font: string): font is FontKey {

/**
* Maximum number of characters displayed in the username title before
* truncation with '...'. Chosen to fit within SVG_WIDTH=600 at the
* Syncopate font size of 18px with letter-spacing of 6px β€” approximately
* 550px of text width, leaving comfortable edge margin on all badge sizes.
*
* Increasing this value may cause title overflow on small (400px) and
* medium (600px) badges. Coordinate any change with SVG_WIDTH in this file.
* truncation with '...'. Set to 39 to accommodate full GitHub usernames
* (which can be up to 39 characters long) without truncation, utilizing dynamic
* font scaling to fit safely within SVG cards.
*/
export const MAX_USERNAME_DISPLAY_LENGTH = 20;
export const MAX_USERNAME_DISPLAY_LENGTH = 39;
Loading