Added Profile

Signed-off-by: Florian Bouillon <florian.bouillon@delta-wings.net>
This commit is contained in:
Florian Bouillon 2020-09-15 16:26:59 +02:00
parent f79e691059
commit 4702a72bff
11 changed files with 370 additions and 128 deletions

74
api/index.ts Normal file
View File

@ -0,0 +1,74 @@
import { renderError, parseBoolean, parseArray, CONSTANTS} from '../src/common/utils'
import { fetchProfile } from '../src/fetcher'
import renderStatsCard from '../src/cards/profileCard'
import blacklist from '../src/common/blacklist'
import { Request, Response } from 'express';
import ReactDOMServer from 'react-dom/server'
import themes from '../themes';
export interface query {
username: string
hide?: string
hide_title?: string
hide_border?: string
hide_rank?: string
show_icons?: string
line_height?: string
title_color?: string
icon_color?: string
text_color?: string
bg_color?: string
theme?: keyof typeof themes
cache_seconds?: string
}
export default async (req: Request<unknown, unknown, unknown, query>, res: Response) => {
const {
username,
hide,
hide_title,
hide_border,
hide_rank,
show_icons,
line_height,
title_color,
icon_color,
text_color,
bg_color,
theme,
} = req.query;
res.setHeader("Content-Type", "image/svg+xml");
if (blacklist.includes(username)) {
return res.send(renderError("Username is in blacklist"));
}
try {
const data = await fetchProfile(username);
const cacheSeconds = CONSTANTS.TWO_HOURS
res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`);
return res.send(ReactDOMServer.renderToStaticMarkup(
renderStatsCard(data, {
hide: parseArray(hide),
show_icons: parseBoolean(show_icons),
hide_title: parseBoolean(hide_title),
hide_border: parseBoolean(hide_border),
hide_rank: parseBoolean(hide_rank),
line_height: line_height ? parseInt(line_height , 10) : undefined,
title_color,
icon_color,
text_color,
bg_color,
theme,
}))
);
} catch (err) {
return res.send(
ReactDOMServer.renderToStaticMarkup(renderError(err.message, err.secondaryMessage))
);
}
};

View File

@ -1,5 +1,5 @@
import { renderError, clampValue, parseBoolean, parseArray, CONSTANTS} from '../src/common/utils'
import fetchTopLanguages from '../src/fetchers/top-languages-fetcher'
import { fetchTopLanguages } from '../src/fetcher'
import renderTopLanguages from '../src/cards/top-languages-card'
import blacklist from '../src/common/blacklist'
import { Request, Response } from 'express';

185
src/cards/profileCard.tsx Normal file
View File

@ -0,0 +1,185 @@
import { kFormatter, getCardColors, FlexLayout, encodeHTML, getProgress } from '../common/utils'
import { getStyles } from '../getStyles'
import icons from '../common/icons'
import Card from '../common/Card'
import React from 'react'
import themes from '../../themes'
const createTextNode = ({
icon,
label,
value,
index,
showIcons
}: {
icon: JSX.Element,
label: string,
value: number,
index: number,
showIcons: boolean
}) => {
const kValue = kFormatter(value);
const staggerDelay = (index + 3) * 150;
const iconSvg = showIcons
? (
<svg data-testid="icon" className="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
{icon}
</svg>
)
: undefined
return (
<g className="stagger" style={{animationDelay: `${staggerDelay}ms`}} transform="translate(25, 0)">
{iconSvg}
<text className="stat bold" x={showIcons ? 25 : undefined} y="12.5">{label}:</text>
<text
className="stat"
x={showIcons ? 120 : 100}
y="12.5"
>{kValue}</text>
</g>
)
};
interface Options {
hide?: Array<string>
show_icons?: boolean
hide_title?: boolean
hide_border?: boolean
hide_rank?: boolean
line_height?: number
title_color?: string
icon_color?: string
text_color?: string
bg_color?: string
theme?: keyof typeof themes
}
export default (stats: {username: string, xp: number, recentXp: number, level: number}, options: Options = { hide: [] }) => {
const {
username,
xp,
recentXp,
level
} = stats;
const {
hide = [],
show_icons = false,
hide_title = false,
hide_border = false,
hide_rank = false,
line_height = 25,
title_color,
icon_color,
text_color,
bg_color,
theme = "default",
} = options;
const lheight = line_height
// returns theme based colors with proper overrides and defaults
const { titleColor, textColor, iconColor, bgColor } = getCardColors({
title_color,
icon_color,
text_color,
bg_color,
theme,
});
// Meta data for creating text nodes with createTextNode function
const STATS = {
xp: {
icon: icons.star,
label: "XP",
value: xp,
id: "xp",
},
recent_xp: {
icon: icons.commits,
label: 'Recent xp',
value: recentXp,
id: "recent_xp",
},
};
// filter out hidden stats defined by user & create the text nodes
const statItems = Object.keys(STATS)
.filter((key) => !hide.includes(key))
.map((key, index) =>
// create the text nodes, and pass index so that we can calculate the line spacing
createTextNode({
...STATS[key],
index,
showIcons: show_icons
})
);
// 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 : 120
);
// Conditionally rendered elements
const rankCircle = hide_rank
? undefined
: (
<g data-testid="rank-circle"
transform={`translate(400, 0)`}>
<circle className="rank-circle-rim" cx="-10" cy="8" r="40" />
<circle className="rank-circle" cx="-10" cy="8" r="40" />
<g className="rank-text">
<text
x="-4"
y="0"
alignmentBaseline="central"
dominantBaseline="central"
textAnchor="middle"
>
lv {level}
</text>
</g>
</g>
)
const progress = getProgress(xp + recentXp);
const cssStyles = getStyles({
titleColor,
textColor,
iconColor,
show_icons,
progress,
});
const apostrophe = ["x", "s"].includes(username.slice(-1)) ? "" : "s";
const card = new Card(
495,
height,
{
titleColor,
textColor,
iconColor,
bgColor,
},
`${encodeHTML(username)}'${apostrophe} Code::stats Profile`
)
card.setHideBorder(hide_border);
card.setHideTitle(hide_title);
card.setCSS(cssStyles);
return card.render(
<>
{rankCircle}
<svg x="0" y="0">
{FlexLayout({
items: statItems,
gap: lheight,
direction: "column",
})}
</svg>
</>
)
}

