Added History Card

Signed-off-by: Florian Bouillon <florian.bouillon@delta-wings.net>
This commit is contained in:
2020-09-19 23:56:02 +02:00
parent 383ff46c05
commit 332be009cf
38 changed files with 1580 additions and 4088 deletions

282
src/cards/HistoryCard.tsx Normal file
View File

@@ -0,0 +1,282 @@
import { formatDateNumber, getColorOfLanguage } from "../common/utils"
import Card, { CardOptions } from '../common/Card'
import React from 'react'
import FlexLayout from "../components/FlexLayout"
interface HistoryOptions extends CardOptions {
width?: number
height?: number
layout?: 'horizontal'
hide_legend?: boolean
// Put language in Other
hide?: Array<string>
language_count?: number
}
export default class HistoryCard extends Card {
private topLanguages: Array<string>
public constructor(
private username: string,
private days: Array<{day: string, total: number, data: Array<{xp: number, language: string}>}>,
private options: HistoryOptions
) {
super(options)
this.height = 45 + (this.days.length + 1) * 40
this.width = options.width ?? 500
if (options.layout === 'horizontal') {
this.width = 45 + (this.days.length + 1) * 40 + 100
this.height = options.height ?? 300
}
const languagesToHide = options.hide || []
let languageCount: Array<{language: string, xp: number}> = []
for (const day of this.days) {
for (const data of day.data) {
let index = languageCount.findIndex((item) => item.language === data.language)
if (index === -1) {
index = languageCount.push({
language: data.language,
xp: 0
}) - 1
}
languageCount[index].xp += data.xp
}
}
this.topLanguages = languageCount
.sort((a, b) => b.xp - a.xp)
.map((item) => item.language)
.filter((lang) => !languagesToHide.includes(lang))
languagesToHide.push(...this.topLanguages.splice((options.language_count || 8 )))
if (languagesToHide.length > 0) {
this.topLanguages.push('Other')
}
for (const day of this.days) {
const toRemove: Array<number> = []
for (let i = 0; i < day.data.length; i++) {
const element = day.data[i];
if (languagesToHide.includes(element.language)) {
const otherIndex = day.data.findIndex((el) => el.language === 'Other')
if (otherIndex === -1) {
day.data.push({
language: 'Other',
xp: element.xp
})
} else {
day.data[otherIndex].xp += element.xp
}
toRemove.push(i)
}
}
for (const index of toRemove.reverse()) {
day.data.splice(index, 1)
}
}
this.title = 'Code History Language breakdown'
this.css = ProgressNode.getCSS('#000')
}
public render() {
const totalTotal = this.days.reduce((prvs, crnt) => {
if (prvs < crnt.total) {
return crnt.total
}
return prvs
}, 0)
const legendWidth = Math.max(this.width * 20 / 100 + 60, 150)
const historyWidth = this.width - legendWidth
return super.render(
<svg x="25">
<FlexLayout items={[(() => {
if (this.options.layout === 'horizontal') {
return (
<FlexLayout items={
this.days.reverse().map((el, index) => (
<VerticalProgressNode
{...el}
totalTotal={totalTotal} height={this.height - 120} />
))
}
gap={40}
// direction="column"
/>
)
} else {
return (
<FlexLayout items={
this.days.map((el, index) => (
<ProgressNode
{...el}
totalTotal={totalTotal} width={historyWidth - 30} />
))
}
gap={40}
direction="column"
/>
)
}
})(), (
<FlexLayout items={
this.topLanguages.map((el, index) => (
<>
<rect rx="5" x="2" y="7" height="12" width="12" fill={getColorOfLanguage(el)} />
<text x="18" y="18" className="lang-name" key={index}>{el}</text>
</>
))
}
gap={20}
direction="column"
/>
)]}
gap={this.options.layout === 'horizontal' ? this.width - 180 : historyWidth - 20}
/>
</svg>
)
}
}
class ProgressNode extends React.Component<{
day: string
total: number
totalTotal: number
data: Array<{xp: number, language: string}>
width: number
}> {
public static getCSS = (textColor: string) => `
.lang-name {
font: 400 16px 'Segoe UI', Ubuntu, Sans-Serif;
fill: ${textColor};
}
.xp-txt {
font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif;
fill: ${textColor};
}
.xp-txt-invert {
font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif;
fill: white;
}
.subtitle {
font: 400 14px 'Segoe UI', Ubuntu, Sans-Serif;
fill: ${textColor};
}
`
public render() {
let offset = 0
const maskId = `mask-${this.props.day}`
return (
<>
<text x="2" y="15" className="lang-name">{new Date(this.props.day).toDateString().substr(4, 6)}</text>
<svg width={this.props.width}>
<mask id={maskId}>
<rect x="0" y="20" width={this.calcSize(this.props.total)} height="16" fill="white" rx="5" />
</mask>
<rect rx="5" ry="5" x="0" y="20" width={this.props.width} height="16" fill="#ddd" />
{this.props.data.map((el, index) => {
const color = getColorOfLanguage(el.language)
offset += el.xp
return (
<rect
key={index}
mask={`url(#${maskId})`}
height="16"
fill={color}
x={this.calcSize(offset - el.xp)} y="20"
width={`${this.calcSize(el.xp)}px`}
/>
)
})}
{(() => {
let size = this.calcSize(offset) + 6
const txtSize = (this.props.total.toString().length + 3) * 8
let classes = 'xp-txt'
if (size + txtSize >= this.calcSize(this.props.totalTotal)) {
size -= txtSize
classes += ' xp-txt-invert'
}
return (
<text x={size} y="33" className={classes}>{this.props.total} XP</text>
)
})()}
</svg>
</>
)
}
protected calcSize(number: number) {
return number * this.props.width / this.props.totalTotal
}
}
class VerticalProgressNode extends React.Component<{
day: string
total: number
totalTotal: number
data: Array<{xp: number, language: string}>
height: number
}> {
public render() {
let offset = this.props.totalTotal
const maskId = `mask-${this.props.day}`
return (
<>
<svg x="7" y="-20" height={this.props.height + 60}>
<mask id={maskId}>
<rect x="0" y={25 + this.calcSize(this.props.totalTotal - this.props.total)} width="16" height={this.calcSize(this.props.total)} fill="white" rx="5" />
</mask>
<rect rx="5" ry="5" x="0" y="25" width="16" height={this.props.height} fill="#ddd" />
{this.props.data.map((el, index) => {
const color = getColorOfLanguage(el.language)
offset -= el.xp
return (
<rect
key={index}
mask={`url(#${maskId})`}
width="16"
fill={color}
y={25 + this.calcSize(offset)}
x="0"
height={`${this.calcSize(el.xp)}px`}
/>
)
})}
{this.getXPTxt()}
</svg>
<text x="2" y={this.props.height + 18} className="subtitle">{new Date(this.props.day).toDateString().substr(4, 3)}</text>
<text x="5" y={this.props.height + 34} className="subtitle">{formatDateNumber(new Date(this.props.day).getDate())}</text>
</>
)
}
protected calcSize(number: number) {
return number * this.props.height / this.props.totalTotal
}
private getXPTxt() {
const txtLength = (this.props.total.toString().length + 3) * 13
let position = 25 + this.calcSize(this.props.totalTotal - this.props.total) - txtLength
let classes = 'xp-txt'
if (position <= 28) {
position += txtLength + 16
classes += ' xp-txt-invert'
}
return (
<text transform={`rotate(90, 4, ${position})`} letterSpacing="5" y={position} x="4" rotate="-90" className={classes}>{this.props.total} XP</text>
)
}
}

