diff --git a/api/wakatime.js b/api/wakatime.js new file mode 100644 index 0000000000000..c68bb8f55daa1 --- /dev/null +++ b/api/wakatime.js @@ -0,0 +1,59 @@ +require("dotenv").config(); +const { + renderError, + parseBoolean, + clampValue, + CONSTANTS, +} = require("../src/common/utils"); +const { fetchLast7Days } = require("../src/fetchers/wakatime-fetcher"); +const wakatimeCard = require("../src/cards/wakatime-card"); + +module.exports = async (req, res) => { + const { + username, + title_color, + icon_color, + hide_border, + line_height, + text_color, + bg_color, + theme, + cache_seconds, + hide_title, + hide_progress, + } = req.query; + + res.setHeader("Content-Type", "image/svg+xml"); + + try { + const last7Days = await fetchLast7Days({ username }); + + let cacheSeconds = clampValue( + parseInt(cache_seconds || CONSTANTS.TWO_HOURS, 10), + CONSTANTS.TWO_HOURS, + CONSTANTS.ONE_DAY + ); + + if (!cache_seconds) { + cacheSeconds = CONSTANTS.FOUR_HOURS; + } + + res.setHeader("Cache-Control", `public, max-age=${cacheSeconds}`); + + return res.send( + wakatimeCard(last7Days, { + hide_title: parseBoolean(hide_title), + hide_border: parseBoolean(hide_border), + line_height, + title_color, + icon_color, + text_color, + bg_color, + theme, + hide_progress, + }) + ); + } catch (err) { + return res.send(renderError(err.message, err.secondaryMessage)); + } +}; diff --git a/package.json b/package.json index af82b59522d82..0cb50dc167e6e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dotenv": "^8.2.0", "emoji-name-map": "^1.2.8", "github-username-regex": "^1.0.0", + "prettier": "^2.1.2", "word-wrap": "^1.2.3" }, "husky": { diff --git a/readme.md b/readme.md index 2765b4d25f1ef..7ef0bef27156c 100644 --- a/readme.md +++ b/readme.md @@ -58,6 +58,7 @@ - [GitHub Stats Card](#github-stats-card) - [GitHub Extra Pins](#github-extra-pins) - [Top Languages Card](#top-languages-card) +- [Wakatime Week Stats](#wakatime-week-stats) - [Themes](#themes) - [Customization](#customization) - [Deploy Yourself](#deploy-on-your-own-vercel-instance) @@ -171,6 +172,13 @@ You can provide multiple comma-separated values in bg_color option to render a g > Language names should be uri-escaped, as specified in [Percent Encoding](https://en.wikipedia.org/wiki/Percent-encoding) > (i.e: `c++` should become `c%2B%2B`, `jupyter notebook` should become `jupyter%20notebook`, etc.) +#### Wakatime Card Exclusive Options: + +- `hide_title` - _(boolean)_ +- `hide_border` - _(boolean)_ +- `line_height` - Sets the line-height between text _(number)_ +- `hide_progress` - Hides the progress bar and percentage _(boolean)_ + --- # GitHub Extra Pins @@ -245,6 +253,20 @@ You can use the `&layout=compact` option to change the card design. [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra&layout=compact)](https://github.com/anuraghazra/github-readme-stats) +# Wakatime Week Stats + +Change the `?username=` value to your Wakatime username. + +```md +[![willianrod's wakatime stats](https://github-readme-stats.vercel.app/api/wakatime?username=willianrod)](https://github.com/anuraghazra/github-readme-stats) +``` + +### Demo + +[![willianrod's wakatime stats](https://github-readme-stats.vercel.app/api/wakatime?username=willianrod)](https://github.com/anuraghazra/github-readme-stats) + +[![willianrod's wakatime stats](https://github-readme-stats.vercel.app/api/wakatime?username=willianrod&hide_progress=true)](https://github.com/anuraghazra/github-readme-stats) + --- ### All Demos @@ -287,6 +309,10 @@ Choose from any of the [default themes](#themes) [![Top Langs](https://github-readme-stats.vercel.app/api/top-langs/?username=anuraghazra)](https://github.com/anuraghazra/github-readme-stats) +- Wakatime card + +[![willianrod's wakatime stats](https://github-readme-stats.vercel.app/api/wakatime?username=willianrod)](https://github.com/anuraghazra/github-readme-stats) + --- ### Quick Tip (Align The Repo Cards) diff --git a/src/cards/wakatime-card.js b/src/cards/wakatime-card.js new file mode 100644 index 0000000000000..7b0aca568fdc9 --- /dev/null +++ b/src/cards/wakatime-card.js @@ -0,0 +1,156 @@ +const { getCardColors, FlexLayout, clampValue } = require("../common/utils"); +const { getStyles } = require("../getStyles"); +const icons = require("../common/icons"); +const Card = require("../common/Card"); + +const noCodingActivityNode = ({ color }) => { + return ` + No coding activity this week + `; +}; + +const createProgressNode = ({ + width, + color, + progress, + progressBarBackgroundColor, +}) => { + const progressPercentage = clampValue(progress, 2, 100); + + return ` + + + + + + `; +}; + +const createTextNode = ({ + id, + label, + value, + index, + percent, + hideProgress, + progressBarColor, + progressBarBackgroundColor, +}) => { + const staggerDelay = (index + 3) * 150; + + const cardProgress = hideProgress + ? null + : createProgressNode({ + progress: percent, + color: progressBarColor, + width: 220, + name: label, + progressBarBackgroundColor, + }); + + return ` + + ${label}: + ${value} + ${cardProgress} + + `; +}; + +const renderWakatimeCard = (stats = {}, options = { hide: [] }) => { + const { languages } = stats; + const { + hide_title = false, + hide_border = false, + line_height = 25, + title_color, + icon_color, + text_color, + bg_color, + theme = "default", + hide_progress, + } = options; + + const lheight = parseInt(line_height, 10); + + // returns theme based colors with proper overrides and defaults + const { titleColor, textColor, iconColor, bgColor } = getCardColors({ + title_color, + icon_color, + text_color, + bg_color, + theme, + }); + + const statItems = languages + ? languages + .filter((language) => language.hours || language.minutes) + .map((language) => { + return createTextNode({ + id: language.name, + label: language.name, + value: language.text, + percent: language.percent, + progressBarColor: titleColor, + progressBarBackgroundColor: textColor, + hideProgress: hide_progress, + }); + }) + : []; + + // 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, 150); + + const cssStyles = getStyles({ + titleColor, + textColor, + iconColor, + }); + + const card = new Card({ + title: "Wakatime week stats", + width: 495, + height, + colors: { + titleColor, + textColor, + iconColor, + bgColor, + }, + }); + + card.setHideBorder(hide_border); + card.setHideTitle(hide_title); + card.setCSS( + ` + ${cssStyles} + .lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } + ` + ); + + return card.render(` + + ${FlexLayout({ + items: statItems.length + ? statItems + : [noCodingActivityNode({ color: textColor })], + gap: lheight, + direction: "column", + }).join("")} + + `); +}; + +module.exports = renderWakatimeCard; diff --git a/src/fetchers/wakatime-fetcher.js b/src/fetchers/wakatime-fetcher.js new file mode 100644 index 0000000000000..98dad3a0b169b --- /dev/null +++ b/src/fetchers/wakatime-fetcher.js @@ -0,0 +1,13 @@ +const axios = require("axios"); + +const fetchLast7Days = async ({ username }) => { + const { data } = await axios.get( + `https://wakatime.com/api/v1/users/${username}/stats/last_7_days?is_including_today=true` + ); + + return data.data; +}; + +module.exports = { + fetchLast7Days, +}; diff --git a/tests/fetchWakatime.test.js b/tests/fetchWakatime.test.js new file mode 100644 index 0000000000000..29aaa010b32d4 --- /dev/null +++ b/tests/fetchWakatime.test.js @@ -0,0 +1,215 @@ +require("@testing-library/jest-dom"); +const axios = require("axios"); +const MockAdapter = require("axios-mock-adapter"); +const { fetchLast7Days } = require("../src/fetchers/wakatime-fetcher"); +const mock = new MockAdapter(axios); + +afterEach(() => { + mock.reset(); +}); + +const wakaTimeData = { + data: { + categories: [ + { + digital: "22:40", + hours: 22, + minutes: 40, + name: "Coding", + percent: 100, + text: "22 hrs 40 mins", + total_seconds: 81643.570077, + }, + ], + daily_average: 16095, + daily_average_including_other_language: 16329, + days_including_holidays: 7, + days_minus_holidays: 5, + editors: [ + { + digital: "22:40", + hours: 22, + minutes: 40, + name: "VS Code", + percent: 100, + text: "22 hrs 40 mins", + total_seconds: 81643.570077, + }, + ], + holidays: 2, + human_readable_daily_average: "4 hrs 28 mins", + human_readable_daily_average_including_other_language: "4 hrs 32 mins", + human_readable_total: "22 hrs 21 mins", + human_readable_total_including_other_language: "22 hrs 40 mins", + id: "random hash", + is_already_updating: false, + is_coding_activity_visible: true, + is_including_today: false, + is_other_usage_visible: true, + is_stuck: false, + is_up_to_date: true, + languages: [ + { + digital: "0:19", + hours: 0, + minutes: 19, + name: "Other", + percent: 1.43, + text: "19 mins", + total_seconds: 1170.434361, + }, + { + digital: "0:01", + hours: 0, + minutes: 1, + name: "TypeScript", + percent: 0.1, + text: "1 min", + total_seconds: 83.293809, + }, + { + digital: "0:00", + hours: 0, + minutes: 0, + name: "YAML", + percent: 0.07, + text: "0 secs", + total_seconds: 54.975151, + }, + ], + operating_systems: [ + { + digital: "22:40", + hours: 22, + minutes: 40, + name: "Mac", + percent: 100, + text: "22 hrs 40 mins", + total_seconds: 81643.570077, + }, + ], + percent_calculated: 100, + range: "last_7_days", + status: "ok", + timeout: 15, + total_seconds: 80473.135716, + total_seconds_including_other_language: 81643.570077, + user_id: "random hash", + username: "anuraghazra", + writes_only: false, + }, +}; + +describe("Wakatime fetcher", () => { + it("should fetch correct wakatime data", async () => { + const username = "anuraghazra"; + mock + .onGet( + `https://wakatime.com/api/v1/users/${username}/stats/last_7_days?is_including_today=true` + ) + .reply(200, wakaTimeData); + + const repo = await fetchLast7Days({ username }); + expect(repo).toMatchInlineSnapshot(` + Object { + "categories": Array [ + Object { + "digital": "22:40", + "hours": 22, + "minutes": 40, + "name": "Coding", + "percent": 100, + "text": "22 hrs 40 mins", + "total_seconds": 81643.570077, + }, + ], + "daily_average": 16095, + "daily_average_including_other_language": 16329, + "days_including_holidays": 7, + "days_minus_holidays": 5, + "editors": Array [ + Object { + "digital": "22:40", + "hours": 22, + "minutes": 40, + "name": "VS Code", + "percent": 100, + "text": "22 hrs 40 mins", + "total_seconds": 81643.570077, + }, + ], + "holidays": 2, + "human_readable_daily_average": "4 hrs 28 mins", + "human_readable_daily_average_including_other_language": "4 hrs 32 mins", + "human_readable_total": "22 hrs 21 mins", + "human_readable_total_including_other_language": "22 hrs 40 mins", + "id": "random hash", + "is_already_updating": false, + "is_coding_activity_visible": true, + "is_including_today": false, + "is_other_usage_visible": true, + "is_stuck": false, + "is_up_to_date": true, + "languages": Array [ + Object { + "digital": "0:19", + "hours": 0, + "minutes": 19, + "name": "Other", + "percent": 1.43, + "text": "19 mins", + "total_seconds": 1170.434361, + }, + Object { + "digital": "0:01", + "hours": 0, + "minutes": 1, + "name": "TypeScript", + "percent": 0.1, + "text": "1 min", + "total_seconds": 83.293809, + }, + Object { + "digital": "0:00", + "hours": 0, + "minutes": 0, + "name": "YAML", + "percent": 0.07, + "text": "0 secs", + "total_seconds": 54.975151, + }, + ], + "operating_systems": Array [ + Object { + "digital": "22:40", + "hours": 22, + "minutes": 40, + "name": "Mac", + "percent": 100, + "text": "22 hrs 40 mins", + "total_seconds": 81643.570077, + }, + ], + "percent_calculated": 100, + "range": "last_7_days", + "status": "ok", + "timeout": 15, + "total_seconds": 80473.135716, + "total_seconds_including_other_language": 81643.570077, + "user_id": "random hash", + "username": "anuraghazra", + "writes_only": false, + } + `); + }); + + it("should throw error", async () => { + mock.onGet(/\/https:\/\/wakatime\.com\/api/).reply(404, wakaTimeData); + + await expect(fetchLast7Days("noone")).rejects.toThrow( + "Request failed with status code 404" + ); + }); +}); + +module.exports = { wakaTimeData }; diff --git a/tests/renderWakatimeCard.test.js b/tests/renderWakatimeCard.test.js new file mode 100644 index 0000000000000..6daa30a1cc126 --- /dev/null +++ b/tests/renderWakatimeCard.test.js @@ -0,0 +1,116 @@ +require("@testing-library/jest-dom"); +const renderWakatimeCard = require("../src/cards/wakatime-card"); + +const { wakaTimeData } = require("./fetchWakatime.test"); + +describe("Test Render Wakatime Card", () => { + it("should render correctly", () => { + const card = renderWakatimeCard(wakaTimeData); + + expect(card).toMatchInlineSnapshot(` + " + + + + undefined + + + + + + + Wakatime week stats + + + + + + + + + No coding activity this week + + + + + + " + `); + }); +});