From 2e07ac2bd6c966be095602be5816f07319f81f6c Mon Sep 17 00:00:00 2001 From: Lucas <100660343+lulunac27a@users.noreply.github.com> Date: Mon, 13 Oct 2025 12:21:28 -0500 Subject: [PATCH] feat: implement number precision parameter for stats card (#4514) * Update short number format Update short number format by adding 2 decimal places instead of 1 for numbers between 1,000 and 9,999 * review * Update src/common/utils.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * dev * dev --------- Co-authored-by: Alexandr Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/api-renamed/index.js | 2 ++ backend/readme.md | 1 + backend/src/cards/stats.js | 24 ++++++++++++---- backend/src/cards/types.d.ts | 1 + backend/src/common/utils.js | 20 +++++++++---- backend/tests/renderStatsCard.test.js | 7 +++++ backend/tests/utils.test.js | 41 ++++++++++++++++++++++++++- 7 files changed, 85 insertions(+), 11 deletions(-) diff --git a/backend/api-renamed/index.js b/backend/api-renamed/index.js index 2b172b08..9a5cad9d 100644 --- a/backend/api-renamed/index.js +++ b/backend/api-renamed/index.js @@ -46,6 +46,7 @@ export default async (req, res) => { disable_animations, border_radius, number_format, + number_precision, role, border_color, rank_icon, @@ -180,6 +181,7 @@ export default async (req, res) => { border_radius, border_color, number_format, + number_precision: parseInt(number_precision, 10), locale: locale ? locale.toLowerCase() : null, disable_animations: parseBoolean(disable_animations), rank_icon, diff --git a/backend/readme.md b/backend/readme.md index 430a36bd..2474dbdc 100644 --- a/backend/readme.md +++ b/backend/readme.md @@ -396,6 +396,7 @@ If we don't support your language, please consider contributing! You can find mo | `disable_animations` | Disables all animations in the card. | boolean | `false` | | `ring_color` | Color of the rank circle. | string (hex color) | `2f80ed` | | `number_format` | Switches between two available formats for displaying the card values: `short` (i.e. `6.6k`) and `long` (i.e. `6626`). | enum | `short` | +| `number_precision` | Enforce the number of digits after the decimal point for `short` number format. Must be an integer between 0 and 2. Will be ignored for `long` number format. | integer (0, 1 or 2) | `null` | | `show` | Shows [additional items](#showing-additional-individual-stats) on stats card (i.e. `reviews`, `discussions_started`, `discussions_answered`, `prs_merged` or `prs_merged_percentage`. And the following, which support the `repo` and `owner` filters: `prs_authored`, `prs_commented`, `prs_reviewed`, `issues_authored` or `issues_commented`). | string (comma-separated values) | `null` | | `commits_year` | Filters and counts only commits made in the specified year. | integer _(YYYY)_ | ` (one year to date)` | diff --git a/backend/src/cards/stats.js b/backend/src/cards/stats.js index ea328580..dd6a4de0 100644 --- a/backend/src/cards/stats.js +++ b/backend/src/cards/stats.js @@ -34,7 +34,8 @@ const RANK_ONLY_CARD_DEFAULT_WIDTH = 290; * @param {boolean} params.showIcons Whether to show icons. * @param {number} params.shiftValuePos Number of pixels the value has to be shifted to the right. * @param {boolean} params.bold Whether to bold the label. - * @param {string} params.number_format The format of numbers on card. + * @param {string} params.numberFormat The format of numbers on card. + * @param {number=} params.numberPrecision The precision of numbers on card. * @param {string} params.link Url to link to. * @param {number} params.labelXOffset horizontal offset for label. * @returns {string} The stats card text item SVG object. @@ -49,12 +50,19 @@ const createTextNode = ({ showIcons, shiftValuePos, bold, - number_format, + numberFormat, + numberPrecision, link, labelXOffset = 25, }) => { + const precision = + typeof numberPrecision === "number" && !isNaN(numberPrecision) + ? clampValue(numberPrecision, 0, 2) + : undefined; const kValue = - number_format.toLowerCase() === "long" ? value : kFormatter(value); + numberFormat.toLowerCase() === "long" || id === "prs_merged_percentage" + ? value + : kFormatter(value, precision); const staggerDelay = (index + 3) * 150; const labelOffset = showIcons ? `x="${labelXOffset}"` : ""; @@ -273,6 +281,7 @@ const renderStatsCard = ( border_radius, border_color, number_format = "short", + number_precision, locale, disable_animations = false, rank_icon = "default", @@ -341,7 +350,11 @@ const renderStatsCard = ( STATS.prs_merged_percentage = { icon: icons.prs_merged_percentage, label: i18n.t("statcard.prs-merged-percentage"), - value: mergedPRsPercentage.toFixed(2), + value: mergedPRsPercentage.toFixed( + typeof number_precision === "number" && !isNaN(number_precision) + ? clampValue(number_precision, 0, 2) + : 2, + ), id: "prs_merged_percentage", unitSymbol: "%", }; @@ -483,7 +496,8 @@ const renderStatsCard = ( showIcons: show_icons, shiftValuePos: 29.01 + (longLabels ? 50 : 0) + (isLongLocale ? 50 : 0), bold: text_bold, - number_format, + numberFormat: number_format, + numberPrecision: number_precision, link: STATS[key].link, }); }); diff --git a/backend/src/cards/types.d.ts b/backend/src/cards/types.d.ts index 154b09f7..ade80677 100644 --- a/backend/src/cards/types.d.ts +++ b/backend/src/cards/types.d.ts @@ -25,6 +25,7 @@ export type StatCardOptions = CommonOptions & { custom_title: string; disable_animations: boolean; number_format: string; + number_precision: number; ring_color: string; text_bold: boolean; rank_icon: RankIcon; diff --git a/backend/src/common/utils.js b/backend/src/common/utils.js index 42af5a5c..3c8f25ed 100644 --- a/backend/src/common/utils.js +++ b/backend/src/common/utils.js @@ -79,15 +79,25 @@ const iconWithLabel = (icon, label, testid, iconSize) => { }; /** - * Retrieves num with suffix k(thousands) precise to 1 decimal if greater than 999. + * Retrieves num with suffix k(thousands) precise to given decimal places. * * @param {number} num The number to format. + * @param {number=} precision The number of decimal places to include. * @returns {string|number} The formatted number. */ -const kFormatter = (num) => { - return Math.abs(num) > 999 - ? Math.sign(num) * parseFloat((Math.abs(num) / 1000).toFixed(1)) + "k" - : Math.sign(num) * Math.abs(num); +const kFormatter = (num, precision) => { + const abs = Math.abs(num); + const sign = Math.sign(num); + + if (typeof precision === "number" && !isNaN(precision)) { + return (sign * (abs / 1000)).toFixed(precision) + "k"; + } + + if (abs < 1000) { + return sign * abs; + } + + return sign * parseFloat((abs / 1000).toFixed(1)) + "k"; }; /** diff --git a/backend/tests/renderStatsCard.test.js b/backend/tests/renderStatsCard.test.js index 7fa44959..1327fada 100644 --- a/backend/tests/renderStatsCard.test.js +++ b/backend/tests/renderStatsCard.test.js @@ -410,6 +410,13 @@ describe("Test renderStatsCard", () => { expect(getByTestId(document.body, "commits").textContent).toBe("2k"); document.body.innerHTML = renderStatsCard(stats, { number_format: "long" }); expect(getByTestId(document.body, "commits").textContent).toBe("1999"); + document.body.innerHTML = renderStatsCard(stats, { number_precision: 2 }); + expect(getByTestId(document.body, "commits").textContent).toBe("2.00k"); + document.body.innerHTML = renderStatsCard(stats, { + number_format: "long", + number_precision: 2, + }); + expect(getByTestId(document.body, "commits").textContent).toBe("1999"); }); it("should render default rank icon with level A+", () => { diff --git a/backend/tests/utils.test.js b/backend/tests/utils.test.js index f7ce665c..625786c5 100644 --- a/backend/tests/utils.test.js +++ b/backend/tests/utils.test.js @@ -12,16 +12,55 @@ import { import { getCardColors } from "../src/common/color.js"; describe("Test utils.js", () => { - it("should test kFormatter", () => { + it("should test kFormatter default behavior", () => { expect(kFormatter(1)).toBe(1); expect(kFormatter(-1)).toBe(-1); expect(kFormatter(500)).toBe(500); expect(kFormatter(1000)).toBe("1k"); + expect(kFormatter(1200)).toBe("1.2k"); expect(kFormatter(10000)).toBe("10k"); expect(kFormatter(12345)).toBe("12.3k"); + expect(kFormatter(99900)).toBe("99.9k"); expect(kFormatter(9900000)).toBe("9900k"); }); + it("should test kFormatter with 0 decimal precision", () => { + expect(kFormatter(1, 0)).toBe("0k"); + expect(kFormatter(-1, 0)).toBe("-0k"); + expect(kFormatter(500, 0)).toBe("1k"); + expect(kFormatter(1000, 0)).toBe("1k"); + expect(kFormatter(1200, 0)).toBe("1k"); + expect(kFormatter(10000, 0)).toBe("10k"); + expect(kFormatter(12345, 0)).toBe("12k"); + expect(kFormatter(99000, 0)).toBe("99k"); + expect(kFormatter(99900, 0)).toBe("100k"); + expect(kFormatter(9900000, 0)).toBe("9900k"); + }); + + it("should test kFormatter with 1 decimal precision", () => { + expect(kFormatter(1, 1)).toBe("0.0k"); + expect(kFormatter(-1, 1)).toBe("-0.0k"); + expect(kFormatter(500, 1)).toBe("0.5k"); + expect(kFormatter(1000, 1)).toBe("1.0k"); + expect(kFormatter(1200, 1)).toBe("1.2k"); + expect(kFormatter(10000, 1)).toBe("10.0k"); + expect(kFormatter(12345, 1)).toBe("12.3k"); + expect(kFormatter(99900, 1)).toBe("99.9k"); + expect(kFormatter(9900000, 1)).toBe("9900.0k"); + }); + + it("should test kFormatter with 2 decimal precision", () => { + expect(kFormatter(1, 2)).toBe("0.00k"); + expect(kFormatter(-1, 2)).toBe("-0.00k"); + expect(kFormatter(500, 2)).toBe("0.50k"); + expect(kFormatter(1000, 2)).toBe("1.00k"); + expect(kFormatter(1200, 2)).toBe("1.20k"); + expect(kFormatter(10000, 2)).toBe("10.00k"); + expect(kFormatter(12345, 2)).toBe("12.35k"); + expect(kFormatter(99900, 2)).toBe("99.90k"); + expect(kFormatter(9900000, 2)).toBe("9900.00k"); + }); + it("should test parseBoolean", () => { expect(parseBoolean(true)).toBe(true); expect(parseBoolean(false)).toBe(false);