214
src/cards/ProfileCard.tsx Normal file
View File

@@ -0,0 +1,214 @@
import { kFormatter, encodeHTML, getProgress, getLevel, parseBoolean, calculateCircleProgress, getColor } from '../common/utils'
import icons from '../common/icons'
import Card, { CardOptions } from '../common/Card'
import React from 'react'
import FlexLayout from '../components/FlexLayout'
interface ProfileCardOptions extends CardOptions {
hide?: Array<string>
show_icons?: boolean
hide_rank?: boolean
line_height?: number
icon_color?: string
text_color?: string
}
export default class ProfileCard extends Card {
private stats: Record<string, {
icon: JSX.Element
label: string
value: number
}>
private defaults = {
line_height: 25,
hide: []
}
public constructor(
private username: string,
private xp: number,
private recentXp: number,
private options: ProfileCardOptions
) {
super(
options
)
// This Element
this.stats = {
xp: {
icon: icons.star,
label: "XP",
value: xp,
},
recent_xp: {
icon: icons.commits,
label: 'Recent xp',
value: this.recentXp,
},
}
// Card Settings
this.width = 495
this.height = Math.max(
45 + (Object.keys(this.stats).length + 1) * (this.options.line_height || this.defaults.line_height),
options.hide_rank ? 0 : 120
)
this.title = `${encodeHTML(this.username)}${
["x", "s"].includes(this.username.slice(-1)) ? "" : "s"
} Code::Stats Profile`
const textColor = getColor('text_color', options.text_color, options.theme)
const iconColor = getColor('icon_color', options.icon_color, options.theme)
if (!this.options.hide_rank) {
this.css += RankCircle.getCSS(textColor, iconColor, getProgress(xp))
}
if ((this.options.hide || []) < Object.keys(this.stats)) {
this.css += TextNode.getCSS(textColor, !!this.options.show_icons ? iconColor : undefined)
}
}
public render() {
return super.render(
<>
<RankCircle xp={this.options.hide_rank ? undefined : this.xp} />
<svg x="0" y="0">
<FlexLayout
items={Object.keys(this.stats).filter((item) => !(this.options.hide || []).includes(item)).map((el, index) => {
const item = this.stats[el]
return (
<TextNode
{...item}
icon={!!this.options.show_icons ? item.icon : undefined}
key={index}
index={index}
/>
)
})}
gap={this.options.line_height || this.defaults.line_height}
direction="column"
/>
</svg>
</>
)
}
}
class RankCircle extends React.Component<{
xp?: number
}> {
public static getCSS = (textColor: string, titleColor: string, progress: number) => `
.rank-text {
font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor};
animation: scaleInAnimation 0.3s ease-in-out forwards;
}
.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;
}
@keyframes rankAnimation {
from {
stroke-dashoffset: ${calculateCircleProgress(0)};
}
to {
stroke-dashoffset: ${calculateCircleProgress(progress)};
}
}
`
public render () {
if (!this.props.xp) {
return undefined
}
return (
<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 {getLevel(this.props.xp)}
</text>
</g>
</g>
)
}
}
class TextNode extends React.Component<{
icon?: JSX.Element
label: string
value: number
index: number
}> {
public static getCSS = (textColor: string, iconColor?: string) => `
${
iconColor ? `.icon {
fill: ${iconColor};
// display: block;
}` : ''
}
.stagger {
opacity: 0;
animation: fadeInAnimation 0.3s ease-in-out forwards;
}
.stat {
font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor};
}
.bold {
font-weight: 700
}
`
public render() {
const delay = (this.props.index + 3 * 150)
// Icon prefixing line
const icon = this.props.icon ? (
<svg data-testid="icon" className="icon" viewBox="0 0 16 16" version="1.1" width="16" height="16">
{this.props.icon}
</svg>
) : undefined
return (
<g className="stagger" data-index={this.props.index} style={{animationDelay: `${delay}ms`}} transform="translate(25, 0)">
{icon}
<text className="stat bold" x={this.props.icon ? 25 : undefined} y="12.5">{this.props.label}:</text>
<text
className="stat"
x={this.props.icon ? 120 : 100}
y="12.5"
>{kFormatter(this.props.value)}</text>
</g>
)
}
}

