Repository: anuraghazra/github-readme-stats
Pull request #3064 (2023-08-11 04:42:21)

Feature: Add gist card
This pull request adds new endpoint `https://github-readme-stats-2p1ibrtpu-github-readme-stats-team.vercel.app/api/gist?id=1e0f4ebcc7c35bdee2cae9ccf6fec6ec` which generates gist cards. Some examples provided below. What do you think @rickstaa @francois-rozet @anuraghazra @VydrOz?

![image](https://github-readme-stats-2p1ibrtpu-github-readme-stats-team.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d)

![image](https://github-readme-stats-2p1ibrtpu-github-readme-stats-team.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d&theme=react)

![image](https://github-readme-stats-2p1ibrtpu-github-readme-stats-team.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d&theme=neon)

![image](https://github-readme-stats-2p1ibrtpu-github-readme-stats-team.vercel.app/api/gist?id=5d7c1706904b7de18610530f467e935e)

![image](https://github-readme-stats-2p1ibrtpu-github-readme-stats-team.vercel.app/api/gist?id=5d7c1706904b7de18610530f467e935e&theme=react)

![image](https://github-readme-stats-2p1ibrtpu-github-readme-stats-team.vercel.app/api/gist?id=5d7c1706904b7de18610530f467e935e&theme=neon)

![image](https://github-readme-stats-2p1ibrtpu-github-readme-stats-team.vercel.app/api/gist?id=11df91d8a61f46a6f27f71b1bbf90c05)

![image](https://github-readme-stats-2p1ibrtpu-github-readme-stats-team.vercel.app/api/gist?id=1e0f4ebcc7c35bdee2cae9ccf6fec6ec)

Preview deployed there:

https://github-readme-stats-2p1ibrtpu-github-readme-stats-team.vercel.app/

TODOS:

- [x] docs
- [ ] tests
- [x] refactoring



vercel[bot]: [vc]: #Y9MEzj63fuDcjqqPRGa8m/rb0ZASbr6ILLLiqzbMaFY=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJnaXRodWItcmVhZG1lLXN0YXRzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2dpdGh1Yi1yZWFkbWUtc3RhdHMtdGVhbS9naXRodWItcmVhZG1lLXN0YXRzL0dMaU1ONGI5RzhOVDhKV3lLczlnWkM3NkJ2azUiLCJwcmV2aWV3VXJsIjoiZ2l0aHViLXJlYWRtZS1zdGF0cy1naXQtZmVhdHVyZS0yMmFmODYtZ2l0aHViLXJlYWRtZS1zdGF0cy10ZWFtLnZlcmNlbC5hcHAiLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated (UTC) |
| :--- | :----- | :------ | :------ |
| **github-readme-stats** | ✅ Ready ([Inspect](https://vercel.com/github-readme-stats-team/github-readme-stats/GLiMN4b9G8NT8JWyKs9gZC76Bvk5)) | [Visit Preview](https://github-readme-stats-git-feature-22af86-github-readme-stats-team.vercel.app) | Aug 11, 2023 4:42am |


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/anuraghazra/github-readme-stats/pull/3064?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra) Report
Patch and project coverage have no change.
> Comparison is base [(`c394ea3`)](https://app.codecov.io/gh/anuraghazra/github-readme-stats/commit/c394ea30ef889abe35bae465b208fa6163622346?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra) 97.65% compared to head [(`ad57cbd`)](https://app.codecov.io/gh/anuraghazra/github-readme-stats/pull/3064?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra) 97.65%.

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #3064   +/-   ##
=======================================
  Coverage   97.65%   97.65%           
=======================================
  Files          24       24           
  Lines        5341     5341           
  Branches      465      465           
=======================================
  Hits         5216     5216           
  Misses        124      124           
  Partials        1        1           
```




</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/anuraghazra/github-readme-stats/pull/3064?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Anurag+Hazra).

VydrOz: > This pull request adds new endpoint `http://localhost:3000/api/gist?username=rickstaa&id=1e0f4ebcc7c35bdee2cae9ccf6fec6ec` which generates gist cards. Some examples provided below. What do you think?

Perfect. I suppose we can use the same themes as the other cards.


Patch:
diff --git a/api/gist.js b/api/gist.js
new file mode 100644
index 0000000..4ef153d
--- /dev/null
+++ b/api/gist.js
@@ -0,0 +1,81 @@
+import {
+  clampValue,
+  CONSTANTS,
+  renderError,
+  parseBoolean,
+} from "../src/common/utils.js";
+import { isLocaleAvailable } from "../src/translations.js";
+import { renderGistCard } from "../src/cards/gist-card.js";
+import { fetchGist } from "../src/fetchers/gist-fetcher.js";
+
+export default async (req, res) => {
+  const {
+    id,
+    title_color,
+    icon_color,
+    text_color,
+    bg_color,
+    theme,
+    cache_seconds,
+    locale,
+    border_radius,
+    border_color,
+    show_owner,
+  } = req.query;
+
+  res.setHeader("Content-Type", "image/svg+xml");
+
+  if (locale && !isLocaleAvailable(locale)) {
+    return res.send(renderError("Something went wrong", "Language not found"));
+  }
+
+  try {
+    const gistData = await fetchGist(id);
+
+    let cacheSeconds = clampValue(
+      parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10),
+      CONSTANTS.FOUR_HOURS,
+      CONSTANTS.ONE_DAY,
+    );
+    cacheSeconds = process.env.CACHE_SECONDS
+      ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds
+      : cacheSeconds;
+
+    /*
+      if star count & fork count is over 1k then we are kFormating the text
+      and if both are zero we are not showing the stats
+      so we can just make the cache longer, since there is no need to frequent updates
+    */
+    const stars = gistData.starsCount;
+    const forks = gistData.forksCount;
+    const isBothOver1K = stars > 1000 && forks > 1000;
+    const isBothUnder1 = stars < 1 && forks < 1;
+    if (!cache_seconds && (isBothOver1K || isBothUnder1)) {
+      cacheSeconds = CONSTANTS.FOUR_HOURS;
+    }
+
+    res.setHeader(
+      "Cache-Control",
+      `max-age=${
+        cacheSeconds / 2
+      }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`,
+    );
+
+    return res.send(
+      renderGistCard(gistData, {
+        title_color,
+        icon_color,
+        text_color,
+        bg_color,
+        theme,
+        border_radius,
+        border_color,
+        locale: locale ? locale.toLowerCase() : null,
+        show_owner: parseBoolean(show_owner),
+      }),
+    );
+  } catch (err) {
+    res.setHeader("Cache-Control", `no-cache, no-store, must-revalidate`); // Don't cache error responses.
+    return res.send(renderError(err.message, err.secondaryMessage));
+  }
+};
diff --git a/readme.md b/readme.md
index 2760b19..caffaca 100644
--- a/readme.md
+++ b/readme.md
@@ -97,8 +97,11 @@ Please visit [this link](https://give.do/fundraisers/stand-beside-the-victims-of
 - [GitHub Extra Pins](#github-extra-pins)
     - [Usage](#usage)
     - [Demo](#demo)
-- [Top Languages Card](#top-languages-card)
+- [GitHub Gist Pins](#github-gist-pins)
     - [Usage](#usage-1)
+    - [Demo](#demo-1)
+- [Top Languages Card](#top-languages-card)
+    - [Usage](#usage-2)
     - [Language stats algorithm](#language-stats-algorithm)
     - [Exclude individual repositories](#exclude-individual-repositories)
     - [Hide individual languages](#hide-individual-languages)
@@ -108,9 +111,9 @@ Please visit [this link](https://give.do/fundraisers/stand-beside-the-victims-of
     - [Donut Vertical Chart Language Card Layout](#donut-vertical-chart-language-card-layout)
     - [Pie Chart Language Card Layout](#pie-chart-language-card-layout)
     - [Hide Progress Bars](#hide-progress-bars)
-    - [Demo](#demo-1)
-- [Wakatime Stats Card](#wakatime-stats-card)
     - [Demo](#demo-2)
+- [Wakatime Stats Card](#wakatime-stats-card)
+    - [Demo](#demo-3)
 - [All Demos](#all-demos)
   - [Quick Tip (Align The Cards)](#quick-tip-align-the-cards)
 - [Deploy on your own](#deploy-on-your-own)
@@ -156,10 +159,10 @@ You can pass a query parameter `&hide=` to hide any specific stats with comma-se
 
 You can pass a query parameter `&show=` to show any specific additional stats with comma-separated values.
 
-> Options: `&show=reviews,discussions_started,discussions_answered`
+> Options: `&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage`
 
 ```md
-![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show=reviews)
+![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage)
 ```
 
 ### Showing icons
@@ -319,7 +322,7 @@ You can provide multiple comma-separated values in the bg\_color option to rende
 *   `disable_animations` - Disables all animations in the card *(boolean)*. Default: `false`.
 *   `ring_color` - Color of the rank circle *(hex color)*. Defaults to the theme ring color if it exists and otherwise the title color.
 *   `number_format` - Switches between two available formats for displaying the card values `short` (i.e. `6.6k`) and `long` (i.e. `6626`). Default: `short`.
-*   `show` - Shows [additional items](#showing-additional-individual-stats) on stats card (i.e. `reviews`, `discussions_started` or `discussions_answered`) *(Comma-separated values)*. Default: `[] (blank array)`.
+*   `show` - Shows [additional items](#showing-additional-individual-stats) on stats card (i.e. `reviews`, `discussions_started`, `discussions_answered`, `prs_merged` or `prs_merged_percentage`) *(Comma-separated values)*. Default: `[] (blank array)`.
 
 > [!NOTE]\
 > When hide\_rank=`true`, the minimum card width is 270 px + the title length and padding.
@@ -328,6 +331,10 @@ You can provide multiple comma-separated values in the bg\_color option to rende
 
 *   `show_owner` - Shows the repo's owner name *(boolean)*. Default: `false`.
 
+#### Gist Card Exclusive Options
+
+*   `show_owner` - Shows the gist's owner name *(boolean)*. Default: `false`.
+
 #### Language Card Exclusive Options
 
 *   `hide` - Hides the languages specified from the card *(Comma-separated values)*. Default: `[] (blank array)`.
@@ -384,6 +391,28 @@ Use [show\_owner](#repo-card-exclusive-options) query option to include the repo
 
 ![Readme Card](https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra\&repo=github-readme-stats\&show_owner=true)
 
+# GitHub Gist Pins
+
+GitHub gist pins allow you to pin gists in your GitHub profile using a GitHub readme profile.
+
+### Usage
+
+Copy-paste this code into your readme and change the links.
+
+Endpoint: `api/gist?id=bbfce31e0217a3689c8d961a356cb10d`
+
+```md
+[![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d)](https://gist.github.com/Yizack/bbfce31e0217a3689c8d961a356cb10d/)
+```
+
+### Demo
+
+![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d)
+
+Use [show\_owner](#gist-card-exclusive-options) query option to include the gist's owner username
+
+![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d\&show_owner=true)
+
 # Top Languages Card
 
 The top languages card shows a GitHub user's most frequently used languages.
@@ -548,7 +577,7 @@ Change the `?username=` value to your [Wakatime](https://wakatime.com) username.
 
 *   Showing additional stats
 
-![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show=reviews,discussions_started,discussions_answered)
+![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra\&show=reviews,discussions_started,discussions_answered,prs_merged,prs_merged_percentage)
 
 *   Showing icons
 
@@ -592,6 +621,14 @@ Choose from any of the [default themes](#themes)
 
 ![Customized Card](https://github-readme-stats.vercel.app/api/pin?username=anuraghazra\&repo=github-readme-stats\&title_color=fff\&icon_color=f9f9f9\&text_color=9f9f9f\&bg_color=151515)
 
+*   Gist card
+
+![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d)
+
+*   Customizing gist card
+
+![Gist Card](https://github-readme-stats.vercel.app/api/gist?id=bbfce31e0217a3689c8d961a356cb10d&theme=calm)
+
 *   Top languages
 
 ![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra)
diff --git a/src/cards/gist-card.js b/src/cards/gist-card.js
new file mode 100644
index 0000000..895616c
--- /dev/null
+++ b/src/cards/gist-card.js
@@ -0,0 +1,180 @@
+// @ts-check
+
+import {
+  getCardColors,
+  parseEmojis,
+  wrapTextMultiline,
+  encodeHTML,
+  kFormatter,
+  measureText,
+  flexLayout,
+} from "../common/utils.js";
+import Card from "../common/Card.js";
+import { icons } from "../common/icons.js";
+
+/** Import language colors.
+ *
+ * @description Here we use the workaround found in
+ * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node
+ * since vercel is using v16.14.0 which does not yet support json imports without the
+ * --experimental-json-modules flag.
+ */
+import { createRequire } from "module";
+const require = createRequire(import.meta.url);
+const languageColors = require("../common/languageColors.json"); // now works
+
+const ICON_SIZE = 16;
+const CARD_DEFAULT_WIDTH = 400;
+const HEADER_MAX_LENGTH = 35;
+
+/**
+ * Creates a node to display the primary programming language of the gist.
+ *
+ * @param {string} langName Language name.
+ * @param {string} langColor Language color.
+ * @returns {string} Language display SVG object.
+ */
+const createLanguageNode = (langName, langColor) => {
+  return `
+    <g data-testid="primary-lang">
+      <circle data-testid="lang-color" cx="0" cy="-5" r="6" fill="${langColor}" />
+      <text data-testid="lang-name" class="gray" x="15">${langName}</text>
+    </g>
+    `;
+};
+
+/**
+ * Creates an icon with label to display gist stats like forks, stars, etc.
+ *
+ * @param {string} icon The icon to display.
+ * @param {number|string} label The label to display.
+ * @param {string} testid The testid to assign to the label.
+ * @returns {string} Icon with label SVG object.
+ */
+const iconWithLabel = (icon, label, testid) => {
+  if (typeof label === "number" && label <= 0) return "";
+  const iconSvg = `
+      <svg
+        class="icon"
+        y="-12"
+        viewBox="0 0 16 16"
+        version="1.1"
+        width="${ICON_SIZE}"
+        height="${ICON_SIZE}"
+      >
+        ${icon}
+      </svg>
+    `;
+  const text = `<text data-testid="${testid}" class="gray">${label}</text>`;
+  return flexLayout({ items: [iconSvg, text], gap: 20 }).join("");
+};
+
+/**
+ * @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options.
+ * @typedef {import('../fetchers/types').GistData} GistData Gist data.
+ */
+
+/**
+ * Render gist card.
+ *
+ * @param {GistData} gistData Gist data.
+ * @param {Partial<GistCardOptions>} options Gist card options.
+ * @returns {string} Gist card.
+ */
+const renderGistCard = (gistData, options = {}) => {
+  const { name, nameWithOwner, description, language, starsCount, forksCount } =
+    gistData;
+  const {
+    title_color,
+    icon_color,
+    text_color,
+    bg_color,
+    theme,
+    border_radius,
+    border_color,
+    show_owner = false,
+  } = options;
+
+  // returns theme based colors with proper overrides and defaults
+  const { titleColor, textColor, iconColor, bgColor, borderColor } =
+    getCardColors({
+      title_color,
+      icon_color,
+      text_color,
+      bg_color,
+      border_color,
+      theme,
+    });
+
+  const lineWidth = 59;
+  const linesLimit = 10;
+  const desc = parseEmojis(description || "No description provided");
+  const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit);
+  const descriptionLines = multiLineDescription.length;
+  const descriptionSvg = multiLineDescription
+    .map((line) => `<tspan dy="1.2em" x="25">${encodeHTML(line)}</tspan>`)
+    .join("");
+
+  const lineHeight = descriptionLines > 3 ? 12 : 10;
+  const height =
+    (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight;
+
+  const totalStars = kFormatter(starsCount);
+  const totalForks = kFormatter(forksCount);
+  const svgStars = iconWithLabel(icons.star, totalStars, "starsCount");
+  const svgForks = iconWithLabel(icons.fork, totalForks, "forksCount");
+
+  const languageName = language || "Unspecified";
+  const languageColor = languageColors[languageName] || "#858585";
+
+  const svgLanguage = createLanguageNode(languageName, languageColor);
+
+  const starAndForkCount = flexLayout({
+    items: [svgLanguage, svgStars, svgForks],
+    sizes: [
+      measureText(languageName, 12),
+      ICON_SIZE + measureText(`${totalStars}`, 12),
+      ICON_SIZE + measureText(`${totalForks}`, 12),
+    ],
+    gap: 25,
+  }).join("");
+
+  const header = show_owner ? nameWithOwner : name;
+
+  const card = new Card({
+    defaultTitle:
+      header.length > HEADER_MAX_LENGTH
+        ? `${header.slice(0, HEADER_MAX_LENGTH)}...`
+        : header,
+    titlePrefixIcon: icons.gist,
+    width: CARD_DEFAULT_WIDTH,
+    height,
+    border_radius,
+    colors: {
+      titleColor,
+      textColor,
+      iconColor,
+      bgColor,
+      borderColor,
+    },
+  });
+
+  card.setCSS(`
+    .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
+    .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
+    .icon { fill: ${iconColor} }
+  `);
+
+  return card.render(`
+    <text class="description" x="25" y="-5">
+        ${descriptionSvg}
+    </text>
+
+    <g transform="translate(30, ${height - 75})">
+        ${starAndForkCount}
+    </g>
+  `);
+};
+
+export { renderGistCard, HEADER_MAX_LENGTH };
+export default renderGistCard;
diff --git a/src/cards/stats-card.js b/src/cards/stats-card.js
index 7c5d437..778e676 100644
--- a/src/cards/stats-card.js
+++ b/src/cards/stats-card.js
@@ -28,6 +28,7 @@ const RANK_ONLY_CARD_DEFAULT_WIDTH = 290;
  * @param {string} createTextNodeParams.label The label to display.
  * @param {number} createTextNodeParams.value The value to display.
  * @param {string} createTextNodeParams.id The id of the stat.
+ * @param {string=} createTextNodeParams.unitSymbol The unit symbol of the stat.
  * @param {number} createTextNodeParams.index The index of the stat.
  * @param {boolean} createTextNodeParams.showIcons Whether to show icons.
  * @param {number} createTextNodeParams.shiftValuePos Number of pixels the value has to be shifted to the right.
@@ -40,6 +41,7 @@ const createTextNode = ({
   label,
   value,
   id,
+  unitSymbol,
   index,
   showIcons,
   shiftValuePos,
@@ -69,7 +71,7 @@ const createTextNode = ({
         x="${(showIcons ? 140 : 120) + shiftValuePos}"
         y="12.5"
         data-testid="${id}"
-      >${kValue}</text>
+      >${kValue}${unitSymbol ? ` ${unitSymbol}` : ""}</text>
     </g>
   `;
 };
@@ -93,6 +95,8 @@ const renderStatsCard = (stats, options = {}) => {
     totalCommits,
     totalIssues,
     totalPRs,
+    totalPRsMerged,
+    mergedPRsPercentage,
     totalReviews,
     totalDiscussionsStarted,
     totalDiscussionsAnswered,
@@ -171,6 +175,25 @@ const renderStatsCard = (stats, options = {}) => {
     id: "prs",
   };
 
+  if (show.includes("prs_merged")) {
+    STATS.prs_merged = {
+      icon: icons.prs_merged,
+      label: i18n.t("statcard.prs-merged"),
+      value: totalPRsMerged,
+      id: "prs_merged",
+    };
+  }
+
+  if (show.includes("prs_merged_percentage")) {
+    STATS.prs_merged_percentage = {
+      icon: icons.prs_merged_percentage,
+      label: i18n.t("statcard.prs-merged-percentage"),
+      value: mergedPRsPercentage.toFixed(2),
+      id: "prs_merged_percentage",
+      unitSymbol: "%",
+    };
+  }
+
   if (show.includes("reviews")) {
     STATS.reviews = {
       icon: icons.reviews,
@@ -235,7 +258,11 @@ const renderStatsCard = (stats, options = {}) => {
     .map((key, index) =>
       // create the text nodes, and pass index so that we can calculate the line spacing
       createTextNode({
-        ...STATS[key],
+        icon: STATS[key].icon,
+        label: STATS[key].label,
+        value: STATS[key].value,
+        id: STATS[key].id,
+        unitSymbol: STATS[key].unitSymbol,
         index,
         showIcons: show_icons,
         shiftValuePos: 79.01 + (isLongLocale ? 50 : 0),
diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts
index a3f0b2b..3274d8c 100644
--- a/src/cards/types.d.ts
+++ b/src/cards/types.d.ts
@@ -57,3 +57,7 @@ type WakaTimeOptions = CommonOptions & {
   layout: "compact" | "normal";
   langs_count: number;
 };
+
+export type GistCardOptions = CommonOptions & {
+  show_owner: boolean;
+};
diff --git a/src/common/icons.js b/src/common/icons.js
index 3f91d86..771704a 100644
--- a/src/common/icons.js
+++ b/src/common/icons.js
@@ -2,6 +2,8 @@ const icons = {
   star: `<path fill-rule="evenodd" d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25zm0 2.445L6.615 5.5a.75.75 0 01-.564.41l-3.097.45 2.24 2.184a.75.75 0 01.216.664l-.528 3.084 2.769-1.456a.75.75 0 01.698 0l2.77 1.456-.53-3.084a.75.75 0 01.216-.664l2.24-2.183-3.096-.45a.75.75 0 01-.564-.41L8 2.694v.001z"/>`,
   commits: `<path fill-rule="evenodd" d="M1.643 3.143L.427 1.927A.25.25 0 000 2.104V5.75c0 .138.112.25.25.25h3.646a.25.25 0 00.177-.427L2.715 4.215a6.5 6.5 0 11-1.18 4.458.75.75 0 10-1.493.154 8.001 8.001 0 101.6-5.684zM7.75 4a.75.75 0 01.75.75v2.992l2.028.812a.75.75 0 01-.557 1.392l-2.5-1A.75.75 0 017 8.25v-3.5A.75.75 0 017.75 4z"/>`,
   prs: `<path fill-rule="evenodd" d="M7.177 3.073L9.573.677A.25.25 0 0110 .854v4.792a.25.25 0 01-.427.177L7.177 3.427a.25.25 0 010-.354zM3.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122v5.256a2.251 2.251 0 11-1.5 0V5.372A2.25 2.25 0 011.5 3.25zM11 2.5h-1V4h1a1 1 0 011 1v5.628a2.251 2.251 0 101.5 0V5A2.5 2.5 0 0011 2.5zm1 10.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0zM3.75 12a.75.75 0 100 1.5.75.75 0 000-1.5z"/>`,
+  prs_merged: `<path fill-rule="evenodd" d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218ZM4.25 13.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm8.5-4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5ZM5 3.25a.75.75 0 1 0 0 .005V3.25Z" />`,
+  prs_merged_percentage: `<path fill-rule="evenodd" d="M13.442 2.558a.625.625 0 0 1 0 .884l-10 10a.625.625 0 1 1-.884-.884l10-10a.625.625 0 0 1 .884 0zM4.5 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5zm7 6a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 1a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z" />`,
   issues: `<path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm9 3a1 1 0 11-2 0 1 1 0 012 0zm-.25-6.25a.75.75 0 00-1.5 0v3.5a.75.75 0 001.5 0v-3.5z"/>`,
   icon: `<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>`,
   contribs: `<path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"/>`,
@@ -9,6 +11,7 @@ const icons = {
   reviews: `<path fill-rule="evenodd" d="M8 2c1.981 0 3.671.992 4.933 2.078 1.27 1.091 2.187 2.345 2.637 3.023a1.62 1.62 0 0 1 0 1.798c-.45.678-1.367 1.932-2.637 3.023C11.67 13.008 9.981 14 8 14c-1.981 0-3.671-.992-4.933-2.078C1.797 10.83.88 9.576.43 8.898a1.62 1.62 0 0 1 0-1.798c.45-.677 1.367-1.931 2.637-3.022C4.33 2.992 6.019 2 8 2ZM1.679 7.932a.12.12 0 0 0 0 .136c.411.622 1.241 1.75 2.366 2.717C5.176 11.758 6.527 12.5 8 12.5c1.473 0 2.825-.742 3.955-1.715 1.124-.967 1.954-2.096 2.366-2.717a.12.12 0 0 0 0-.136c-.412-.621-1.242-1.75-2.366-2.717C10.824 4.242 9.473 3.5 8 3.5c-1.473 0-2.825.742-3.955 1.715-1.124.967-1.954 2.096-2.366 2.717ZM8 10a2 2 0 1 1-.001-3.999A2 2 0 0 1 8 10Z"/>`,
   discussions_started: `<path fill-rule="evenodd" d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z" />`,
   discussions_answered: `<path fill-rule="evenodd" d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />`,
+  gist: `<path fill-rule="evenodd" d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z" />`,
 };
 
 /**
diff --git a/src/fetchers/gist-fetcher.js b/src/fetchers/gist-fetcher.js
new file mode 100644
index 0000000..2afc62a
--- /dev/null
+++ b/src/fetchers/gist-fetcher.js
@@ -0,0 +1,106 @@
+// @ts-check
+
+import { request } from "../common/utils.js";
+import { retryer } from "../common/retryer.js";
+
+/**
+ * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers.
+ * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response.
+ */
+
+const QUERY = `
+query gistInfo($gistName: String!) {
+    viewer {
+        gist(name: $gistName) {
+            description
+            owner {
+                login
+            }
+            stargazerCount
+            forks {
+                totalCount
+            }
+            files {
+                name
+                language {
+                    name
+                }
+                size
+            }
+        }
+    }
+}
+`;
+
+/**
+ * Gist data fetcher.
+ *
+ * @param {AxiosRequestHeaders} variables Fetcher variables.
+ * @param {string} token GitHub token.
+ * @returns {Promise<AxiosResponse>} The response.
+ */
+const fetcher = async (variables, token) => {
+  return await request(
+    { query: QUERY, variables },
+    { Authorization: `token ${token}` },
+  );
+};
+
+/**
+ * @typedef {import('./types').GistData} GistData Gist data.
+ */
+
+/**
+ * Fetch GitHub gist information by given username and ID.
+ *
+ * @param {string} id Github gist ID.
+ * @returns {Promise<GistData>} Gist data.
+ */
+const fetchGist = async (id) => {
+  const res = await retryer(fetcher, { gistName: id });
+  if (res.data.errors) throw new Error(res.data.errors[0].message);
+  const data = res.data.data.viewer.gist;
+  return {
+    name: data.files[Object.keys(data.files)[0]].name,
+    nameWithOwner: `${data.owner.login}/${
+      data.files[Object.keys(data.files)[0]].name
+    }`,
+    description: data.description,
+    language: calculatePrimaryLanguage(data.files),
+    starsCount: data.stargazerCount,
+    forksCount: data.forks.totalCount,
+  };
+};
+
+/**
+ * @typedef {{ name: string; language: { name: string; }, size: number }} GistFile Gist file.
+ */
+
+/**
+ * This function calculates the primary language of a gist by files size.
+ *
+ * @param {GistFile[]} files Files.
+ * @returns {string} Primary language.
+ */
+const calculatePrimaryLanguage = (files) => {
+  const languages = {};
+  for (const file of files) {
+    if (file.language) {
+      if (languages[file.language.name]) {
+        languages[file.language.name] += file.size;
+      } else {
+        languages[file.language.name] = file.size;
+      }
+    }
+  }
+  let primaryLanguage = Object.keys(languages)[0];
+  for (const language in languages) {
+    if (languages[language] > languages[primaryLanguage]) {
+      primaryLanguage = language;
+    }
+  }
+  return primaryLanguage;
+};
+
+export { fetchGist };
+export default fetchGist;
diff --git a/src/fetchers/stats-fetcher.js b/src/fetchers/stats-fetcher.js
index a1e2821..ac51d49 100644
--- a/src/fetchers/stats-fetcher.js
+++ b/src/fetchers/stats-fetcher.js
@@ -54,6 +54,9 @@ const GRAPHQL_STATS_QUERY = `
       pullRequests(first: 1) {
         totalCount
       }
+      mergedPullRequests: pullRequests(states: MERGED) {
+        totalCount
+      }
       openIssues: issues(states: OPEN) {
         totalCount
       }
@@ -140,7 +143,7 @@ const statsFetcher = async (username) => {
 /**
  * Fetch all the commits for all the repositories of a given username.
  *
- * @param {*} username GitHub username.
+ * @param {string} username GitHub username.
  * @returns {Promise<number>} Total commits.
  *
  * @description Done like this because the GitHub API does not provide a way to fetch all the commits. See
@@ -201,6 +204,8 @@ const fetchStats = async (
   const stats = {
     name: "",
     totalPRs: 0,
+    totalPRsMerged: 0,
+    mergedPRsPercentage: 0,
     totalReviews: 0,
     totalCommits: 0,
     totalIssues: 0,
@@ -246,6 +251,9 @@ const fetchStats = async (
   }
 
   stats.totalPRs = user.pullRequests.totalCount;
+  stats.totalPRsMerged = user.mergedPullRequests.totalCount;
+  stats.mergedPRsPercentage =
+    (user.mergedPullRequests.totalCount / user.pullRequests.totalCount) * 100;
   stats.totalReviews =
     user.contributionsCollection.totalPullRequestReviewContributions;
   stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount;
diff --git a/src/fetchers/types.d.ts b/src/fetchers/types.d.ts
index 3a86205..73c390e 100644
--- a/src/fetchers/types.d.ts
+++ b/src/fetchers/types.d.ts
@@ -1,3 +1,12 @@
+export type GistData = {
+  name: string;
+  nameWithOwner: string;
+  description: string;
+  language: string | null;
+  starsCount: number;
+  forksCount: number;
+};
+
 export type RepositoryData = {
   name: string;
   nameWithOwner: string;
@@ -18,6 +27,8 @@ export type RepositoryData = {
 export type StatsData = {
   name: string;
   totalPRs: number;
+  totalPRsMerged: number;
+  mergedPRsPercentage: number;
   totalReviews: number;
   totalCommits: number;
   totalIssues: number;
diff --git a/src/translations.js b/src/translations.js
index b39f17b..1ced5f6 100644
--- a/src/translations.js
+++ b/src/translations.js
@@ -325,6 +325,66 @@ const statCardLocales = ({ name, apostrophe }) => {
       vi: "Tổng Số Thảo Luận Đã Trả Lời",
       se: "Totalt antal diskussioner besvarade",
     },
+    "statcard.prs-merged": {
+      ar: "مجموع الطلبات المدمجة",
+      cn: "合并的 PR 总数",
+      "zh-tw": "合併的 PR 總數",
+      cs: "Celkem sloučených PR",
+      de: "Insgesamt zusammengeführte PRs",
+      en: "Total PRs Merged",
+      bn: "সর্বমোট PR একত্রীকৃত",
+      es: "PR totales fusionados",
+      fr: "Nombre total de PR fusionnés",
+      hu: "Összes egyesített PR",
+      it: "PR totali uniti",
+      ja: "マージされた PR の総数",
+      kr: "병합된 총 PR",
+      nl: "Totaal samengevoegde PR's",
+      "pt-pt": "Total de PRs Fundidos",
+      "pt-br": "Total de PRs Fundidos",
+      np: "कुल PRs मर्ज गरिएको",
+      el: "Σύνολο Συγχωνευμένων PR",
+      ru: "Всего объединённых pull request`ов",
+      "uk-ua": "Всього об'єднаних pull request`iв",
+      id: "Total PR Digabungkan",
+      my: "Jumlah PR Digabungkan",
+      sk: "Celkový počet zlúčených PR",
+      tr: "Toplam Birleştirilmiş PR",
+      pl: "Łącznie połączonych PR",
+      uz: "Birlangan PR-lar soni",
+      vi: "Tổng Số PR Đã Hợp Nhất",
+      se: "Totalt antal sammanfogade PR",
+    },
+    "statcard.prs-merged-percentage": {
+      ar: "نسبة الطلبات المدمجة",
+      cn: "合并的 PR 百分比",
+      "zh-tw": "合併的 PR 百分比",
+      cs: "Sloučené PRs v procentech",
+      de: "Zusammengeführte PRs in Prozent",
+      en: "Merged PRs Percentage",
+      bn: "PR একত্রীকরণের শতাংশ",
+      es: "Porcentaje de PR fusionados",
+      fr: "Pourcentage de PR fusionnés",
+      hu: "Egyesített PR-k százaléka",
+      it: "Percentuale di PR uniti",
+      ja: "マージされた PR の割合",
+      kr: "병합된 PR의 비율",
+      nl: "Percentage samengevoegde PR's",
+      "pt-pt": "Percentagem de PRs Fundidos",
+      "pt-br": "Porcentagem de PRs Fundidos",
+      np: "PR मर्ज गरिएको प्रतिशत",
+      el: "Ποσοστό Συγχωνευμένων PR",
+      ru: "Процент объединённых pull request`ов",
+      "uk-ua": "Відсоток об'єднаних pull request`iв",
+      id: "Persentase PR Digabungkan",
+      my: "Peratus PR Digabungkan",
+      sk: "Percento zlúčených PR",
+      tr: "Birleştirilmiş PR Yüzdesi",
+      pl: "Procent połączonych PR",
+      uz: "Birlangan PR-lar foizi",
+      vi: "Tỷ Lệ PR Đã Hợp Nhất",
+      se: "Procent av sammanfogade PR",
+    },
   };
 };
 


Test patch:
diff --git a/tests/api.test.js b/tests/api.test.js
index 3c72583..567d2c1 100644
--- a/tests/api.test.js
+++ b/tests/api.test.js
@@ -13,6 +13,8 @@ const stats = {
   totalCommits: 200,
   totalIssues: 300,
   totalPRs: 400,
+  totalPRsMerged: 320,
+  mergedPRsPercentage: 80,
   totalReviews: 50,
   totalDiscussionsStarted: 10,
   totalDiscussionsAnswered: 40,
@@ -41,6 +43,7 @@ const data_stats = {
         totalPullRequestReviewContributions: stats.totalReviews,
       },
       pullRequests: { totalCount: stats.totalPRs },
+      mergedPullRequests: { totalCount: stats.totalPRsMerged },
       openIssues: { totalCount: stats.totalIssues },
       closedIssues: { totalCount: 0 },
       followers: { totalCount: 0 },
diff --git a/tests/e2e/e2e.test.js b/tests/e2e/e2e.test.js
index e220d52..2ff55e7 100644
--- a/tests/e2e/e2e.test.js
+++ b/tests/e2e/e2e.test.js
@@ -9,10 +9,13 @@ import { renderRepoCard } from "../../src/cards/repo-card.js";
 import { renderStatsCard } from "../../src/cards/stats-card.js";
 import { renderTopLanguages } from "../../src/cards/top-languages-card.js";
 import { renderWakatimeCard } from "../../src/cards/wakatime-card.js";
+import { renderGistCard } from "../../src/cards/gist-card.js";
 import { expect, describe, beforeAll, test } from "@jest/globals";
 
 const REPO = "curly-fiesta";
 const USER = "catelinemnemosyne";
+const GIST_ID = "372cef55fd897b31909fdeb3a7262758";
+
 const STATS_DATA = {
   name: "Cateline Mnemosyne",
   totalPRs: 2,
@@ -81,6 +84,23 @@ const REPOSITORY_DATA = {
   starCount: 1,
 };
 
+/**
+ * @typedef {import("../../src/fetchers/types").GistData} GistData Gist data type.
+ */
+
+/**
+ * @type {GistData}
+ */
+const GIST_DATA = {
+  name: "link.txt",
+  nameWithOwner: "qwerty541/link.txt",
+  description:
+    "Trying to access this path on Windown 10 ver. 1803+ will breaks NTFS",
+  language: "Text",
+  starsCount: 1,
+  forksCount: 0,
+};
+
 const CACHE_BURST_STRING = `v=${new Date().getTime()}`;
 
 describe("Fetch Cards", () => {
@@ -177,4 +197,26 @@ describe("Fetch Cards", () => {
     // Check if Repo card from deployment matches the local Repo card.
     expect(serverRepoSvg.data).toEqual(localRepoCardSVG);
   }, 15000);
+
+  test("retrieve gist card", async () => {
+    expect(VERCEL_PREVIEW_URL).toBeDefined();
+
+    // Check if the Vercel preview instance Gist function is up and running.
+    await expect(
+      axios.get(
+        `${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
+      ),
+    ).resolves.not.toThrow();
+
+    // Get local gist card.
+    const localGistCardSVG = renderGistCard(GIST_DATA);
+
+    // Get the Vercel preview gist card response.
+    const serverGistSvg = await axios.get(
+      `${VERCEL_PREVIEW_URL}/api/gist?id=${GIST_ID}&${CACHE_BURST_STRING}`,
+    );
+
+    // Check if Gist card from deployment matches the local Gist card.
+    expect(serverGistSvg.data).toEqual(localGistCardSVG);
+  }, 15000);
 });
diff --git a/tests/fetchGist.test.js b/tests/fetchGist.test.js
new file mode 100644
index 0000000..2cdffcf
--- /dev/null
+++ b/tests/fetchGist.test.js
@@ -0,0 +1,72 @@
+import "@testing-library/jest-dom";
+import axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { expect, it, describe, afterEach } from "@jest/globals";
+import { fetchGist } from "../src/fetchers/gist-fetcher.js";
+
+const gist_data = {
+  data: {
+    viewer: {
+      gist: {
+        description:
+          "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
+        owner: {
+          login: "Yizack",
+        },
+        stargazerCount: 33,
+        forks: {
+          totalCount: 11,
+        },
+        files: [
+          {
+            name: "countries.json",
+            language: {
+              name: "JSON",
+            },
+            size: 85858,
+          },
+        ],
+      },
+    },
+  },
+};
+
+const gist_errors_data = {
+  errors: [
+    {
+      message: "Some test GraphQL error",
+    },
+  ],
+};
+
+const mock = new MockAdapter(axios);
+
+afterEach(() => {
+  mock.reset();
+});
+
+describe("Test fetchGist", () => {
+  it("should fetch gist correctly", async () => {
+    mock.onPost("https://api.github.com/graphql").reply(200, gist_data);
+
+    let gist = await fetchGist("bbfce31e0217a3689c8d961a356cb10d");
+
+    expect(gist).toStrictEqual({
+      name: "countries.json",
+      nameWithOwner: "Yizack/countries.json",
+      description:
+        "List of countries and territories in English and Spanish: name, continent, capital, dial code, country codes, TLD, and area in sq km. Lista de países y territorios en Inglés y Español: nombre, continente, capital, código de teléfono, códigos de país, dominio y área en km cuadrados. Updated 2023",
+      language: "JSON",
+      starsCount: 33,
+      forksCount: 11,
+    });
+  });
+
+  it("should throw error if reaponse contains them", async () => {
+    mock.onPost("https://api.github.com/graphql").reply(200, gist_errors_data);
+
+    await expect(fetchGist("bbfce31e0217a3689c8d961a356cb10d")).rejects.toThrow(
+      "Some test GraphQL error",
+    );
+  });
+});
diff --git a/tests/fetchStats.test.js b/tests/fetchStats.test.js
index 80b19bf..6db4ef4 100644
--- a/tests/fetchStats.test.js
+++ b/tests/fetchStats.test.js
@@ -16,6 +16,7 @@ const data_stats = {
         totalPullRequestReviewContributions: 50,
       },
       pullRequests: { totalCount: 300 },
+      mergedPullRequests: { totalCount: 240 },
       openIssues: { totalCount: 100 },
       closedIssues: { totalCount: 100 },
       followers: { totalCount: 100 },
@@ -121,6 +122,8 @@ describe("Test fetchStats", () => {
       totalCommits: 100,
       totalIssues: 200,
       totalPRs: 300,
+      totalPRsMerged: 240,
+      mergedPRsPercentage: 80,
       totalReviews: 50,
       totalStars: 300,
       totalDiscussionsStarted: 10,
@@ -155,6 +158,8 @@ describe("Test fetchStats", () => {
       totalCommits: 100,
       totalIssues: 200,
       totalPRs: 300,
+      totalPRsMerged: 240,
+      mergedPRsPercentage: 80,
       totalReviews: 50,
       totalStars: 300,
       totalDiscussionsStarted: 10,
@@ -195,6 +200,8 @@ describe("Test fetchStats", () => {
       totalCommits: 1000,
       totalIssues: 200,
       totalPRs: 300,
+      totalPRsMerged: 240,
+      mergedPRsPercentage: 80,
       totalReviews: 50,
       totalStars: 300,
       totalDiscussionsStarted: 10,
@@ -226,6 +233,8 @@ describe("Test fetchStats", () => {
       totalCommits: 1000,
       totalIssues: 200,
       totalPRs: 300,
+      totalPRsMerged: 240,
+      mergedPRsPercentage: 80,
       totalReviews: 50,
       totalStars: 200,
       totalDiscussionsStarted: 10,
@@ -255,6 +264,8 @@ describe("Test fetchStats", () => {
       totalCommits: 100,
       totalIssues: 200,
       totalPRs: 300,
+      totalPRsMerged: 240,
+      mergedPRsPercentage: 80,
       totalReviews: 50,
       totalStars: 400,
       totalDiscussionsStarted: 10,
@@ -284,6 +295,8 @@ describe("Test fetchStats", () => {
       totalCommits: 100,
       totalIssues: 200,
       totalPRs: 300,
+      totalPRsMerged: 240,
+      mergedPRsPercentage: 80,
       totalReviews: 50,
       totalStars: 300,
       totalDiscussionsStarted: 10,
@@ -313,6 +326,8 @@ describe("Test fetchStats", () => {
       totalCommits: 100,
       totalIssues: 200,
       totalPRs: 300,
+      totalPRsMerged: 240,
+      mergedPRsPercentage: 80,
       totalReviews: 50,
       totalStars: 300,
       totalDiscussionsStarted: 10,
diff --git a/tests/renderGistCard.test.js b/tests/renderGistCard.test.js
new file mode 100644
index 0000000..2af6889
--- /dev/null
+++ b/tests/renderGistCard.test.js
@@ -0,0 +1,79 @@
+import { renderGistCard } from "../src/cards/gist-card";
+import { describe, expect, it } from "@jest/globals";
+import { queryByTestId } from "@testing-library/dom";
+import "@testing-library/jest-dom";
+
+/**
+ * @type {import("../src/fetchers/gist-fetcher").GistData}
+ */
+const data = {
+  name: "test",
+  nameWithOwner: "anuraghazra/test",
+  description: "Small test repository with different Python programs.",
+  language: "Python",
+  starsCount: 163,
+  forksCount: 19,
+};
+
+describe("test renderGistCard", () => {
+  it("should render correctly", () => {
+    document.body.innerHTML = renderGistCard(data);
+
+    const [header] = document.getElementsByClassName("header");
+
+    expect(header).toHaveTextContent("test");
+    expect(header).not.toHaveTextContent("anuraghazra");
+    expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
+      "Small test repository with different Python programs.",
+    );
+    expect(queryByTestId(document.body, "starsCount")).toHaveTextContent("163");
+    expect(queryByTestId(document.body, "forksCount")).toHaveTextContent("19");
+    expect(queryByTestId(document.body, "lang-name")).toHaveTextContent(
+      "Python",
+    );
+    expect(queryByTestId(document.body, "lang-color")).toHaveAttribute(
+      "fill",
+      "#3572A5",
+    );
+  });
+
+  it("should display username in title if show_owner is true", () => {
+    document.body.innerHTML = renderGistCard(data, { show_owner: true });
+    const [header] = document.getElementsByClassName("header");
+    expect(header).toHaveTextContent("anuraghazra/test");
+  });
+
+  it("should trim header if name is too long", () => {
+    document.body.innerHTML = renderGistCard({
+      ...data,
+      name: "some-really-long-repo-name-for-test-purposes",
+    });
+    const [header] = document.getElementsByClassName("header");
+    expect(header).toHaveTextContent("some-really-long-repo-name-for-test...");
+  });
+
+  it("should trim description if description os too long", () => {
+    document.body.innerHTML = renderGistCard({
+      ...data,
+      description:
+        "The quick brown fox jumps over the lazy dog is an English-language pangram—a sentence that contains all of the letters of the English alphabet",
+    });
+    expect(
+      document.getElementsByClassName("description")[0].children[0].textContent,
+    ).toBe("The quick brown fox jumps over the lazy dog is an");
+
+    expect(
+      document.getElementsByClassName("description")[0].children[1].textContent,
+    ).toBe("English-language pangram—a sentence that contains all");
+  });
+
+  it("should not trim description if it is short", () => {
+    document.body.innerHTML = renderGistCard({
+      ...data,
+      description: "Small text should not trim",
+    });
+    expect(document.getElementsByClassName("description")[0]).toHaveTextContent(
+      "Small text should not trim",
+    );
+  });
+});
diff --git a/tests/renderStatsCard.test.js b/tests/renderStatsCard.test.js
index 174aa07..fbc02a9 100644
--- a/tests/renderStatsCard.test.js
+++ b/tests/renderStatsCard.test.js
@@ -18,6 +18,8 @@ const stats = {
   totalCommits: 200,
   totalIssues: 300,
   totalPRs: 400,
+  totalPRsMerged: 320,
+  mergedPRsPercentage: 80,
   totalReviews: 50,
   totalDiscussionsStarted: 10,
   totalDiscussionsAnswered: 50,
@@ -52,6 +54,10 @@ describe("Test renderStatsCard", () => {
     expect(
       queryByTestId(document.body, "discussions_answered"),
     ).not.toBeInTheDocument();
+    expect(queryByTestId(document.body, "prs_merged")).not.toBeInTheDocument();
+    expect(
+      queryByTestId(document.body, "prs_merged_percentage"),
+    ).not.toBeInTheDocument();
   });
 
   it("should have proper name apostrophe", () => {
@@ -85,16 +91,24 @@ describe("Test renderStatsCard", () => {
     expect(queryByTestId(document.body, "reviews")).toBeNull();
     expect(queryByTestId(document.body, "discussions_started")).toBeNull();
     expect(queryByTestId(document.body, "discussions_answered")).toBeNull();
+    expect(queryByTestId(document.body, "prs_merged")).toBeNull();
+    expect(queryByTestId(document.body, "prs_merged_percentage")).toBeNull();
   });
 
   it("should show additional stats", () => {
     document.body.innerHTML = renderStatsCard(stats, {
-      show: ["reviews", "discussions_started", "discussions_answered"],
+      show: [
+        "reviews",
+        "discussions_started",
+        "discussions_answered",
+        "prs_merged",
+        "prs_merged_percentage",
+      ],
     });
 
     expect(
       document.body.getElementsByTagName("svg")[0].getAttribute("height"),
-    ).toBe("270");
+    ).toBe("320");
 
     expect(queryByTestId(document.body, "stars")).toBeDefined();
     expect(queryByTestId(document.body, "commits")).toBeDefined();
@@ -104,6 +118,8 @@ describe("Test renderStatsCard", () => {
     expect(queryByTestId(document.body, "reviews")).toBeDefined();
     expect(queryByTestId(document.body, "discussions_started")).toBeDefined();
     expect(queryByTestId(document.body, "discussions_answered")).toBeDefined();
+    expect(queryByTestId(document.body, "prs_merged")).toBeDefined();
+    expect(queryByTestId(document.body, "prs_merged_percentage")).toBeDefined();
   });
 
   it("should hide_rank", () => {