diff --git a/api/top-langs.js b/api/top-langs.js
index d63463de..10ecc5d9 100644
--- a/api/top-langs.js
+++ b/api/top-langs.js
@@ -34,6 +34,7 @@ export default async (req, res) => {
border_color,
disable_animations,
hide_progress,
+ stats_format,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
@@ -85,6 +86,16 @@ export default async (req, res) => {
);
}
+ if (
+ stats_format !== undefined &&
+ (typeof stats_format !== "string" ||
+ !["bytes", "percentages"].includes(stats_format))
+ ) {
+ return res.send(
+ renderError("Something went wrong", "Incorrect stats_format input"),
+ );
+ }
+
try {
const topLangs = await fetchTopLanguages(
username,
@@ -125,6 +136,7 @@ export default async (req, res) => {
locale: locale ? locale.toLowerCase() : null,
disable_animations: parseBoolean(disable_animations),
hide_progress: parseBoolean(hide_progress),
+ stats_format,
}),
);
} catch (err) {
diff --git a/readme.md b/readme.md
index cb137590..a6df0d08 100644
--- a/readme.md
+++ b/readme.md
@@ -74,6 +74,7 @@
- [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)
+ - [Change format of language's stats](#change-format-of-languages-stats)
- [Demo](#demo-2)
- [WakaTime Stats Card](#wakatime-stats-card)
- [Options](#options-3)
@@ -468,6 +469,7 @@ You can customize the appearance and behavior of the top languages card using th
| `hide_progress` | Uses the compact layout option, hides percentages, and removes the bars. | boolean | `false` |
| `size_weight` | Configures language stats algorithm (see [Language stats algorithm](#language-stats-algorithm)). | integer | `1` |
| `count_weight` | Configures language stats algorithm (see [Language stats algorithm](#language-stats-algorithm)). | integer | `0` |
+| `stats_format` | Switches between two available formats for language's stats `percentages` and `bytes`. | enum | `percentages` |
> [!WARNING]\
> Language names should be URI-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding)
@@ -556,6 +558,15 @@ You can use the `&hide_progress=true` option to hide the percentages and the pro

```
+### Change format of language's stats
+
+You can use the `&stats_format=bytes` option to display the stats in bytes instead of percentage.
+
+```md
+
+```
+
+
### Demo

@@ -580,6 +591,11 @@ You can use the `&hide_progress=true` option to hide the percentages and the pro

+
+* Display bytes instead of percentage
+
+
+
# WakaTime Stats Card
> [!WARNING]\
diff --git a/src/cards/top-languages.js b/src/cards/top-languages.js
index 9385f4a7..fec72c7c 100644
--- a/src/cards/top-languages.js
+++ b/src/cards/top-languages.js
@@ -9,6 +9,7 @@ import {
getCardColors,
lowercaseTrim,
measureText,
+ formatBytes,
} from "../common/utils.js";
import { langCardLocales } from "../translations.js";
@@ -196,6 +197,18 @@ const trimTopLanguages = (topLangs, langs_count, hide) => {
return { langs, totalLanguageSize };
};
+/**
+ * Get display value corresponding to the format.
+ *
+ * @param {number} size Bytes size.
+ * @param {number} percentages Percentage value.
+ * @param {string} format Format of the stats.
+ * @returns {string} Display value.
+ */
+const getDisplayValue = (size, percentages, format) => {
+ return format === "bytes" ? formatBytes(size) : `${percentages.toFixed(2)}%`;
+};
+
/**
* Create progress bar text item for a programming language.
*
@@ -203,20 +216,33 @@ const trimTopLanguages = (topLangs, langs_count, hide) => {
* @param {number} props.width The card width
* @param {string} props.color Color of the programming language.
* @param {string} props.name Name of the programming language.
- * @param {number} props.progress Usage of the programming language in percentage.
+ * @param {number} props.size Size of the programming language.
+ * @param {number} props.totalSize Total size of all languages.
+ * @param {string} props.statsFormat Stats format.
* @param {number} props.index Index of the programming language.
* @returns {string} Programming language SVG node.
*/
-const createProgressTextNode = ({ width, color, name, progress, index }) => {
+const createProgressTextNode = ({
+ width,
+ color,
+ name,
+ size,
+ totalSize,
+ statsFormat,
+ index,
+}) => {
const staggerDelay = (index + 3) * 150;
const paddingRight = 95;
const progressTextX = width - paddingRight + 10;
const progressWidth = width - paddingRight;
+ const progress = (size / totalSize) * 100;
+ const displayValue = getDisplayValue(size, progress, statsFormat);
+
return `
${name}
- ${progress}%
+ ${displayValue}
${createProgressNode({
x: 0,
y: 25,
@@ -237,11 +263,20 @@ const createProgressTextNode = ({ width, color, name, progress, index }) => {
* @param {Lang} props.lang Programming language object.
* @param {number} props.totalSize Total size of all languages.
* @param {boolean=} props.hideProgress Whether to hide percentage.
+ * @param {string=} props.statsFormat Stats format
* @param {number} props.index Index of the programming language.
* @returns {string} Compact layout programming language SVG node.
*/
-const createCompactLangNode = ({ lang, totalSize, hideProgress, index }) => {
- const percentage = ((lang.size / totalSize) * 100).toFixed(2);
+const createCompactLangNode = ({
+ lang,
+ totalSize,
+ hideProgress,
+ statsFormat = "percentages",
+ index,
+}) => {
+ const percentages = (lang.size / totalSize) * 100;
+ const displayValue = getDisplayValue(lang.size, percentages, statsFormat);
+
const staggerDelay = (index + 3) * 150;
const color = lang.color || "#858585";
@@ -249,7 +284,7 @@ const createCompactLangNode = ({ lang, totalSize, hideProgress, index }) => {
- ${lang.name} ${hideProgress ? "" : percentage + "%"}
+ ${lang.name} ${hideProgress ? "" : displayValue}
`;
@@ -262,9 +297,15 @@ const createCompactLangNode = ({ lang, totalSize, hideProgress, index }) => {
* @param {Lang[]} props.langs Array of programming languages.
* @param {number} props.totalSize Total size of all languages.
* @param {boolean=} props.hideProgress Whether to hide percentage.
+ * @param {string=} props.statsFormat Stats format
* @returns {string} Programming languages SVG node.
*/
-const createLanguageTextNode = ({ langs, totalSize, hideProgress }) => {
+const createLanguageTextNode = ({
+ langs,
+ totalSize,
+ hideProgress,
+ statsFormat,
+}) => {
const longestLang = getLongestLang(langs);
const chunked = chunkArray(langs, langs.length / 2);
const layouts = chunked.map((array) => {
@@ -274,6 +315,7 @@ const createLanguageTextNode = ({ langs, totalSize, hideProgress }) => {
lang,
totalSize,
hideProgress,
+ statsFormat,
index,
}),
);
@@ -299,15 +341,17 @@ const createLanguageTextNode = ({ langs, totalSize, hideProgress }) => {
* @param {object} props Function properties.
* @param {Lang[]} props.langs Array of programming languages.
* @param {number} props.totalSize Total size of all languages.
+ * @param {string} props.statsFormat Stats format
* @returns {string} Donut layout programming language SVG node.
*/
-const createDonutLanguagesNode = ({ langs, totalSize }) => {
+const createDonutLanguagesNode = ({ langs, totalSize, statsFormat }) => {
return flexLayout({
items: langs.map((lang, index) => {
return createCompactLangNode({
lang,
totalSize,
hideProgress: false,
+ statsFormat,
index,
});
}),
@@ -322,18 +366,19 @@ const createDonutLanguagesNode = ({ langs, totalSize }) => {
* @param {Lang[]} langs Array of programming languages.
* @param {number} width Card width.
* @param {number} totalLanguageSize Total size of all languages.
+ * @param {string} statsFormat Stats format.
* @returns {string} Normal layout card SVG object.
*/
-const renderNormalLayout = (langs, width, totalLanguageSize) => {
+const renderNormalLayout = (langs, width, totalLanguageSize, statsFormat) => {
return flexLayout({
items: langs.map((lang, index) => {
return createProgressTextNode({
width,
name: lang.name,
color: lang.color || DEFAULT_LANG_COLOR,
- progress: parseFloat(
- ((lang.size / totalLanguageSize) * 100).toFixed(2),
- ),
+ size: lang.size,
+ totalSize: totalLanguageSize,
+ statsFormat,
index,
});
}),
@@ -349,9 +394,16 @@ const renderNormalLayout = (langs, width, totalLanguageSize) => {
* @param {number} width Card width.
* @param {number} totalLanguageSize Total size of all languages.
* @param {boolean=} hideProgress Whether to hide progress bar.
+ * @param {string} statsFormat Stats format.
* @returns {string} Compact layout card SVG object.
*/
-const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => {
+const renderCompactLayout = (
+ langs,
+ width,
+ totalLanguageSize,
+ hideProgress,
+ statsFormat = "percentages",
+) => {
const paddingRight = 50;
const offsetWidth = width - paddingRight;
// progressOffset holds the previous language's width and used to offset the next language
@@ -397,6 +449,7 @@ const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => {
langs,
totalSize: totalLanguageSize,
hideProgress,
+ statsFormat,
})}
`;
@@ -407,9 +460,10 @@ const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => {
*
* @param {Lang[]} langs Array of programming languages.
* @param {number} totalLanguageSize Total size of all languages.
+ * @param {string} statsFormat Stats format.
* @returns {string} Compact layout card SVG object.
*/
-const renderDonutVerticalLayout = (langs, totalLanguageSize) => {
+const renderDonutVerticalLayout = (langs, totalLanguageSize, statsFormat) => {
// Donut vertical chart radius and total length
const radius = 80;
const totalCircleLength = getCircleLength(radius);
@@ -465,6 +519,7 @@ const renderDonutVerticalLayout = (langs, totalLanguageSize) => {
langs,
totalSize: totalLanguageSize,
hideProgress: false,
+ statsFormat,
})}
@@ -477,9 +532,10 @@ const renderDonutVerticalLayout = (langs, totalLanguageSize) => {
*
* @param {Lang[]} langs Array of programming languages.
* @param {number} totalLanguageSize Total size of all languages.
+ * @param {string} statsFormat Stats format.
* @returns {string} Compact layout card SVG object.
*/
-const renderPieLayout = (langs, totalLanguageSize) => {
+const renderPieLayout = (langs, totalLanguageSize, statsFormat) => {
// Pie chart radius and center coordinates
const radius = 90;
const centerX = 150;
@@ -560,6 +616,7 @@ const renderPieLayout = (langs, totalLanguageSize) => {
langs,
totalSize: totalLanguageSize,
hideProgress: false,
+ statsFormat,
})}
@@ -610,9 +667,10 @@ const createDonutPaths = (cx, cy, radius, percentages) => {
* @param {Lang[]} langs Array of programming languages.
* @param {number} width Card width.
* @param {number} totalLanguageSize Total size of all languages.
+ * @param {string} statsFormat Stats format.
* @returns {string} Donut layout card SVG object.
*/
-const renderDonutLayout = (langs, width, totalLanguageSize) => {
+const renderDonutLayout = (langs, width, totalLanguageSize, statsFormat) => {
const centerX = width / 3;
const centerY = width / 3;
const radius = centerX - 60;
@@ -655,7 +713,7 @@ const renderDonutLayout = (langs, width, totalLanguageSize) => {
return `
- ${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize })}
+ ${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize, statsFormat })}
@@ -738,6 +796,7 @@ const renderTopLanguages = (topLangs, options = {}) => {
border_radius,
border_color,
disable_animations,
+ stats_format = "percentages",
} = options;
const i18n = new I18n({
@@ -779,10 +838,14 @@ const renderTopLanguages = (topLangs, options = {}) => {
});
} else if (layout === "pie") {
height = calculatePieLayoutHeight(langs.length);
- finalLayout = renderPieLayout(langs, totalLanguageSize);
+ finalLayout = renderPieLayout(langs, totalLanguageSize, stats_format);
} else if (layout === "donut-vertical") {
height = calculateDonutVerticalLayoutHeight(langs.length);
- finalLayout = renderDonutVerticalLayout(langs, totalLanguageSize);
+ finalLayout = renderDonutVerticalLayout(
+ langs,
+ totalLanguageSize,
+ stats_format,
+ );
} else if (layout === "compact" || hide_progress == true) {
height =
calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0);
@@ -792,13 +855,24 @@ const renderTopLanguages = (topLangs, options = {}) => {
width,
totalLanguageSize,
hide_progress,
+ stats_format,
);
} else if (layout === "donut") {
height = calculateDonutLayoutHeight(langs.length);
width = width + 50; // padding
- finalLayout = renderDonutLayout(langs, width, totalLanguageSize);
+ finalLayout = renderDonutLayout(
+ langs,
+ width,
+ totalLanguageSize,
+ stats_format,
+ );
} else {
- finalLayout = renderNormalLayout(langs, width, totalLanguageSize);
+ finalLayout = renderNormalLayout(
+ langs,
+ width,
+ totalLanguageSize,
+ stats_format,
+ );
}
const card = new Card({
diff --git a/src/cards/types.d.ts b/src/cards/types.d.ts
index 9a21be4a..08f80ef2 100644
--- a/src/cards/types.d.ts
+++ b/src/cards/types.d.ts
@@ -44,6 +44,7 @@ export type TopLangOptions = CommonOptions & {
langs_count: number;
disable_animations: boolean;
hide_progress: boolean;
+ stats_format: "percentages" | "bytes";
};
export type WakaTimeOptions = CommonOptions & {
diff --git a/src/common/utils.js b/src/common/utils.js
index b12a9147..04b02e2c 100644
--- a/src/common/utils.js
+++ b/src/common/utils.js
@@ -598,6 +598,33 @@ const dateDiff = (d1, d2) => {
return Math.round(diff / (1000 * 60));
};
+/**
+ * Convert bytes to a human-readable string representation.
+ *
+ * @param {number} bytes The number of bytes to convert.
+ * @returns {string} The human-readable representation of bytes.
+ * @throws {Error} If bytes is negative or too large.
+ */
+const formatBytes = (bytes) => {
+ if (bytes < 0) {
+ throw new Error("Bytes must be a non-negative number");
+ }
+
+ if (bytes === 0) {
+ return "0 B";
+ }
+
+ const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
+ const base = 1024;
+ const i = Math.floor(Math.log(bytes) / Math.log(base));
+
+ if (i >= sizes.length) {
+ throw new Error("Bytes is too large to convert to a human-readable string");
+ }
+
+ return `${(bytes / Math.pow(base, i)).toFixed(1)} ${sizes[i]}`;
+};
+
export {
ERROR_CARD_LENGTH,
renderError,
@@ -624,4 +651,5 @@ export {
chunkArray,
parseEmojis,
dateDiff,
+ formatBytes,
};
diff --git a/tests/renderTopLanguagesCard.test.js b/tests/renderTopLanguagesCard.test.js
index 55965da0..0a7aba61 100644
--- a/tests/renderTopLanguagesCard.test.js
+++ b/tests/renderTopLanguagesCard.test.js
@@ -849,4 +849,40 @@ describe("Test renderTopLanguages", () => {
"No languages data.",
);
});
+
+ it("should show proper stats format", () => {
+ document.body.innerHTML = renderTopLanguages(langs, {
+ layout: "compact",
+ stats_format: "percentages",
+ });
+
+ expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent(
+ "HTML 40.00%",
+ );
+
+ expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent(
+ "javascript 40.00%",
+ );
+
+ expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent(
+ "css 20.00%",
+ );
+
+ document.body.innerHTML = renderTopLanguages(langs, {
+ layout: "compact",
+ stats_format: "bytes",
+ });
+
+ expect(queryAllByTestId(document.body, "lang-name")[0]).toHaveTextContent(
+ "HTML 200.0 B",
+ );
+
+ expect(queryAllByTestId(document.body, "lang-name")[1]).toHaveTextContent(
+ "javascript 200.0 B",
+ );
+
+ expect(queryAllByTestId(document.body, "lang-name")[2]).toHaveTextContent(
+ "css 100.0 B",
+ );
+ });
});
diff --git a/tests/utils.test.js b/tests/utils.test.js
index 5bd43955..7277b95d 100644
--- a/tests/utils.test.js
+++ b/tests/utils.test.js
@@ -7,6 +7,7 @@ import {
parseBoolean,
renderError,
wrapTextMultiline,
+ formatBytes,
} from "../src/common/utils.js";
import { expect, it, describe } from "@jest/globals";
@@ -134,6 +135,20 @@ describe("Test utils.js", () => {
borderColor: "#fff",
});
});
+
+ it("formatBytes: should return expected values", () => {
+ expect(formatBytes(0)).toBe("0 B");
+ expect(formatBytes(100)).toBe("100.0 B");
+ expect(formatBytes(1024)).toBe("1.0 KB");
+ expect(formatBytes(1024 * 1024)).toBe("1.0 MB");
+ expect(formatBytes(1024 * 1024 * 1024)).toBe("1.0 GB");
+ expect(formatBytes(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
+ expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 PB");
+ expect(formatBytes(1024 * 1024 * 1024 * 1024 * 1024 * 1024)).toBe("1.0 EB");
+
+ expect(formatBytes(1234 * 1024)).toBe("1.2 MB");
+ expect(formatBytes(123.4 * 1024)).toBe("123.4 KB");
+ });
});
describe("wrapTextMultiline", () => {