View File

@@ -0,0 +1,179 @@
import { getColor, getProgress, trunc } from "../common/utils"
import Card, { CardOptions } from '../common/Card'
import React from 'react'
import FlexLayout from "../components/FlexLayout";
import { TopLanguage } from "../interfaces";
interface TopLanguagesOptions extends CardOptions {
hide?: Array<string>
language_count?: number
card_width?: number
layout?: string
text_color?: string
}
export default class TopLanguagesCard extends Card {
public constructor(
private username: string,
private langs: Array<TopLanguage>,
private options: TopLanguagesOptions
) {
super(options)
this.langs = this.langs
.filter((item) => !(options.hide || []).includes(item.name))
.slice(0, options.language_count || 5)
this.height = 45 + (this.langs.length + 1) * 40
this.width = 300
if (options.card_width && !isNaN(options.card_width)) {
this.width = options.card_width
}
const textColor = getColor('text_color', options.text_color, options.theme)
this.title = "Most Used Languages"
this.css = CompactTextNode.getCSS(textColor as string)
}
public render() {
if (this.options.layout === 'compact') {
this.width = this.width + 50
this.height = 90 + Math.round(this.langs.length / 2) * 25
return super.render(
<svg x="25">
<mask id="rect-mask">
<rect x="0" y="0" width={this.width - 50} height="8" fill="white" rx="5" />
</mask>
<CompactProgressBar langs={this.langs} parentWidth={this.width} />
{this.langs.map((el, index) => (
<CompactTextNode key={index} index={index} lang={el} />
))}
</svg>
)
} else {
return super.render(
<svg x="25">
<FlexLayout items={
this.langs.map((el, index) => (
<ProgressNode lang={el} parentWidth={this.width} />
))
}
gap={40}
direction="column"
/>
</svg>
)
}
}
}
class CompactProgressBar extends React.Component<{
langs: Array<TopLanguage>
parentWidth: number
}> {
public render() {
let offset = 0
const totalSize = this.props.langs.reduce((acc, curr) => acc + curr.xp, 0)
return this.props.langs.map((lang, index) => {
const percent = trunc((lang.xp / totalSize) * (this.props.parentWidth - 50), 2)
const progress = percent < 10 ? percent + 10 : percent
const output = (
<rect
key={index}
mask="url(#rect-mask)"
data-testid="lang-progress"
x={offset}
y="0"
width={progress}
height="8"
fill={lang.color || "#858585"}
/>
)
offset += percent
return output
})
}
}
class CompactTextNode extends React.Component<{
index: number
lang: TopLanguage
}> {
public static getCSS = (textColor: string) => `
.lang-name {
font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif;
fill: ${textColor};
}
`
public render() {
const index = this.props.index
let x = 0
let y = 12.5 * index + 25
if (index % 2 !== 0) {
x = 150
y = 12.5 + 12.5 * index
}
return (
<g transform={`translate(${x}, ${y})`}>
<circle cx="5" cy="6" r="5" fill={this.props.lang.color} />
<text data-testid="lang-name" x="15" y="10" className='lang-name'>
{this.props.lang.name} {getProgress(this.props.lang.xp)}%
</text>
</g>
)
}
}
class ProgressNode extends React.Component<{
lang: TopLanguage
parentWidth: number
}> {
private paddingRight = 60
public render() {
const width = this.props.parentWidth - this.paddingRight
const progress1 = getProgress(this.props.lang.xp)
const progress2 = getProgress(this.props.lang.xp - this.props.lang.recentXp)
return (
<>
<text data-testid="lang-name" x="2" y="15" className="lang-name">{this.props.lang.name} {progress1}% {this.props.lang.recentXp >= 1 ? ' + ' + (trunc(progress1 - progress2, 2)) + '%' : ''}</text>
<svg width={width}>
<rect rx="5" ry="5" x="0" y="25" width={width} height="8" fill="#ddd" />
{progress1 !== progress2 && (
<rect
height="8"
fill="#f2b866"
rx="5" ry="5" x="1" y="25"
width={`${progress1}%`}
/>
)}
{progress1 >= progress2 && (
<rect
height="8"
fill={this.props.lang.color}
rx="5" ry="5" x="0" y="25"
data-testid="lang-progress"
width={`${progress2}%`}
/>
)}
</svg>
</>
)
}
}

View File