View File

@ -1,7 +1,6 @@
import { getCardColors, FlexLayout, clampValue } from "../common/utils"
import Card from '../common/Card'
import { query } from "../../api/top-langs";
import { data } from "../fetchers/top-languages-fetcher";
import { data } from "../fetcher";
import React from 'react'
import themes from "../../themes";

View File

@ -14,7 +14,7 @@ export default class Card {
constructor(
public width = 100,
public height = 100,
public colors: {titleColor?: string | Array<string>, textColor?: string | Array<string>, bgColor?: string | Array<string>} = {},
public colors: {titleColor?: string | Array<string>, textColor?: string | Array<string>, bgColor?: string | Array<string>, iconColor?: string | Array<string>} = {},
public title = "",
public titlePrefixIcon?: string
) {}

View File

@ -1,12 +1,12 @@
import React from 'react'
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"/>,
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"/>,
fork: <path fill-rule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>,
star: <path fillRule="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 fillRule="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 fillRule="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"/>,
issues: <path fillRule="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 fillRule="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 fillRule="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"/>,
fork: <path fillRule="evenodd" d="M5 3.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm0 2.122a2.25 2.25 0 10-1.5 0v.878A2.25 2.25 0 005.75 8.5h1.5v2.128a2.251 2.251 0 101.5 0V8.5h1.5a2.25 2.25 0 002.25-2.25v-.878a2.25 2.25 0 10-1.5 0v.878a.75.75 0 01-.75.75h-4.5A.75.75 0 015 6.25v-.878zm3.75 7.378a.75.75 0 11-1.5 0 .75.75 0 011.5 0zm3-8.75a.75.75 0 100-1.5.75.75 0 000 1.5z"/>,
};
export default icons

