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.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 = `
+
+
+
+
+
+
+
+ ${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(`
+
+ `);
+};
+
+export { renderCombinedCard };
+export default renderCombinedCard;