@@ -1,185 +0,0 @@
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,242 +0,0 @@
import { getCardColors, FlexLayout, clampValue } from "../common/utils"
import Card from '../common/Card'
import { data } from "../fetcher";
import React from 'react'
import themes from "../../themes";
export interface parsedQuery {
hide?: Array<string>
hide_title?: boolean
hide_border?: boolean
card_width?: number
title_color?: string
text_color?: string
bg_color?: string
language_count?: number
show_level?: string
theme?: keyof typeof themes
cache_seconds?: string
layout?: string
}
const createProgressNode = ({ width, color, name, progress, progress2 }: {width: number ,color: string, name: string, progress: number, progress2:number}) => {
const paddingRight = 60;
const progressWidth = width - paddingRight;
const progressPercentage = clampValue(progress, 2, 100);
const progress2Percentage = clampValue(progress2, 2, 100);
return (
<>
<text data-testid="lang-name" x="2" y="15" className="lang-name">{name} {progress}%{progress2 > progress ? ` + ${progress2 - progress}%` : ''}</text>
<svg width={progressWidth}>
<rect rx="5" ry="5" x="0" y="25" width={progressWidth} height="8" fill="#ddd" />
<rect
height="8"
fill="#f2b866"
rx="5" ry="5" x="1" y="25"
width={`calc(${progress2Percentage}% - 1px)`}
/>
<rect
height="8"
fill={color}
rx="5" ry="5" x="0" y="25"
data-testid="lang-progress"
width={`${progressPercentage}%`}
/>
</svg>
</>
)
};
const createCompactLangNode = ({ lang, totalSize, x, y }: {lang: data, totalSize: number, x: number, y: number}) => {
const percentage = ((lang.size / totalSize) * 100).toFixed(2);
const color = lang.color || "#858585";
return (
<g transform={`translate(${x}, ${y})`}>
<circle cx="5" cy="6" r="5" fill={color} />
<text data-testid="lang-name" x="15" y="10" className='lang-name'>
{lang.name} {percentage}%
</text>
</g>
)
};
const createLanguageTextNode = ({ langs, totalSize, x, y }: { langs: Array<data>, totalSize: number, x: number, y: number}) => {
return langs.map((lang, index) => {
if (index % 2 === 0) {
return createCompactLangNode({
lang,
x,
y: 12.5 * index + y,
totalSize
});
}
return createCompactLangNode({
lang,
x: 150,
y: 12.5 + 12.5 * index,
totalSize
});
});
};
const lowercaseTrim = (name: string) => name.toLowerCase().trim();
const renderTopLanguages = (topLangs: Record<string, data>, options: parsedQuery = {}) => {
const {
hide_title,
hide_border,
card_width,
title_color,
text_color,
bg_color,
hide,
language_count,
theme,
layout,
} = options;
let langs = Object.values(topLangs);
let langsToHide: Record<string, boolean> = {};
// populate langsToHide map for quick lookup
// while filtering out
if (hide) {
hide.forEach((langName) => {
langsToHide[lowercaseTrim(langName)] = true;
});
}
// filter out langauges to be hidden
langs = langs
.sort((a, b) => b.size - a.size)
.filter((lang) => {
return !langsToHide[lowercaseTrim(lang.name)];
})
.slice(0, language_count || 5);
const totalLanguageSize = langs.reduce((acc, curr) => {
return acc + curr.size;
}, 0);
// returns theme based colors with proper overrides and defaults
const { titleColor, textColor, bgColor } = getCardColors({
title_color,
text_color,
bg_color,
theme,
});
let width = typeof card_width !== 'number' ? 300 : isNaN(card_width) ? 300 : card_width;
let height = 45 + (langs.length + 1) * 40;
let finalLayout: JSX.Element | Array<JSX.Element>;
// RENDER COMPACT LAYOUT
if (layout === "compact") {
width = width + 50;
height = 30 + (langs.length / 2 + 1) * 40;
// progressOffset holds the previous language's width and used to offset the next language
// so that we can stack them one after another, like this: [--][----][---]
let progressOffset = 0;
const compactProgressBar = langs
.map((lang, index) => {
const percentage = parseFloat((
(lang.size / totalLanguageSize) *
(width - 50)
).toFixed(2));
const progress =
percentage < 10 ? percentage + 10 : percentage;
const output = (
<rect
key={index}
mask="url(#rect-mask)"
data-testid="lang-progress"
x={progressOffset}
y="0"
width={progress}
height="8"
fill={lang.color || "#858585"}
/>
)
progressOffset += percentage;
return output;
})
finalLayout = (
<>
<mask id="rect-mask">
<rect x="0" y="0" width={
width - 50
} height="8" fill="white" rx="5" />
</mask>
{compactProgressBar}
{createLanguageTextNode({
x: 0,
y: 25,
langs,
totalSize: totalLanguageSize,
})}
</>
)
finalLayout = (
<>
<mask id="rect-mask">
<rect x="0" y="0" width={width - 50} height="8" fill="white" rx="5" />
</mask>
{compactProgressBar}
{createLanguageTextNode({
x: 0,
y: 25,
langs,
totalSize: totalLanguageSize,
})}
</>
)
} else {
finalLayout = FlexLayout({
items: langs.map((lang) => {
return createProgressNode({
width: width,
name: lang.name,
color: lang.color || "#858585",
progress: parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)),
progress2: parseFloat(((lang.recentSize / totalLanguageSize) * 100).toFixed(2)),
});
}),
gap: 40,
direction: "column",
})
}
const card = new Card(
width,
height,
{
titleColor,
textColor,
bgColor,
},
"Most Used Languages",
)
card.disableAnimations();
card.setHideBorder(hide_border || false);
card.setHideTitle(hide_title || false);
card.setCSS(`
.lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} }
`);
return card.render(
<svg data-testid="lang-items" x="25">
{finalLayout}
</svg>
)
};
export default renderTopLanguages