View File

@ -1,42 +1,18 @@
import { logger, CustomError } from './utils'
import { CustomError, request } from './utils'
import { AxiosPromise } from 'axios';
const retryer = async (fetcher, variables, retries = 0) => {
const retryer = async <T = AxiosPromise<{error: string}>>(fetcher: (variables: {login: string}) => T, variables: {login: string}, retries = 0): Promise<T> => {
if (retries > 7) {
throw new CustomError("Maximum retries exceeded", CustomError.MAX_RETRY);
throw new CustomError("Maximum retries exceeded", 'MAX_RETRY');
}
try {
// try to fetch with the first token since RETRIES is 0 index i'm adding +1
let response = await fetcher(
variables,
process.env[`PAT_${retries + 1}`],
retries
);
variables
)
// prettier-ignore
const isRateExceeded = response.data.errors && response.data.errors[0].type === "RATE_LIMITED";
// if rate limit is hit increase the RETRIES and recursively call the retryer
// with username, and current RETRIES
if (isRateExceeded) {
logger.log(`PAT_${retries + 1} Failed`);
retries++;
// directly return from the function
return retryer(fetcher, variables, retries);
}
// finally return the response
return response;
} catch (err) {
// prettier-ignore
// also checking for bad credentials if any tokens gets invalidated
const isBadCredential = err.response.data && err.response.data.message === "Bad credentials";
if (isBadCredential) {
logger.log(`PAT_${retries + 1} Failed`);
retries++;
// directly return from the function
return retryer(fetcher, variables, retries);
}
return retryer(fetcher, variables, 1 + retries);
}
};

View File

