From 4b3e741159a110be0044cd03f6f43a2760408197 Mon Sep 17 00:00:00 2001 From: Jason Ernst Date: Thu, 19 Mar 2026 21:03:12 -0700 Subject: [PATCH] feat: add combined card with stats, streaks, and languages 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) --- api/combined.js | 127 ++++++++++++++ src/cards/combined.js | 376 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 503 insertions(+) create mode 100644 api/combined.js create mode 100644 src/cards/combined.js diff --git a/api/combined.js b/api/combined.js new file mode 100644 index 0000000..537cba2 --- /dev/null +++ b/api/combined.js @@ -0,0 +1,127 @@ +// @ts-check + +import { renderCombinedCard } from "../src/cards/combined.js"; +import { guardAccess } from "../src/common/access.js"; +import { + CACHE_TTL, + resolveCacheSeconds, + setCacheHeaders, + setErrorCacheHeaders, +} from "../src/common/cache.js"; +import { + MissingParamError, + retrieveSecondaryMessage, +} from "../src/common/error.js"; +import { parseArray, parseBoolean } from "../src/common/ops.js"; +import { renderError } from "../src/common/render.js"; +import { fetchOverview } from "../src/fetchers/overview.js"; +import { fetchStreak } from "../src/fetchers/streak.js"; +import { fetchTopLanguages } from "../src/fetchers/top-languages.js"; + +// @ts-ignore +export default async (req, res) => { + const { + username, + hide_border, + card_width, + title_color, + icon_color, + text_color, + bg_color, + theme, + cache_seconds, + custom_title, + border_radius, + border_color, + disable_animations, + langs_count, + exclude_repo, + hide, + } = req.query; + res.setHeader("Content-Type", "image/svg+xml"); + + const access = guardAccess({ + res, + id: username, + type: "username", + colors: { + title_color, + text_color, + bg_color, + border_color, + theme, + }, + }); + if (!access.isPassed) { + return access.result; + } + + 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), + }, + ), + ); + } catch (err) { + setErrorCacheHeaders(res); + if (err instanceof Error) { + return res.send( + renderError({ + message: err.message, + secondaryMessage: retrieveSecondaryMessage(err), + renderOptions: { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: !(err instanceof MissingParamError), + }, + }), + ); + } + return res.send( + renderError({ + message: "An unknown error occurred", + renderOptions: { + title_color, + text_color, + bg_color, + border_color, + theme, + }, + }), + ); + } +}; diff --git a/src/cards/combined.js b/src/cards/combined.js new file mode 100644 index 0000000..533da15 --- /dev/null +++ b/src/cards/combined.js @@ -0,0 +1,376 @@ +// @ts-check + +import { Card } from "../common/Card.js"; +import { getCardColors } from "../common/color.js"; +import { icons } from "../common/icons.js"; +import { clampValue } from "../common/ops.js"; + +const CARD_DEFAULT_WIDTH = 800; +const STREAK_COLOR = "#FB8C00"; + +/** + * Format a number with commas. + * + * @param {number} value The number to format. + * @returns {string} Formatted number. + */ +const formatNumber = (value) => { + return value.toLocaleString("en-US"); +}; + +/** + * Format a date range string. + * + * @param {string} start Start date (YYYY-MM-DD). + * @param {string} end End date (YYYY-MM-DD). + * @returns {string} Formatted date range. + */ +const formatDateRange = (start, end) => { + if (!start || !end) { + return ""; + } + const opts = { month: "short", day: "numeric" }; + const s = new Date(start + "T00:00:00").toLocaleDateString("en-US", opts); + const e = new Date(end + "T00:00:00").toLocaleDateString("en-US", opts); + return `${s} - ${e}`; +}; + +/** + * Format a date range with year. + * + * @param {string} start Start date (YYYY-MM-DD). + * @param {string} end End date (YYYY-MM-DD). + * @returns {string} Formatted date range. + */ +const formatDateRangeWithYear = (start, end) => { + if (!start || !end) { + return ""; + } + const opts = { month: "short", day: "numeric", year: "numeric" }; + const s = new Date(start + "T00:00:00").toLocaleDateString("en-US", opts); + const e = new Date(end + "T00:00:00").toLocaleDateString("en-US", opts); + return `${s} - ${e}`; +}; + +// Icons for the stats section. +const statIcons = { + star: icons.star, + fork: icons.fork, + commits: icons.commits, + plus: ``, + eye: ``, + contribs: icons.contribs, +}; + +/** + * Renders the combined stats card. + * + * @param {object} data Combined data. + * @param {object} data.overview Overview stats. + * @param {object} data.streak Streak stats. + * @param {object} data.langs Top languages data (key-value object). + * @param {object} options Card options. + * @returns {string} Combined card SVG. + */ +const renderCombinedCard = (data, options = {}) => { + const { overview, streak, langs } = data; + + const { + hide_border = false, + card_width, + title_color, + icon_color, + text_color, + bg_color, + theme = "default", + custom_title, + border_radius, + border_color, + disable_animations = false, + langs_count = 8, + } = options; + + const width = + card_width && !isNaN(card_width) ? card_width : CARD_DEFAULT_WIDTH; + const leftColWidth = width * 0.5; + const rightColX = leftColWidth + 10; + const rightColWidth = width - rightColX - 25; + + // Colors. + const { titleColor, iconColor, textColor, bgColor, borderColor } = + getCardColors({ + title_color, + text_color, + icon_color, + bg_color, + border_color, + ring_color: title_color, + theme, + }); + + const streakColor = icon_color ? iconColor : STREAK_COLOR; + + // Stats rows. + const statsData = [ + { + icon: statIcons.star, + label: "Stars", + value: formatNumber(overview.totalStars), + }, + { + icon: statIcons.fork, + label: "Forks", + value: formatNumber(overview.totalForks), + }, + { + icon: statIcons.commits, + label: "All-time contributions", + value: formatNumber(overview.totalCommits), + }, + { + icon: statIcons.plus, + label: "Lines of code changed", + value: formatNumber(overview.linesChanged), + }, + { + icon: statIcons.eye, + label: "Repository views (past two weeks)", + value: formatNumber(overview.repoViews), + }, + { + icon: statIcons.contribs, + label: "Repositories with contributions", + value: formatNumber(overview.contributedTo), + }, + ]; + + const lineHeight = 25; + const statsHeight = statsData.length * lineHeight; + + // Streak section. + const streakSectionY = statsHeight + 10; + const streakHeight = 100; + + // Languages. + 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); + + // Calculate right column height: progress bar + lang list. + const langListHeight = topLangs.length * 20 + 30; + + // Total card height. + const contentHeight = Math.max(streakSectionY + streakHeight, langListHeight); + const height = contentHeight + 55; + + // Build stats SVG. + const statsItems = statsData + .map((stat, i) => { + const y = i * lineHeight; + const staggerDelay = (i + 3) * 150; + return ` + + + ${stat.icon} + + ${stat.label} + ${stat.value} + + `; + }) + .join(""); + + // Build streak SVG. + const currentDates = + streak.currentStreak > 0 + ? formatDateRange(streak.currentStreakStart, streak.currentStreakEnd) + : ""; + const longestDates = + streak.longestStreak > 0 + ? formatDateRangeWithYear( + streak.longestStreakStart, + streak.longestStreakEnd, + ) + : ""; + + const streakCenterLeft = leftColWidth * 0.3; + const streakCenterRight = leftColWidth * 0.7; + const streakMid = leftColWidth * 0.5; + + const streakSection = ` + + + + + + + + + + + + + + + + + + + + + + ${streak.currentStreak} + + Current Streak + + ${currentDates} + + + + + + ${streak.longestStreak} + + Longest Streak + + ${longestDates} + + `; + + // Build languages SVG. + const progressBarWidth = rightColWidth; + // Create stacked progress bar using SVG offsets. + let progressOffset = 0; + const progressSegments = topLangs + .map((lang) => { + const percent = (lang.size / totalSize) * progressBarWidth; + const segment = ``; + progressOffset += percent; + return segment; + }) + .join(""); + + const langItems = topLangs + .map((lang, i) => { + const percent = ((lang.size / totalSize) * 100).toFixed(2); + const y = i * 20; + const staggerDelay = (i + 3) * 150; + return ` + + + ${lang.name} ${percent}% + + `; + }) + .join(""); + + const languagesSection = ` + + + + + + ${progressSegments} + + + + ${langItems} + + + `; + + // Vertical divider between columns. + const colDivider = ``; + + // CSS. + const cssStyles = ` + .stat { + font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; + } + @supports(-moz-appearance: auto) { + .stat { font-size:12px; } + } + .stagger { + opacity: 0; + animation: fadeInAnimation 0.3s ease-in-out forwards; + } + .bold { font-weight: 700 } + .icon { + fill: ${iconColor}; + display: block; + } + .lang-name { + font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; + fill: ${textColor}; + } + .streak-number { + font: 700 24px 'Segoe UI', Ubuntu, Sans-Serif; + fill: ${textColor}; + } + .streak-label { + font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; + fill: ${textColor}; + } + .streak-label-current { + font: 700 12px 'Segoe UI', Ubuntu, Sans-Serif; + fill: ${streakColor}; + } + .streak-dates { + font: 400 10px 'Segoe UI', Ubuntu, Sans-Serif; + fill: #9E9E9E; + } + `; + + const apostrophe = /s$/i.test(overview.name.trim()) ? "" : "s"; + const defaultTitle = `${overview.name}'${apostrophe} GitHub Statistics`; + + const card = new Card({ + customTitle: custom_title, + defaultTitle, + width, + height, + border_radius, + colors: { + titleColor, + textColor, + iconColor, + bgColor, + borderColor, + }, + }); + + card.setHideBorder(hide_border); + card.setCSS(cssStyles); + + if (disable_animations) { + card.disableAnimations(); + } + + card.setAccessibilityLabel({ + title: defaultTitle, + desc: `Stars: ${overview.totalStars}, Forks: ${overview.totalForks}, Contributions: ${overview.totalCommits}, Current streak: ${streak.currentStreak} days, Longest streak: ${streak.longestStreak} days`, + }); + + return card.render(` + + + + ${statsItems} + ${streakSection} + + + ${colDivider} + + ${languagesSection} + + `); +}; + +export { renderCombinedCard }; +export default renderCombinedCard;