Compare commits

..
Author SHA1 Message Date
François Rozet 1afe4189cb Add finer ranking levels 2023-06-01 20:24:21 +02:00
20 changed files with 106 additions and 211 deletions
+1 -6
View File
@@ -15,15 +15,10 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }}
with:
filter: "1772"
label: true
label: false
dashboard: true
dashboard_show_total_reactions: true
top_issues: true
top_bugs: true
top_features: true
top_pull_requests: true
custom_pull_requests_label: themes
top_custom_pull_requests_label: ":star: top themes"
top_custom_pull_requests_label_description:
The description used for the top custom pull requests.
top_custom_pull_requests_label_colour: #A23599
-10
View File
@@ -44,16 +44,6 @@ export default async (req, res) => {
return res.send(renderError("Something went wrong", "Locale not found"));
}
if (
layout !== undefined &&
(typeof layout !== "string" ||
!["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout))
) {
return res.send(
renderError("Something went wrong", "Incorrect layout input"),
);
}
try {
const topLangs = await fetchTopLanguages(
username,
+2 -1
View File
@@ -28,6 +28,7 @@ export default async (req, res) => {
langs_count,
hide,
api_domain,
range,
border_radius,
border_color,
} = req.query;
@@ -39,7 +40,7 @@ export default async (req, res) => {
}
try {
const stats = await fetchWakatimeStats({ username, api_domain });
const stats = await fetchWakatimeStats({ username, api_domain, range });
let cacheSeconds = clampValue(
parseInt(cache_seconds || CONSTANTS.FOUR_HOURS, 10),
+9 -11
View File
@@ -69,15 +69,12 @@
<img src="https://cfstatic.give.do/logo.png" alt="Give india logo" width="200" />
</a>
Are you considering supporting the project by donating to me? Please DO NOT!!
Are you considering supporting the project by donating? Please DO NOT!!
Instead, Help India fight the second deadly wave of COVID-19.
Thousands of people are dying in India because of a lack of Oxygen & also COVID-related infrastructure.
<img src="https://cfstatic.give.do/910ede2a-7892-43fe-8c8a-dea45e96d950.webp" alt="Picture of Coromandel Express train tragedy" width="35%">
India just suffered one of the most devastating train accident and your help will be immensely valuable for the people who were effected by this tragedy.
Please visit [this link](https://give.do/fundraisers/stand-beside-the-victims-of-the-coromandel-express-train-tragedy-in-odisha-donate-now) and make a small donation to help the people in need. A small donation goes a long way. :heart:
Visit <https://indiafightscorona.giveindia.org> and make a small donation to help us fight COVID and overcome this crisis. A small donation goes a long way. :heart:
</p>
@@ -295,7 +292,7 @@ You can provide multiple comma-separated values in the bg_color option to render
- `hide_title` - _(boolean)_. Default: `false`.
- `layout` - Switch between five available layouts `normal` & `compact` & `donut` & `donut-vertical` & `pie`. Default: `normal`.
- `card_width` - Set the card's width manually _(number)_. Default `300`.
- `langs_count` - Show more languages on the card, between 1-10 _(number)_. Default: `5` for `normal` and `donut`, `6` for other layouts.
- `langs_count` - Show more languages on the card, between 1-10 _(number)_. Default `5`.
- `exclude_repo` - Exclude specified repositories _(Comma-separated values)_. Default: `[] (blank array)`.
- `custom_title` - Sets a custom title for the card _(string)_. Default `Most Used Languages`.
- `disable_animations` - Disables all animations in the card _(boolean)_. Default: `false`.
@@ -318,6 +315,7 @@ You can provide multiple comma-separated values in the bg_color option to render
- `layout` - Switch between two available layouts `default` & `compact`. Default `default`.
- `langs_count` - Limit the number of languages on the card, defaults to all reported languages _(number)_.
- `api_domain` - Set a custom API domain for the card, e.g. to use services like [Hakatime](https://github.com/mujx/hakatime) or [Wakapi](https://github.com/muety/wakapi) _(string)_. Default `Waka API`.
- `range` Request a range different from your WakaTime default, e.g. `last_7_days`. See [WakaTime API docs](https://wakatime.com/developers#stats) for a list of available options. _(YYYY-MM, last_7_days, last_30_days, last_6_months, last_year, or all_time)_. Default `all_time`.
* * *
@@ -470,15 +468,15 @@ You can use the `&hide_progress=true` option to hide the percentages and the pro
# Wakatime Week Stats
> **Warning**
> Please be aware that we currently only show data from Wakatime profiles that are public. You therefore have to make sure that **BOTH** `Display code time publicly` and `Display languages, editors, os, categories publicly` are enabled.
Change the `?username=` value to your [Wakatime](https://wakatime.com) username.
```md
[![Harlok's wakatime stats](https://github-readme-stats.vercel.app/api/wakatime?username=Harlok)](https://github.com/anuraghazra/github-readme-stats)
```
> **Note**:
> Please be aware that we currently only show data from Wakatime profiles that are public.
### Demo
[![Harlok's wakatime stats](https://github-readme-stats.vercel.app/api/wakatime?username=Harlok)](https://github.com/anuraghazra/github-readme-stats)
+25 -29
View File
@@ -1,5 +1,10 @@
function expsf(x, lambda = 1) {
return 2 ** (-lambda * x);
function exponential_cdf(x) {
return 1 - 2 ** -x;
}
function log_normal_cdf(x) {
// approximation
return x / (1 + x);
}
/**
@@ -13,7 +18,7 @@ function expsf(x, lambda = 1) {
* @param {number} params.repos Total number of repos.
* @param {number} params.stars The number of stars.
* @param {number} params.followers The number of followers.
* @returns {{level: string, score: number}}} The users rank.
* @returns {{level: string, percentile: number}}} The users rank.
*/
function calculateRank({
all_commits,
@@ -24,15 +29,15 @@ function calculateRank({
stars,
followers,
}) {
const COMMITS_MEAN = all_commits ? 1000 : 250,
const COMMITS_MEDIAN = all_commits ? 1000 : 250,
COMMITS_WEIGHT = 2;
const PRS_MEAN = 50,
const PRS_MEDIAN = 50,
PRS_WEIGHT = 3;
const ISSUES_MEAN = 25,
const ISSUES_MEDIAN = 25,
ISSUES_WEIGHT = 1;
const STARS_MEAN = 250,
const STARS_MEDIAN = 50,
STARS_WEIGHT = 4;
const FOLLOWERS_MEAN = 25,
const FOLLOWERS_MEDIAN = 10,
FOLLOWERS_WEIGHT = 1;
const TOTAL_WEIGHT =
@@ -42,30 +47,21 @@ function calculateRank({
STARS_WEIGHT +
FOLLOWERS_WEIGHT;
const THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100];
const LEVELS = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"];
const rank =
(COMMITS_WEIGHT * expsf(commits, 1 / COMMITS_MEAN) +
PRS_WEIGHT * expsf(prs, 1 / PRS_MEAN) +
ISSUES_WEIGHT * expsf(issues, 1 / ISSUES_MEAN) +
STARS_WEIGHT * expsf(stars, 1 / STARS_MEAN) +
FOLLOWERS_WEIGHT * expsf(followers, 1 / FOLLOWERS_MEAN)) /
TOTAL_WEIGHT;
1 -
(COMMITS_WEIGHT * exponential_cdf(commits / COMMITS_MEDIAN) +
PRS_WEIGHT * exponential_cdf(prs / PRS_MEDIAN) +
ISSUES_WEIGHT * exponential_cdf(issues / ISSUES_MEDIAN) +
STARS_WEIGHT * log_normal_cdf(stars / STARS_MEDIAN) +
FOLLOWERS_WEIGHT * log_normal_cdf(followers / FOLLOWERS_MEDIAN)) /
TOTAL_WEIGHT;
const RANK_S_PLUS = 0.025;
const RANK_S = 0.1;
const RANK_A_PLUS = 0.25;
const RANK_A = 0.5;
const RANK_B_PLUS = 0.75;
const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)];
const level = (() => {
if (rank <= RANK_S_PLUS) return "S+";
if (rank <= RANK_S) return "S";
if (rank <= RANK_A_PLUS) return "A+";
if (rank <= RANK_A) return "A";
if (rank <= RANK_B_PLUS) return "B+";
return "B";
})();
return { level, score: rank * 100 };
return { level: level, percentile: rank * 100 };
}
export { calculateRank };
+2 -3
View File
@@ -209,9 +209,8 @@ const renderStatsCard = (stats = {}, options = { hide: [] }) => {
hide_rank ? 0 : 150,
);
// the better user's score the the rank will be closer to zero so
// subtracting 100 to get the progress in 100%
const progress = 100 - rank.score;
// the lower the user's percentile the better
const progress = 100 - rank.percentile;
const cssStyles = getStyles({
titleColor,
ringColor,
+3 -29
View File
@@ -14,16 +14,11 @@ import { langCardLocales } from "../translations.js";
const DEFAULT_CARD_WIDTH = 300;
const MIN_CARD_WIDTH = 280;
const DEFAULT_LANGS_COUNT = 5;
const DEFAULT_LANG_COLOR = "#858585";
const CARD_PADDING = 25;
const COMPACT_LAYOUT_BASE_HEIGHT = 90;
const NORMAL_LAYOUT_DEFAULT_LANGS_COUNT = 5;
const COMPACT_LAYOUT_DEFAULT_LANGS_COUNT = 6;
const DONUT_LAYOUT_DEFAULT_LANGS_COUNT = 5;
const PIE_LAYOUT_DEFAULT_LANGS_COUNT = 6;
const DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT = 6;
/**
* @typedef {import("../fetchers/types").Lang} Lang
*/
@@ -673,26 +668,6 @@ const noLanguagesDataNode = ({ color, text, layout }) => {
`;
};
/**
* Get default languages count for provided card layout.
*
* @param {import("./types").TopLangOptions["layout"] | undefined} layout Input layout string
* @return {number} Default languages count for input layout
*/
const getDefaultLanguagesCountByLayout = (layout) => {
if (layout === "compact") {
return COMPACT_LAYOUT_DEFAULT_LANGS_COUNT;
} else if (layout === "donut") {
return DONUT_LAYOUT_DEFAULT_LANGS_COUNT;
} else if (layout === "donut-vertical") {
return DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT;
} else if (layout === "pie") {
return PIE_LAYOUT_DEFAULT_LANGS_COUNT;
} else {
return NORMAL_LAYOUT_DEFAULT_LANGS_COUNT;
}
};
/**
* Renders card that display user's most frequently used programming languages.
*
@@ -714,7 +689,7 @@ const renderTopLanguages = (topLangs, options = {}) => {
layout,
custom_title,
locale,
langs_count = getDefaultLanguagesCountByLayout(layout),
langs_count = DEFAULT_LANGS_COUNT,
border_radius,
border_color,
disable_animations,
@@ -771,7 +746,7 @@ const renderTopLanguages = (topLangs, options = {}) => {
totalLanguageSize,
hide_progress,
);
} else if (layout === "donut") {
} else if (layout?.toLowerCase() === "donut") {
height = calculateDonutLayoutHeight(langs.length);
width = width + 50; // padding
finalLayout = renderDonutLayout(langs, width, totalLanguageSize);
@@ -862,5 +837,4 @@ export {
trimTopLanguages,
renderTopLanguages,
MIN_CARD_WIDTH,
getDefaultLanguagesCountByLayout,
};
+3 -22
View File
@@ -279,11 +279,7 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
: noCodingActivityNode({
// @ts-ignore
color: textColor,
text: !stats.is_coding_activity_visible
? i18n.t("wakatimecard.notpublic")
: stats.is_other_usage_visible
? i18n.t("wakatimecard.nocodingactivity")
: i18n.t("wakatimecard.nocodedetails"),
text: i18n.t("wakatimecard.nocodingactivity"),
})
}
`;
@@ -308,11 +304,7 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
noCodingActivityNode({
// @ts-ignore
color: textColor,
text: !stats.is_coding_activity_visible
? i18n.t("wakatimecard.notpublic")
: stats.is_other_usage_visible
? i18n.t("wakatimecard.nocodingactivity")
: i18n.t("wakatimecard.nocodedetails"),
text: i18n.t("wakatimecard.nocodingactivity"),
}),
],
gap: lheight,
@@ -320,20 +312,9 @@ const renderWakatimeCard = (stats = {}, options = { hide: [] }) => {
}).join("");
}
// Get title range text
let titleText = i18n.t("wakatimecard.title");
switch (stats.range) {
case "last_7_days":
titleText += ` (${i18n.t("wakatimecard.last7days")})`;
break;
case "last_year":
titleText += ` (${i18n.t("wakatimecard.lastyear")})`;
break;
}
const card = new Card({
customTitle: custom_title,
defaultTitle: titleText,
defaultTitle: i18n.t("wakatimecard.title"),
width: 495,
height,
border_radius,
-2
View File
@@ -305,7 +305,6 @@ const SECONDARY_ERROR_MESSAGES = {
"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",
GRAPHQL_ERROR: "Please try again later",
WAKATIME_USER_NOT_FOUND: "Make sure you have a public WakaTime profile",
};
/**
@@ -325,7 +324,6 @@ class CustomError extends Error {
static MAX_RETRY = "MAX_RETRY";
static USER_NOT_FOUND = "USER_NOT_FOUND";
static GRAPHQL_ERROR = "GRAPHQL_ERROR";
static WAKATIME_ERROR = "WAKATIME_ERROR";
}
/**
+1 -1
View File
@@ -192,7 +192,7 @@ const fetchStats = async (
totalIssues: 0,
totalStars: 0,
contributedTo: 0,
rank: { level: "B", score: 0 },
rank: { level: "C", percentile: 100 },
};
let res = await statsFetcher(username);
+1 -1
View File
@@ -22,7 +22,7 @@ export type StatsData = {
totalIssues: number;
totalStars: number;
contributedTo: number;
rank: { level: string; score: number };
rank: { level: string; percentile: number };
};
export type Lang = {
+8 -8
View File
@@ -1,29 +1,29 @@
import axios from "axios";
import { CustomError, MissingParamError } from "../common/utils.js";
import { I18n } from "../common/I18n.js";
import { MissingParamError } from "../common/utils.js";
/**
* WakaTime data fetcher.
*
* @param {{username: string, api_domain: string }} props Fetcher props.
* @param {{username: string, api_domain: string, range: string}} props Fetcher props.
* @returns {Promise<WakaTimeData>} WakaTime data response.
*/
const fetchWakatimeStats = async ({ username, api_domain }) => {
const fetchWakatimeStats = async ({ username, api_domain, range }) => {
if (!username) throw new MissingParamError(["username"]);
try {
const { data } = await axios.get(
`https://${
api_domain ? api_domain.replace(/\/$/gi, "") : "wakatime.com"
}/api/v1/users/${username}/stats?is_including_today=true`,
}/api/v1/users/${username}/stats/${
range || "all_time"
}?is_including_today=true`,
);
return data.data;
} catch (err) {
if (err.response.status < 200 || err.response.status > 299) {
throw new CustomError(
`Could not resolve to a User with the login of '${username}'`,
"WAKATIME_USER_NOT_FOUND",
throw new Error(
"Wakatime user not found, make sure you have a wakatime profile",
);
}
throw err;
-16
View File
@@ -356,22 +356,6 @@ const wakatimeCardLocales = {
vi: "Thống Kê Wakatime",
se: "Wakatime statistik",
},
"wakatimecard.lastyear": {
en: "last year",
cn: "去年",
},
"wakatimecard.last7days": {
en: "last 7 days",
cn: "最近 7 天",
},
"wakatimecard.notpublic": {
en: "Wakatime user profile not public",
cn: "Wakatime 用户个人资料未公开",
},
"wakatimecard.nocodedetails": {
en: "User doesn't publicly share detailed code statistics",
cn: "用户不公开分享详细的代码统计信息",
},
"wakatimecard.nocodingactivity": {
ar: "لا يوجد نشاط برمجي لهذا الأسبوع",
cn: "本周没有编程活动",
@@ -123,7 +123,7 @@ exports[`Test Render Wakatime Card should render correctly with compact layout 1
y="0"
class="header"
data-testid="header"
>Wakatime Stats (last 7 days)</text>
>Wakatime Stats</text>
</g>
</g>
@@ -303,7 +303,7 @@ exports[`Test Render Wakatime Card should render correctly with compact layout w
y="0"
class="header"
data-testid="header"
>Wakatime Stats (last 7 days)</text>
>Wakatime Stats</text>
</g>
</g>
+39 -25
View File
@@ -2,7 +2,7 @@ import "@testing-library/jest-dom";
import { calculateRank } from "../src/calculateRank.js";
describe("Test calculateRank", () => {
it("new user gets B rank", () => {
it("new user gets C rank", () => {
expect(
calculateRank({
all_commits: false,
@@ -13,10 +13,24 @@ describe("Test calculateRank", () => {
stars: 0,
followers: 0,
}),
).toStrictEqual({ level: "B", score: 100 });
).toStrictEqual({ level: "C", percentile: 100 });
});
it("average user gets A rank", () => {
it("beginner user gets B- rank", () => {
expect(
calculateRank({
all_commits: false,
commits: 125,
prs: 25,
issues: 10,
repos: 0,
stars: 25,
followers: 5,
}),
).toStrictEqual({ level: "B-", percentile: 69.333868386557 });
});
it("median user gets B+ rank", () => {
expect(
calculateRank({
all_commits: false,
@@ -24,13 +38,13 @@ describe("Test calculateRank", () => {
prs: 50,
issues: 25,
repos: 0,
stars: 250,
followers: 25,
stars: 50,
followers: 10,
}),
).toStrictEqual({ level: "A", score: 50 });
).toStrictEqual({ level: "B+", percentile: 50 });
});
it("average user gets A rank (include_all_commits)", () => {
it("average user gets B+ rank (include_all_commits)", () => {
expect(
calculateRank({
all_commits: true,
@@ -38,13 +52,13 @@ describe("Test calculateRank", () => {
prs: 50,
issues: 25,
repos: 0,
stars: 250,
followers: 25,
stars: 50,
followers: 10,
}),
).toStrictEqual({ level: "A", score: 50 });
).toStrictEqual({ level: "B+", percentile: 50 });
});
it("more than average user gets A+ rank", () => {
it("advanced user gets A rank", () => {
expect(
calculateRank({
all_commits: false,
@@ -52,13 +66,13 @@ describe("Test calculateRank", () => {
prs: 100,
issues: 50,
repos: 0,
stars: 500,
followers: 50,
stars: 200,
followers: 40,
}),
).toStrictEqual({ level: "A+", score: 25 });
).toStrictEqual({ level: "A", percentile: 22.72727272727273 });
});
it("expert user gets S rank", () => {
it("expert user gets A+ rank", () => {
expect(
calculateRank({
all_commits: false,
@@ -66,23 +80,23 @@ describe("Test calculateRank", () => {
prs: 200,
issues: 100,
repos: 0,
stars: 1000,
followers: 100,
stars: 800,
followers: 160,
}),
).toStrictEqual({ level: "S", score: 6.25 });
).toStrictEqual({ level: "A+", percentile: 6.082887700534744 });
});
it("ezyang gets S+ rank", () => {
it("sindresorhus gets S rank", () => {
expect(
calculateRank({
all_commits: false,
commits: 1000,
prs: 4000,
issues: 2000,
commits: 1300,
prs: 1500,
issues: 4500,
repos: 0,
stars: 5000,
followers: 2000,
stars: 600000,
followers: 50000,
}),
).toStrictEqual({ level: "S+", score: 1.1363983154296875 });
).toStrictEqual({ level: "S", percentile: 0.49947889605312934 });
});
});
+5 -2
View File
@@ -20,7 +20,10 @@ const STATS_DATA = {
totalIssues: 1,
totalStars: 1,
contributedTo: 1,
rank: { level: "B", score: 98.50610674501908 },
rank: {
level: "A+",
score: 50.88831151384285,
},
};
const LANGS_DATA = {
@@ -51,7 +54,7 @@ const WAKATIME_DATA = {
is_up_to_date: false,
is_up_to_date_pending_future: false,
percent_calculated: 0,
range: "all_time",
range: "last_7_days",
status: "pending_update",
timeout: 15,
username: USER,
+1 -1
View File
@@ -105,7 +105,7 @@ describe("Wakatime fetcher", () => {
const username = "anuraghazra";
mock
.onGet(
`https://wakatime.com/api/v1/users/${username}/stats?is_including_today=true`,
`https://wakatime.com/api/v1/users/${username}/stats/all_time?is_including_today=true`,
)
.reply(200, wakaTimeData);
+2 -19
View File
@@ -16,7 +16,6 @@ import {
trimTopLanguages,
renderTopLanguages,
MIN_CARD_WIDTH,
getDefaultLanguagesCountByLayout,
} from "../src/cards/top-languages-card.js";
// adds special assertions like toHaveTextContent
@@ -329,15 +328,6 @@ describe("Test renderTopLanguages helper functions", () => {
totalLanguageSize: 300,
});
});
it("getDefaultLanguagesCountByLayout", () => {
expect(getDefaultLanguagesCountByLayout("normal")).toStrictEqual(5);
expect(getDefaultLanguagesCountByLayout(undefined)).toStrictEqual(5);
expect(getDefaultLanguagesCountByLayout("compact")).toStrictEqual(6);
expect(getDefaultLanguagesCountByLayout("donut")).toStrictEqual(5);
expect(getDefaultLanguagesCountByLayout("donut-vertical")).toStrictEqual(6);
expect(getDefaultLanguagesCountByLayout("pie")).toStrictEqual(6);
});
});
describe("Test renderTopLanguages", () => {
@@ -807,7 +797,7 @@ describe("Test renderTopLanguages", () => {
expect(document.querySelector("rect")).toHaveAttribute("rx", "4.5");
});
it("should render langs with specified langs_count", () => {
it("should render langs with specified langs_count", async () => {
const options = {
langs_count: 1,
};
@@ -817,7 +807,7 @@ describe("Test renderTopLanguages", () => {
);
});
it("should render langs with specified langs_count even when hide is set", () => {
it("should render langs with specified langs_count even when hide is set", async () => {
const options = {
hide: ["HTML"],
langs_count: 2,
@@ -827,11 +817,4 @@ describe("Test renderTopLanguages", () => {
options.langs_count,
);
});
it('should show "No languages data." message instead of empty card when nothing to show', () => {
document.body.innerHTML = renderTopLanguages({});
expect(document.querySelector(".stat").textContent).toBe(
"No languages data.",
);
});
});
+1 -1
View File
@@ -43,7 +43,7 @@ describe("Test Render Wakatime Card", () => {
expect(
document.querySelector('g[transform="translate(0, 0)"]>text.stat.bold')
.textContent,
).toBe("Wakatime 用户个人资料未公开");
).toBe("本周没有编程活动");
});
it("should render without rounding", () => {
+1 -22
View File
@@ -123,7 +123,7 @@ describe("Test /api/top-langs", () => {
);
});
it("should render error card on user data fetch error", async () => {
it("should render error card on error", async () => {
const req = {
query: {
username: "anuraghazra",
@@ -140,25 +140,4 @@ describe("Test /api/top-langs", () => {
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(renderError(error.errors[0].message));
});
it("should render error card on incorrect layout input", async () => {
const req = {
query: {
username: "anuraghazra",
layout: ["pie"],
},
};
const res = {
setHeader: jest.fn(),
send: jest.fn(),
};
mock.onPost("https://api.github.com/graphql").reply(200, data_langs);
await topLangs(req, res);
expect(res.setHeader).toBeCalledWith("Content-Type", "image/svg+xml");
expect(res.send).toBeCalledWith(
renderError("Something went wrong", "Incorrect layout input"),
);
});
});