// @ts-check
import { Card } from "../common/Card.js";
import { getCardColors } from "../common/color.js";
import { CustomError } from "../common/error.js";
import { kFormatter } from "../common/fmt.js";
import { I18n } from "../common/I18n.js";
import { icons, rankIcon } from "../common/icons.js";
import { clampValue } from "../common/ops.js";
import { flexLayout, measureText } from "../common/render.js";
import { statCardLocales, wakatimeCardLocales } from "../translations.js";
const CARD_MIN_WIDTH = 287;
const CARD_DEFAULT_WIDTH = 287;
const RANK_CARD_MIN_WIDTH = 420;
const RANK_CARD_DEFAULT_WIDTH = 450;
const RANK_ONLY_CARD_MIN_WIDTH = 290;
const RANK_ONLY_CARD_DEFAULT_WIDTH = 290;
/**
* Long locales that need more space for text. Keep sorted alphabetically.
*
* @type {(keyof typeof wakatimeCardLocales["wakatimecard.title"])[]}
*/
const LONG_LOCALES = [
"az",
"bg",
"cs",
"de",
"el",
"es",
"fil",
"fi",
"fr",
"hu",
"id",
"ja",
"ml",
"my",
"nl",
"pl",
"pt-br",
"pt-pt",
"ru",
"sr",
"sr-latn",
"sw",
"ta",
"uk-ua",
"uz",
"zh-tw",
];
/**
* Create a stats card text item.
*
* @param {object} params Object that contains the createTextNode parameters.
* @param {string} params.icon The icon to display.
* @param {string} params.label The label to display.
* @param {number} params.value The value to display.
* @param {string} params.id The id of the stat.
* @param {string=} params.unitSymbol The unit symbol of the stat.
* @param {number} params.index The index of the stat.
* @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.numberFormat The format of numbers on card.
* @param {number=} params.numberPrecision The precision of numbers on card.
* @returns {string} The stats card text item SVG object.
*/
const createTextNode = ({
icon,
label,
value,
id,
unitSymbol,
index,
showIcons,
shiftValuePos,
bold,
numberFormat,
numberPrecision,
}) => {
const precision =
typeof numberPrecision === "number" && !isNaN(numberPrecision)
? clampValue(numberPrecision, 0, 2)
: undefined;
const kValue =
numberFormat.toLowerCase() === "long" || id === "prs_merged_percentage"
? value
: kFormatter(value, precision);
const staggerDelay = (index + 3) * 150;
const labelOffset = showIcons ? `x="25"` : "";
const iconSvg = showIcons
? `
`
: "";
return `
${iconSvg}
${label}:
${kValue}${unitSymbol ? ` ${unitSymbol}` : ""}
`;
};
/**
* Calculates progress along the boundary of the circle, i.e. its circumference.
*
* @param {number} value The rank value to calculate progress for.
* @returns {number} Progress value.
*/
const calculateCircleProgress = (value) => {
const radius = 40;
const c = Math.PI * (radius * 2);
if (value < 0) {
value = 0;
}
if (value > 100) {
value = 100;
}
return ((100 - value) / 100) * c;
};
/**
* Retrieves the animation to display progress along the circumference of circle
* from the beginning to the given value in a clockwise direction.
*
* @param {{progress: number}} progress The progress value to animate to.
* @returns {string} Progress animation css.
*/
const getProgressAnimation = ({ progress }) => {
return `
@keyframes rankAnimation {
from {
stroke-dashoffset: ${calculateCircleProgress(0)};
}
to {
stroke-dashoffset: ${calculateCircleProgress(progress)};
}
}
`;
};
/**
* Retrieves CSS styles for a card.
*
* @param {Object} colors The colors to use for the card.
* @param {string} colors.titleColor The title color.
* @param {string} colors.textColor The text color.
* @param {string} colors.iconColor The icon color.
* @param {string} colors.ringColor The ring color.
* @param {boolean} colors.show_icons Whether to show icons.
* @param {number} colors.progress The progress value to animate to.
* @returns {string} Card CSS styles.
*/
const getStyles = ({
// eslint-disable-next-line no-unused-vars
titleColor,
textColor,
iconColor,
ringColor,
show_icons,
progress,
}) => {
return `
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
}
@supports(-moz-appearance: auto) {
/* Selector detects Firefox */
.stat { font-size:12px; }
}
.stagger {
opacity: 0;
animation: fadeInAnimation 0.3s ease-in-out forwards;
}
.rank-text {
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor};
animation: scaleInAnimation 0.3s ease-in-out forwards;
}
.rank-percentile-header {
font-size: 14px;
}
.rank-percentile-text {
font-size: 16px;
}
.not_bold { font-weight: 400 }
.bold { font-weight: 700 }
.icon {
fill: ${iconColor};
display: ${show_icons ? "block" : "none"};
}
.rank-circle-rim {
stroke: ${ringColor};
fill: none;
stroke-width: 6;
opacity: 0.2;
}
.rank-circle {
stroke: ${ringColor};
stroke-dasharray: 250;
fill: none;
stroke-width: 6;
stroke-linecap: round;
opacity: 0.8;
transform-origin: -10px 8px;
transform: rotate(-90deg);
animation: rankAnimation 1s forwards ease-in-out;
}
${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })}
`;
};
/**
* Return the label for commits according to the selected options
*
* @param {boolean} include_all_commits Option to include all years
* @param {number|undefined} commits_year Option to include only selected year
* @param {I18n} i18n The I18n instance.
* @returns {string} The label corresponding to the options.
*/
const getTotalCommitsYearLabel = (include_all_commits, commits_year, i18n) =>
include_all_commits
? ""
: commits_year
? ` (${commits_year})`
: ` (${i18n.t("wakatimecard.lastyear")})`;
/**
* @typedef {import('../fetchers/types').StatsData} StatsData
* @typedef {import('./types').StatCardOptions} StatCardOptions
*/
/**
* Renders the stats card.
*
* @param {StatsData} stats The stats data.
* @param {Partial} options The card options.
* @returns {string} The stats card SVG object.
*/
const renderStatsCard = (stats, options = {}) => {
const {
name,
totalStars,
totalCommits,
totalIssues,
totalPRs,
totalPRsMerged,
mergedPRsPercentage,
totalReviews,
totalDiscussionsStarted,
totalDiscussionsAnswered,
contributedTo,
rank,
} = stats;
const {
hide = [],
show_icons = false,
hide_title = false,
hide_border = false,
card_width,
hide_rank = false,
include_all_commits = false,
commits_year,
line_height = 25,
title_color,
ring_color,
icon_color,
text_color,
text_bold = true,
bg_color,
theme = "default",
custom_title,
border_radius,
border_color,
number_format = "short",
number_precision,
locale,
disable_animations = false,
rank_icon = "default",
show = [],
} = options;
const lheight = parseInt(String(line_height), 10);
// returns theme based colors with proper overrides and defaults
const { titleColor, iconColor, textColor, bgColor, borderColor, ringColor } =
getCardColors({
title_color,
text_color,
icon_color,
bg_color,
border_color,
ring_color,
theme,
});
const apostrophe = /s$/i.test(name.trim()) ? "" : "s";
const i18n = new I18n({
locale,
translations: {
...statCardLocales({ name, apostrophe }),
...wakatimeCardLocales,
},
});
// Meta data for creating text nodes with createTextNode function
const STATS = {};
STATS.stars = {
icon: icons.star,
label: i18n.t("statcard.totalstars"),
value: totalStars,
id: "stars",
};
STATS.commits = {
icon: icons.commits,
label: `${i18n.t("statcard.commits")}${getTotalCommitsYearLabel(
include_all_commits,
commits_year,
i18n,
)}`,
value: totalCommits,
id: "commits",
};
STATS.prs = {
icon: icons.prs,
label: i18n.t("statcard.prs"),
value: totalPRs,
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(
typeof number_precision === "number" && !isNaN(number_precision)
? clampValue(number_precision, 0, 2)
: 2,
),
id: "prs_merged_percentage",
unitSymbol: "%",
};
}
if (show.includes("reviews")) {
STATS.reviews = {
icon: icons.reviews,
label: i18n.t("statcard.reviews"),
value: totalReviews,
id: "reviews",
};
}
STATS.issues = {
icon: icons.issues,
label: i18n.t("statcard.issues"),
value: totalIssues,
id: "issues",
};
if (show.includes("discussions_started")) {
STATS.discussions_started = {
icon: icons.discussions_started,
label: i18n.t("statcard.discussions-started"),
value: totalDiscussionsStarted,
id: "discussions_started",
};
}
if (show.includes("discussions_answered")) {
STATS.discussions_answered = {
icon: icons.discussions_answered,
label: i18n.t("statcard.discussions-answered"),
value: totalDiscussionsAnswered,
id: "discussions_answered",
};
}
STATS.contribs = {
icon: icons.contribs,
label: i18n.t("statcard.contribs"),
value: contributedTo,
id: "contribs",
};
// @ts-ignore
const isLongLocale = locale ? LONG_LOCALES.includes(locale) : false;
// filter out hidden stats defined by user & create the text nodes
const statItems = Object.keys(STATS)
.filter((key) => !hide.includes(key))
.map((key, index) => {
// @ts-ignore
const stats = STATS[key];
// create the text nodes, and pass index so that we can calculate the line spacing
return createTextNode({
icon: stats.icon,
label: stats.label,
value: stats.value,
id: stats.id,
unitSymbol: stats.unitSymbol,
index,
showIcons: show_icons,
shiftValuePos: 79.01 + (isLongLocale ? 50 : 0),
bold: text_bold,
numberFormat: number_format,
numberPrecision: number_precision,
});
});
if (statItems.length === 0 && hide_rank) {
throw new CustomError(
"Could not render stats card.",
"Either stats or rank are required.",
);
}
// Calculate the card height depending on how many items there are
// but if rank circle is visible clamp the minimum height to `150`
let height = Math.max(
45 + (statItems.length + 1) * lheight,
hide_rank ? 0 : statItems.length ? 150 : 180,
);
// the lower the user's percentile the better
const progress = 100 - rank.percentile;
const cssStyles = getStyles({
titleColor,
ringColor,
textColor,
iconColor,
show_icons,
progress,
});
const calculateTextWidth = () => {
return measureText(
custom_title
? custom_title
: statItems.length
? i18n.t("statcard.title")
: i18n.t("statcard.ranktitle"),
);
};
/*
When hide_rank=true, the minimum card width is 270 px + the title length and padding.
When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true).
Numbers are picked by looking at existing dimensions on production.
*/
const iconWidth = show_icons && statItems.length ? 16 + /* padding */ 1 : 0;
const minCardWidth =
(hide_rank
? clampValue(
50 /* padding */ + calculateTextWidth() * 2,
CARD_MIN_WIDTH,
Infinity,
)
: statItems.length
? RANK_CARD_MIN_WIDTH
: RANK_ONLY_CARD_MIN_WIDTH) + iconWidth;
const defaultCardWidth =
(hide_rank
? CARD_DEFAULT_WIDTH
: statItems.length
? RANK_CARD_DEFAULT_WIDTH
: RANK_ONLY_CARD_DEFAULT_WIDTH) + iconWidth;
let width = card_width
? isNaN(card_width)
? defaultCardWidth
: card_width
: defaultCardWidth;
if (width < minCardWidth) {
width = minCardWidth;
}
const card = new Card({
customTitle: custom_title,
defaultTitle: statItems.length
? i18n.t("statcard.title")
: i18n.t("statcard.ranktitle"),
width,
height,
border_radius,
colors: {
titleColor,
textColor,
iconColor,
bgColor,
borderColor,
},
});
card.setHideBorder(hide_border);
card.setHideTitle(hide_title);
card.setCSS(cssStyles);
if (disable_animations) {
card.disableAnimations();
}
/**
* Calculates the right rank circle translation values such that the rank circle
* keeps respecting the following padding:
*
* width > RANK_CARD_DEFAULT_WIDTH: The default right padding of 70 px will be used.
* width < RANK_CARD_DEFAULT_WIDTH: The left and right padding will be enlarged
* equally from a certain minimum at RANK_CARD_MIN_WIDTH.
*
* @returns {number} - Rank circle translation value.
*/
const calculateRankXTranslation = () => {
if (statItems.length) {
const minXTranslation = RANK_CARD_MIN_WIDTH + iconWidth - 70;
if (width > RANK_CARD_DEFAULT_WIDTH) {
const xMaxExpansion = minXTranslation + (450 - minCardWidth) / 2;
return xMaxExpansion + width - RANK_CARD_DEFAULT_WIDTH;
} else {
return minXTranslation + (width - minCardWidth) / 2;
}
} else {
return width / 2 + 20 - 10;
}
};
// Conditionally rendered elements
const rankCircle = hide_rank
? ""
: `
${rankIcon(rank_icon, rank?.level, rank?.percentile)}
`;
// Accessibility Labels
const labels = Object.keys(STATS)
.filter((key) => !hide.includes(key))
.map((key) => {
// @ts-ignore
const stats = STATS[key];
if (key === "commits") {
return `${i18n.t("statcard.commits")} ${getTotalCommitsYearLabel(
include_all_commits,
commits_year,
i18n,
)} : ${stats.value}`;
}
return `${stats.label}: ${stats.value}`;
})
.join(", ");
card.setAccessibilityLabel({
title: `${card.title}, Rank: ${rank.level}`,
desc: labels,
});
return card.render(`
${rankCircle}
`);
};
export { renderStatsCard };
export default renderStatsCard;