View File

@@ -1,7 +1,16 @@
import React from 'react'
import themes from '../../themes/themes.json'
import FlexLayout from '../components/FlexLayout'
import { FlexLayout } from './utils'
import { getAnimations } from '../getStyles'
import { getColor, parseBoolean } from './utils'
export interface CardOptions {
title_color?: string
bg_color?: string
hide_border?: boolean
hide_title?: boolean
theme?: keyof typeof themes
}
export default class Card {
public hideBorder = false
@@ -10,45 +19,32 @@ export default class Card {
public paddingX = 25
public paddingY = 35
public animations = true
public height = 100
public width = 100
public title = ''
public colors: {
titleColor?: string | Array<string>,
bgColor?: string | Array<string>
} = {}
public titlePrefix?: JSX.Element
constructor(
public width = 100,
public height = 100,
public colors: {titleColor?: string | Array<string>, textColor?: string | Array<string>, bgColor?: string | Array<string>, iconColor?: string | Array<string>} = {},
public title = "",
public titlePrefixIcon?: string
) {}
disableAnimations() {
this.animations = false;
}
setCSS(value: string) {
this.css = value;
}
setHideBorder(value: boolean) {
this.hideBorder = value;
}
setHideTitle(value: boolean) {
this.hideTitle = value;
if (value) {
this.height -= 30;
constructor(options?: CardOptions) {
if (options) {
this.hideBorder = parseBoolean(options.hide_border)
this.hideTitle = parseBoolean(options.hide_title)
this.colors = {
titleColor: getColor('title_color', options.title_color, options.theme),
bgColor: getColor('bg_color', options.bg_color, options.theme),
}
}
}
setTitle(text: string) {
this.title = text;
}
renderTitle() {
const titleText = (
<text
x="0"
y="0"
className="header"
data-testid="header"
>{this.title}</text>
)
@@ -63,18 +59,17 @@ export default class Card {
width="16"
height="16"
>
${this.titlePrefixIcon}
{this.titlePrefix}
</svg>
)
return (
<g
data-testid="card-title"
transform={`translate(${this.paddingX}, ${this.paddingY})`}
>
{FlexLayout({
items: [this.titlePrefixIcon && prefixIcon, titleText],
gap: 25,
})}
<FlexLayout
items={[this.titlePrefix && prefixIcon, titleText]}
gap={25}
/>
</g>
)
}
@@ -104,7 +99,7 @@ export default class Card {
return (
<svg
width={this.width}
height={this.height}
height={this.height - (this.hideTitle ? 30 : 0)}
viewBox={`0 0 ${this.width} ${this.height}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -117,17 +112,28 @@ export default class Card {
}
${this.css}
${
process.env.NODE_ENV === "test" || !this.animations
? ""
: getAnimations()
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`}</style>
{this.renderGradient()}
<rect
data-testid="card-bg"
x="0.5"
y="0.5"
rx="4.5"
@@ -145,7 +151,6 @@ export default class Card {
{this.hideTitle ? "" : this.renderTitle()}
<g
data-testid="main-card-body"
transform={`translate(0, ${
this.hideTitle ? this.paddingX : this.paddingY + 20
})`}

View File

@@ -1,3 +0,0 @@
const blacklist = ["renovate-bot", "technote-space", "sw-yx"];
export default blacklist

View File

@@ -1,19 +1,20 @@
import { CustomError, request } from './utils'
import { AxiosPromise } from 'axios';
import { CustomError } from './utils'
import { CodeStatsResponse } from '../interfaces';
const retryer = async <T = AxiosPromise<{error: string}>>(fetcher: (variables: {login: string}) => T, variables: {login: string}, retries = 0): Promise<T> => {
export default async function retryer<T = Promise<CodeStatsResponse>>(
fetcher: (username: string) => T,
data: string,
retries = 0,
err?: any
): Promise<T> {
if (retries > 7) {
throw new CustomError("Maximum retries exceeded", 'MAX_RETRY');
throw new CustomError("Maximum retries exceeded" + err, 'MAX_RETRY')
}
try {
let response = await fetcher(
variables
return await fetcher(
data
)
return response;
} catch (err) {
return retryer(fetcher, variables, 1 + retries);
return retryer(fetcher, data, ++retries, err)
}
};
export default retryer
}

208
src/common/utils.ts Normal file
View File

@@ -0,0 +1,208 @@
import fetch from 'node-fetch'
import { Response } from 'express'
import wrap from 'word-wrap'
import themes from '../../themes/themes.json'
import { CodeStatsResponse } from '../interfaces';
import languageColor from '../../themes/language-bar.json'
/**
* Encode a string to escape HTML
*
* https://stackoverflow.com/a/48073476/10629172
* @param str the string to encode
*/
export function encodeHTML(str: string) {
return str
.replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
return "&#" + i.charCodeAt(0) + ";";
})
.replace(/\u0008/gim, "");
}
export const kFormatter = (num: number) =>
Math.abs(num) > 999 ?
trunc(num / 1000) + 'k' :
num
/**
* Transform the `value` into a Boolean
* @param value the value to transform
*/
export function parseBoolean(value: boolean | string | undefined) {
if (value === "true" || value === true) {
return true;
} else {
return false;
}
}
export function parseArray(str: string | undefined) {
if (!str) return [];
return str.split(",");
}
export function clampValue(number: number, min: number, max?: number) {
return Math.max(min, max ? Math.min(number, max) : number);
}
export async function request(username: string): Promise<CodeStatsResponse> {
const resp = await fetch(
`https://codestats.net/api/users/${username}`
);
return resp.json();
}
export async function profileGraphRequest<T>(body: string): Promise<T> {
const resp = await fetch(
'https://codestats.net/profile-graph',
{
body,
method: 'POST'
}
)
return resp.json()
}
export function getColor(color: keyof typeof themes.default, replacementColor?: string, theme: keyof typeof themes = 'default') {
return '#' + (replacementColor ? replacementColor : themes[theme][color])
}
export function wrapTextMultiline(text: string, width = 60, maxLines = 3) {
const wrapped = wrap(encodeHTML(text), { width })
.split("\n") // Split wrapped lines to get an array of lines
.map((line) => line.trim()); // Remove leading and trailing whitespace of each line
const lines = wrapped.slice(0, maxLines); // Only consider maxLines lines
// Add "..." to the last line if the text exceeds maxLines
if (wrapped.length > maxLines) {
lines[maxLines - 1] += "...";
}
// Remove empty lines if text fits in less than maxLines lines
const multiLineText = lines.filter(Boolean);
return multiLineText;
}
export const CONSTANTS = {
THIRTY_MINUTES: 1800,
TWO_HOURS: 7200,
FOUR_HOURS: 14400,
ONE_DAY: 86400,
LEVEL_FACTOR: 0.025,
};
export const SECONDARY_ERROR_MESSAGES = {
MAX_RETRY: "Make sur your profile is not private"
};
export class CustomError extends Error {
public secondaryMessage: string
constructor(message: string, public type: keyof typeof SECONDARY_ERROR_MESSAGES) {
super(message);
this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || "adsad";
}
static MAX_RETRY = "MAX_RETRY";
static USER_NOT_FOUND = "USER_NOT_FOUND";
}
/**
* Return the level depending on the xp
*
* https://codestats.net/api-docs
* @param xp the xp count
*/
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 trunc((CONSTANTS.LEVEL_FACTOR * Math.sqrt(xp) - currentLvl) * 100, 2)
}
/**
* Truncate a number without moving it to a string and reparsing it
*
* https://stackoverflow.com/a/29494612/7335674
* @param number the number to truncate
* @param digits the number of digits after the dot
*/
export function trunc(number: number, digits: number = 0) {
const pow = Math.pow(10, digits)
return Math.round(number * pow) / pow
}
export function parseNumber(number: string | number | undefined): number | undefined {
if (typeof number === 'undefined' || typeof number === 'number') {
return number
}
const n = parseFloat(number)
if (isNaN(n)) {
return undefined
}
return n
}
export function calculateCircleProgress(percent: number) {
let radius = 40;
let c = Math.PI * radius * 2
percent = clampValue(percent, 0, 100)
let percentage = ((100 - percent) / 100) * c
return percentage;
}
/**
* Prepare the response
* @param res the response object
*/
export function prepareResponse(res: Response) {
res.setHeader("Content-Type", "image/svg+xml")
}
/**
* set the cache in the response
* @param res the Response object
* @param cache The cache time in seconds
*/
export function setCache(res: Response, cache: number) {
if (isNaN(cache)) {
cache = CONSTANTS.THIRTY_MINUTES
}
const clampedCache = clampValue(cache, CONSTANTS.THIRTY_MINUTES, CONSTANTS.ONE_DAY)
res.setHeader('Cache-Control', `public, stale-while-revalidate, max-age=${clampedCache} s-maxage=${clampedCache}`)
}
export function lowercaseTrim(str: string) {
return str.toLowerCase().trim()
}
export function formatDateNumber(number: number): string {
if (number < 10) {
return '0' + number
}
return number + ''
}
export function formatDate(date: Date): string {
return `${date.getFullYear()}-${formatDateNumber(date.getMonth() + 1)}-${formatDateNumber(date.getDate())}`
}
export function getColorOfLanguage(name: string): string {
return name in languageColor ? languageColor[name as keyof typeof languageColor].color || '#000' : '#000'
}

View File

@@ -1,203 +0,0 @@
import React from 'react'
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 (
<svg width="495" height="120" viewBox="0 0 495 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>{`
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2F80ED }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #252525 }
.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://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>
</text>
</svg>
)
};
// https://stackoverflow.com/a/48073476/10629172
export function encodeHTML(str: string) {
return str
.replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => {
return "&#" + i.charCodeAt(0) + ";";
})
.replace(/\u0008/gim, "");
}
export function kFormatter(num: number) {
return Math.abs(num) > 999
? Math.sign(num) * parseInt((Math.abs(num) / 1000).toFixed(1)) + "k"
: Math.sign(num) * Math.abs(num);
}
export function isValidHexColor(hexColor: string) {
return new RegExp(
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/
).test(hexColor);
}
export function parseBoolean(value: string | undefined) {
if (value === "true") {
return true;
} else {
return false;
}
}
export function parseArray(str: string | undefined) {
if (!str) return [];
return str.split(",");
}
export function clampValue(number: number, min: number, max: number) {
return Math.max(min, Math.min(number, max));
}
export function isValidGradient(colors: Array<string>) {
return isValidHexColor(colors[1]) && isValidHexColor(colors[2]);
}
export function fallbackColor(color: string, fallbackColor: Array<string>| string) {
let colors = color.split(",");
let gradient = null;
if (colors.length > 1 && isValidGradient(colors)) {
gradient = colors;
}
return (
(gradient ? gradient : isValidHexColor(color) && `#${color}`) ||
fallbackColor
);
}
export function request(data: {login: string}): AxiosPromise<CodeStatsResponse> {
return axios({
url: "https://codestats.net/api/users/" + data.login,
method: "get",
});
}
/**
*
* @param {String[]} items
* @param {Number} gap
* @param {string} direction
*
* @description
* Auto layout utility, allows us to layout things
* vertically or horizontally with proper gaping
*/
export function FlexLayout({ items, gap, direction }: {items: Array<JSX.Element | string | undefined>, gap: number, direction?: string}) {
// filter() for filtering out empty strings
return items.filter(Boolean).map((item, i) => {
let transform = `translate(${gap * i}, 0)`;
if (direction === "column") {
transform = `translate(0, ${gap * i})`;
}
return (
<g key={i} transform={transform}>{item}</g>
);
});
}
// returns theme based colors with proper overrides and defaults
export function getCardColors({
title_color,
text_color,
icon_color,
bg_color,
theme,
fallbackTheme = "default",
}: {title_color?: string, text_color?: string, icon_color?: string, bg_color?: string, theme?: keyof typeof themes, fallbackTheme?: keyof typeof themes}) {
const defaultTheme = themes[fallbackTheme];
const selectedTheme = themes[theme as 'default'] || defaultTheme;
// get the color provided by the user else the theme color
// finally if both colors are invalid fallback to default theme
const titleColor = fallbackColor(
title_color || selectedTheme.title_color,
"#" + defaultTheme.title_color
);
const iconColor = fallbackColor(
icon_color || selectedTheme.icon_color,
"#" + defaultTheme.icon_color
);
const textColor = fallbackColor(
text_color || selectedTheme.text_color,
"#" + defaultTheme.text_color
);
const bgColor = fallbackColor(
bg_color || selectedTheme.bg_color,
"#" + defaultTheme.bg_color
);
return { titleColor, iconColor, textColor, bgColor };
}
export function wrapTextMultiline(text: string, width = 60, maxLines = 3) {
const wrapped = wrap(encodeHTML(text), { width })
.split("\n") // Split wrapped lines to get an array of lines
.map((line) => line.trim()); // Remove leading and trailing whitespace of each line
const lines = wrapped.slice(0, maxLines); // Only consider maxLines lines
// Add "..." to the last line if the text exceeds maxLines
if (wrapped.length > maxLines) {
lines[maxLines - 1] += "...";
}
// Remove empty lines if text fits in less than maxLines lines
const multiLineText = lines.filter(Boolean);
return multiLineText;
}
export const noop = () => {};
// return console instance based on the environment
export const logger =
process.env.NODE_ENV !== "test" ? console : { log: noop, error: noop };
export const CONSTANTS = {
THIRTY_MINUTES: 1800,
TWO_HOURS: 7200,
FOUR_HOURS: 14400,
ONE_DAY: 86400,
LEVEL_FACTOR: 0.025,
};
export const SECONDARY_ERROR_MESSAGES = {
MAX_RETRY: "Make sur your profile is not private"
};
export class CustomError extends Error {
public secondaryMessage: string
constructor(message: string, public type: keyof typeof SECONDARY_ERROR_MESSAGES) {
super(message);
this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || "adsad";
}
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)
}

24
src/components/Error.tsx Normal file
View File

@@ -0,0 +1,24 @@
import React from 'react'
import { encodeHTML } from '../common/utils'
export default class Error extends React.Component<{
message: string
secondaryMessage?: string
}> {
public render = () => (
<svg width="495" height="120" viewBox="0 0 495 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>{`
.text { font: 600 16px 'Segoe UI', Ubuntu, Sans-Serif; fill: #2F80ED }
.small { font: 600 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: #252525 }
.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://dze.io/3nL29</text>
<text data-testid="message" x="25" y="55" className="text small">
<tspan x="25" dy="18">{encodeHTML(this.props.message)}</tspan>
<tspan x="25" dy="18" className="gray">{this.props.secondaryMessage}</tspan>
</text>
</svg>
)
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
export default class FlexLayout extends React.Component<{
items: Array<JSX.Element | string | undefined>
gap: number
direction?: 'column'
}> {
public render() {
return this.props.items.filter(Boolean).map((item, index) => (
<g key={index} transform={this.getGap(index)}>{item}</g>
))
}
private getGap(index: number) {
const gap = this.props.gap * index
let transform = `translate(${gap}, 0)`
if (this.props.direction === 'column') {
transform = `translate(0, ${gap})`
}
return transform
}
}

View File

@@ -1,70 +1,115 @@
import { request, logger, CONSTANTS, getLevel, CustomError } from '../common/utils'
import { request, CONSTANTS, getLevel, profileGraphRequest, CustomError, formatDateNumber, formatDate, getColorOfLanguage } from '../common/utils'
import retryer from '../common/retryer'
import languageColor from '../../themes/language-bar.json'
export interface data {
name: string
size: number
color: string
recentSize: number
}
import { CodeStatsHistoryGraph, TopLanguages } from '../interfaces'
export async function fetchProfile(username: string) {
if (!username) throw Error('Invalid Username')
const response = await retryer(request, {login: username})
const response = await retryer(request, username)
return {
username,
xp: response.data.total_xp,
recentXp: response.data.new_xp,
level: getLevel(response.data.total_xp + response.data.new_xp)
xp: response.total_xp,
recentXp: response.new_xp,
level: getLevel(response.total_xp)
}
}
export async function fetchTopLanguages(username: string) {
if (!username) throw Error("Invalid username");
export async function fetchHistory(username: string, days: number) {
if (!username) throw Error('Invalid Username')
let res = await retryer(request, { login: username });
const date = new Date()
date.setDate(date.getDate() - (days - 1))
let repoNodes = res.data.languages;
const body = `{
profile(username: "${username}") {
day_language_xps: dayLanguageXps(since: "${formatDate(date)}") {date language xp}
}
}`
// Remap nodes
const list = []
for (const key in repoNodes) {
const item = repoNodes[key]
list.push({
name: key,
color: key in languageColor ? languageColor[key as keyof typeof languageColor].color || '#000000' : '#000000',
xp: item.xps,
recentXp: item.new_xps + item.xps,
lvl: Math.trunc(Math.floor(CONSTANTS.LEVEL_FACTOR * Math.sqrt(item.xps)))
})
const response = await retryer<Promise<CodeStatsHistoryGraph>>(profileGraphRequest, body)
if (response.errors) {
throw new CustomError(response.errors[0].message, 'MAX_RETRY')
}
repoNodes = list
.filter((node) => {
return node.xp > 0;
const result: Record<string /* Date */, Array<{
xp: number
language: string
}>> = {}
const languagesData: Record<string, number> = {}
for (const data of response.data.profile.day_language_xps) {
let day = result[data.date]
if (!day) {
day = []
}
day.push({
xp: data.xp,
language: data.language
})
.sort((a, b) => b.xp - a.xp)
.reduce((acc, prev) => {
if (data.language in languagesData) {
languagesData[data.language] += data.xp
} else {
languagesData[data.language] = data.xp
}
result[data.date] = day
}
for (const key of Object.keys(result)) {
const item = result[key]
result[key] = item.sort((a, b) => languagesData[b.language] - languagesData[a.language])
}
const keys = Object.keys(result)
for (const day of keys) {
if (keys.indexOf(day) === 0 && day === formatDate(date)) {
continue
}
const date2 = new Date(day)
date2.setDate(date2.getDate() - 1)
const oldDate = formatDate(date2)
if (!(oldDate in result)) {
result[oldDate] = []
}
}
return Object.keys(result).map((el) => {
return {
...acc,
[prev.name]: {
name: prev.name,
color: prev.color,
size: prev.xp,
recentSize: prev.recentXp
},
};
}, {});
data: result[el],
day: el,
total: result[el].reduce((prvs, crnt) => prvs + crnt.xp, 0)
}
}).sort((a, b) => a.day < b.day ? 1 : -1)
const topLangs = Object.keys(repoNodes)
// .slice(0, 5)
.reduce((result: Record<string, any>, key) => {
result[key] = repoNodes[key];
return result;
}, {});
return topLangs as Record<string, data>
}
export async function fetchTopLanguages(username: string): Promise<TopLanguages> {
if (!username) throw Error("Invalid username")
let res = await retryer(request, username)
const langs = res.languages
const resp = Object.keys(langs)
.map((key) => {
const item = langs[key]
return {
xp: item.xps,
recentXp: item.new_xps,
color: getColorOfLanguage(key),
name: key,
level: getLevel(item.xps)
}
})
.sort((a, b) => (b.xp + b.recentXp) - (a.xp + a.recentXp))
return {
username,
langs: resp
}
}

View File

@@ -1,15 +0,0 @@
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

@@ -1,92 +0,0 @@
const calculateCircleProgress = (value: number) => {
let radius = 40;
let c = Math.PI * (radius * 2);
if (value < 0) value = 0;
if (value > 100) value = 100;
let percentage = ((100 - value) / 100) * c;
return percentage;
};
const getProgressAnimation = ({ progress }: {progress: number}) => {
return `
@keyframes rankAnimation {
from {
stroke-dashoffset: ${calculateCircleProgress(0)};
}
to {
stroke-dashoffset: ${calculateCircleProgress(progress)};
}
}
`;
};
export const getAnimations = () => {
return `
/* Animations */
@keyframes scaleInAnimation {
from {
transform: translate(-5px, 5px) scale(0);
}
to {
transform: translate(-5px, 5px) scale(1);
}
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
};
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"};
}
.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 })}
`;
};

50
src/interfaces.d.ts vendored Normal file
View File

@@ -0,0 +1,50 @@
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>
}
export interface CodeStatsHistoryGraph {
data: {
profile: {
day_language_xps: Array<{
xp: number,
language: string
date: string
}>
}
}
errors?: Array<{
message: string
}>
}
export interface Profile {
username: string
xp: number
recentXp: number
level: number
}
export interface TopLanguages {
username: string
langs: Array<TopLanguage>
}
export interface TopLanguage {
xp: number
recentXp: number
color: string
name: string
level: number
}