@ -1,8 +1,9 @@
import React from 'react'
import axios from 'axios'
import axios, { AxiosPromise } from 'axios'
import wrap from 'word-wrap'
import themes from '../../themes'
import { CodeStatsResponse } from '../fetcher/interface';
export const renderError = (message: string, secondaryMessage = "") => {
return (
@ -13,7 +14,7 @@ export const renderError = (message: string, secondaryMessage = "") => {
.gray { fill: #858585 }
`}</style>
<rect x="0.5" y="0.5" width="494" height="99%" rx="4.5" fill="#FFFEFE" stroke="#E4E2E2"/>
<text x="25" y="45" className="text">Something went wrong! file an issue at https://git.io/JJmN9</text>
<text x="25" y="45" className="text">Something went wrong! file an issue at https://dze.io/3nL29</text>
<text data-testid="message" x="25" y="55" className="text small">
<tspan x="25" dy="18">{encodeHTML(message)}</tspan>
<tspan x="25" dy="18" className="gray">{secondaryMessage}</tspan>
@ -33,7 +34,7 @@ export function encodeHTML(str: string) {
export function kFormatter(num: number) {
return Math.abs(num) > 999
? Math.sign(num) * (Math.abs(num) / 1000).toFixed(1) + "k"
? Math.sign(num) * parseInt((Math.abs(num) / 1000).toFixed(1)) + "k"
: Math.sign(num) * Math.abs(num);
}
@ -78,16 +79,7 @@ export function fallbackColor(color: string, fallbackColor: Array<string>| strin
);
}
export function request(data?: any, headers?: any) {
return axios({
url: "https://api.github.com/graphql",
method: "post",
headers,
data,
});
}
export function codeStatsRequest(data?: any) {
export function request(data: {login: string}): AxiosPromise<CodeStatsResponse> {
return axios({
url: "https://codestats.net/api/users/" + data.login,
method: "get",
@ -182,9 +174,7 @@ export const CONSTANTS = {
};
export const SECONDARY_ERROR_MESSAGES = {
MAX_RETRY:
"Please add an env variable called PAT_1 with your github token in vercel",
USER_NOT_FOUND: "Make sure the provided username is not an organization",
MAX_RETRY: "Make sur your profile is not private"
};
export class CustomError extends Error {
@ -198,3 +188,16 @@ export class CustomError extends Error {
static MAX_RETRY = "MAX_RETRY";
static USER_NOT_FOUND = "USER_NOT_FOUND";
}
export function getLevel(xp: number): number {
return Math.trunc(Math.floor(CONSTANTS.LEVEL_FACTOR * Math.sqrt(xp)))
}
/**
* Return the progress (0-99)% til next level
* @param xp Xp number
*/
export function getProgress(xp: number): number {
const currentLvl = getLevel(xp)
return Math.trunc((CONSTANTS.LEVEL_FACTOR * Math.sqrt(xp) - currentLvl) * 100)
}

View File

@ -1,28 +1,7 @@
import { codeStatsRequest, logger, CONSTANTS } from '../common/utils'
import { request, logger, CONSTANTS, getLevel, CustomError } from '../common/utils'
import retryer from '../common/retryer'
import languageColor from '../../themes/language-bar.json'
const fetcher = (variables: any) => {
return codeStatsRequest(
variables
);
};
interface response {
user: string
total_xp: number
new_xp: number
machines: Record<string, {
xps: number
new_xps: number
}>
languages: Record<string, {
xps: number
new_xps: number
}>
dates: Record<string, number>
}
export interface data {
name: string
size: number
@ -30,10 +9,23 @@ export interface data {
recentSize: number
}
async function fetchTopLanguages(username: string) {
export async function fetchProfile(username: string) {
if (!username) throw Error('Invalid Username')
const response = await retryer(request, {login: username})
return {
username,
xp: response.data.total_xp,
recentXp: response.data.new_xp,
level: getLevel(response.data.total_xp + response.data.new_xp)
}
}
export async function fetchTopLanguages(username: string) {
if (!username) throw Error("Invalid username");
let res: {data: response} = await retryer(fetcher, { login: username });
let res = await retryer(request, { login: username });
let repoNodes = res.data.languages;
@ -76,5 +68,3 @@ async function fetchTopLanguages(username: string) {
return topLangs as Record<string, data>
}
export default fetchTopLanguages

15
src/fetcher/interface.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
export interface CodeStatsResponse {
user: string
error?: string
total_xp: number
new_xp: number
machines: Record<string, {
xps: number
new_xps: number
}>
languages: Record<string, {
xps: number
new_xps: number
}>
dates: Record<string, number>
}

View File

@ -44,49 +44,49 @@ export const getAnimations = () => {
`;
};
// export const getStyles = ({
// titleColor,
// textColor,
// iconColor,
// show_icons,
// progress,
// }) => {
// return `
// .stat {
// font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
// }
// .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;
// }
export const getStyles = ({
titleColor,
textColor,
iconColor,
show_icons,
progress,
}: any) => {
return `
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
}
.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;
}
// .bold { font-weight: 700 }
// .icon {
// fill: ${iconColor};
// display: ${!!show_icons ? "block" : "none"};
// }
.bold { font-weight: 700 }
.icon {
fill: ${iconColor};
display: ${!!show_icons ? "block" : "none"};
}
// .rank-circle-rim {
// stroke: ${titleColor};
// fill: none;
// stroke-width: 6;
// opacity: 0.2;
// }
// .rank-circle {
// stroke: ${titleColor};
// 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 })}
// `;
// };
.rank-circle-rim {
stroke: ${titleColor};
fill: none;
stroke-width: 6;
opacity: 0.2;
}
.rank-circle {
stroke: ${titleColor};
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 })}
`;
};