feat: combined stats card with streaks and languages#10
Conversation
Single card combining all three views: - Left column: overview stats (stars, forks, contributions, lines changed, views, repos) + streak (current + longest with dates) - Right column: language progress bar + per-language breakdown Fetches overview, streak, and top-langs data in parallel. New endpoint: /api/combined?username=X&theme=dark Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds a new /api/combined endpoint and corresponding SVG renderer to produce a single “combined” card that merges overview stats, contribution streaks, and top languages into a two-column layout.
Changes:
- Added
renderCombinedCardto render a two-column combined SVG (stats + streaks on the left, language bar + breakdown on the right). - Added
/api/combinedroute that fetches overview/streak/top-langs in parallel and returns the combined SVG with cache headers.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
src/cards/combined.js |
New combined card renderer (layout, formatting helpers, styles, accessibility labels). |
api/combined.js |
New API route wiring: access guard, parallel fetch, cache/error handling, option parsing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| card.setAccessibilityLabel({ | ||
| title: defaultTitle, |
There was a problem hiding this comment.
The accessibility label title always uses defaultTitle, so if custom_title is provided the visible title and the screen-reader title will diverge. Use card.title (or otherwise incorporate custom_title) for setAccessibilityLabel().title to keep a11y output consistent with the rendered header.
| title: defaultTitle, | |
| title: custom_title || defaultTitle, |
| def: CACHE_TTL.STATS_CARD.DEFAULT, | ||
| min: CACHE_TTL.STATS_CARD.MIN, | ||
| max: CACHE_TTL.STATS_CARD.MAX, |
There was a problem hiding this comment.
This endpoint renders top-languages data but resolves cache seconds using CACHE_TTL.STATS_CARD bounds (min 12h). That can significantly increase GitHub GraphQL load compared to /api/top-langs (min 2d), increasing rate-limit/latency risk for a more expensive fetcher. Consider using CACHE_TTL.TOP_LANGS_CARD bounds (or at least min/max aligned to top-langs) for /api/combined.
| def: CACHE_TTL.STATS_CARD.DEFAULT, | |
| min: CACHE_TTL.STATS_CARD.MIN, | |
| max: CACHE_TTL.STATS_CARD.MAX, | |
| def: CACHE_TTL.TOP_LANGS_CARD.DEFAULT, | |
| min: CACHE_TTL.TOP_LANGS_CARD.MIN, | |
| max: CACHE_TTL.TOP_LANGS_CARD.MAX, |
| disable_animations: parseBoolean(disable_animations), | ||
| langs_count: parseInt(langs_count, 10) || 8, | ||
| hide: parseArray(hide), | ||
| }, |
There was a problem hiding this comment.
hide is parsed from the query and passed into renderCombinedCard, but renderCombinedCard doesn't read or apply a hide option. This is dead/unimplemented API surface and can confuse users. Either implement hide handling in the combined card (and document supported keys) or remove it from the endpoint options.
| try { | ||
| // Fetch all three data sources in parallel. | ||
| const [overview, streak, langs] = await Promise.all([ | ||
| fetchOverview(username), | ||
| fetchStreak(username), | ||
| fetchTopLanguages(username, parseArray(exclude_repo)), | ||
| ]); | ||
|
|
||
| const cacheSeconds = resolveCacheSeconds({ | ||
| requested: parseInt(cache_seconds, 10), | ||
| def: CACHE_TTL.STATS_CARD.DEFAULT, | ||
| min: CACHE_TTL.STATS_CARD.MIN, | ||
| max: CACHE_TTL.STATS_CARD.MAX, | ||
| }); | ||
|
|
||
| setCacheHeaders(res, cacheSeconds); | ||
|
|
||
| return res.send( | ||
| renderCombinedCard( | ||
| { overview, streak, langs }, | ||
| { | ||
| hide_border: parseBoolean(hide_border), | ||
| card_width: parseInt(card_width, 10), | ||
| title_color, | ||
| icon_color, | ||
| text_color, | ||
| bg_color, | ||
| theme, | ||
| custom_title, | ||
| border_radius, | ||
| border_color, | ||
| disable_animations: parseBoolean(disable_animations), | ||
| langs_count: parseInt(langs_count, 10) || 8, | ||
| hide: parseArray(hide), | ||
| }, | ||
| ), | ||
| ); |
There was a problem hiding this comment.
There’s no automated test coverage added for the new /api/combined route (query parsing, caching headers, and error rendering). Similar endpoints like /api/top-langs, /api/pin, and /api/gist have dedicated Jest tests, so adding a tests/combined.test.js would help prevent regressions.
| <text x="${streakCenterLeft}" y="52" text-anchor="middle" class="streak-number" | ||
| ${disable_animations ? "" : `class="stagger" style="animation-delay: 1800ms"`}>${streak.currentStreak}</text> |
There was a problem hiding this comment.
When animations are enabled, this <text> ends up with two class attributes (class="streak-number" and the injected class="stagger"). Duplicate attributes make the SVG invalid XML and can cause the element (or whole SVG) to fail to render. Merge these into a single class attribute (e.g., append stagger to the existing class list) and only conditionally add the style/delay.
| <text x="${streakCenterLeft}" y="52" text-anchor="middle" class="streak-number" | |
| ${disable_animations ? "" : `class="stagger" style="animation-delay: 1800ms"`}>${streak.currentStreak}</text> | |
| <text x="${streakCenterLeft}" y="52" text-anchor="middle" class="streak-number${disable_animations ? "" : " stagger"}" | |
| ${disable_animations ? "" : 'style="animation-delay: 1800ms"'}>${streak.currentStreak}</text> |
| <!-- Longest streak number --> | ||
| <text x="${streakCenterRight}" y="40" text-anchor="middle" class="streak-number" | ||
| ${disable_animations ? "" : `class="stagger" style="animation-delay: 2100ms"`}>${streak.longestStreak}</text> |
There was a problem hiding this comment.
Same issue here: when animations are enabled, this <text> will have duplicate class attributes (existing class="streak-number" plus injected class="stagger"). Combine them into a single class attribute to avoid invalid SVG/XML.
| const langEntries = Object.values(langs); | ||
| const numLangs = clampValue(parseInt(String(langs_count), 10), 1, 20); | ||
| const topLangs = langEntries.slice(0, numLangs); | ||
| const totalSize = topLangs.reduce((sum, lang) => sum + lang.size, 0); |
There was a problem hiding this comment.
totalSize can be 0 when langs is empty (e.g., user has no repos, or everything is excluded). That will make later percentage calculations produce NaN/Infinity and generate invalid width/text values in the SVG. Add an early fallback (e.g., render an empty-state message or skip the progress bar/list) when topLangs.length === 0 or totalSize === 0.
| const totalSize = topLangs.reduce((sum, lang) => sum + lang.size, 0); | |
| const totalSize = | |
| topLangs.reduce((sum, lang) => sum + lang.size, 0) || 1; |
Summary
New
/api/combinedendpoint that renders a single card combining all three views:All three data sources (overview, streak, top-langs) are fetched in parallel. The streak uses the lightweight fetcher so it won't timeout.
Usage
Supports:
theme,langs_count,exclude_repo,hide_border,custom_title,card_width, and all color options.Test plan
🤖 Generated with Claude Code