diff --git a/.github/readme/imgs/plugin_wakatime_token.png b/.github/readme/imgs/plugin_wakatime_token.png new file mode 100644 index 00000000..2e113059 Binary files /dev/null and b/.github/readme/imgs/plugin_wakatime_token.png differ diff --git a/source/app/metrics/utils.mjs b/source/app/metrics/utils.mjs index 9a26ad13..0657d441 100644 --- a/source/app/metrics/utils.mjs +++ b/source/app/metrics/utils.mjs @@ -46,6 +46,15 @@ } format.percentage = percentage +/** Text ellipsis formatter */ + export function ellipsis(text, {length = 20} = {}) { + text = `${text}` + if (text.length < length) + return text + return `${text.substring(0, length)}…` + } + format.ellipsis = ellipsis + /** Array shuffler */ export function shuffle(array) { for (let i = array.length-1; i > 0; i--) { diff --git a/source/app/mocks/api/axios/get/wakatime.mjs b/source/app/mocks/api/axios/get/wakatime.mjs new file mode 100644 index 00000000..0baa0f0f --- /dev/null +++ b/source/app/mocks/api/axios/get/wakatime.mjs @@ -0,0 +1,44 @@ +/** Mocked data */ + export default function ({faker, url, options, login = faker.internet.userName()}) { + //Wakatime api + if (/^https:..wakatime.com.api.v1.users.current.stats.*$/.test(url)) { + //Get user profile + if (/api_key=MOCKED_TOKEN/.test(url)) { + console.debug(`metrics/compute/mocks > mocking wakatime api result > ${url}`) + const stats = (array) => { + const elements = [] + let result = new Array(4+faker.random.number(2)).fill(null).map(_ => ({ + get digital() { return `${this.hours}:${this.minutes}` }, + hours:faker.random.number(1000), minutes:faker.random.number(1000), + name:array ? faker.random.arrayElement(array) : faker.lorem.words(), + percent:faker.random.number(100), total_seconds:faker.random.number(1000000), + })) + return result.filter(({name}) => elements.includes(name) ? false : (elements.push(name), true)) + } + return ({ + status:200, + data:{ + data:{ + best_day:{ + created_at:faker.date.recent(), + date:`${faker.date.recent()}`.substring(0, 10), + total_seconds:faker.random.number(1000000), + }, + categories:stats(), + daily_average:faker.random.number(1000000000), + daily_average_including_other_language:faker.random.number(1000000000), + dependencies:stats(), + editors:stats(["VS Code", "Chrome", "IntelliJ", "PhpStorm", "WebStorm", "Android Studio", "Visual Studio", "Sublime Text", "PyCharm", "Vim", "Atom", "Xcode"]), + languages:stats(["JavaScript", "TypeScript", "PHP", "Java", "Python", "Vue.js", "HTML", "C#", "JSON", "Dart", "SCSS", "Kotlin", "JSX", "Go", "Ruby", "YAML"]), + machines:stats(), + operating_systems:stats(["Mac", "Windows", "Linux"]), + project:null, + projects:stats(), + total_seconds:faker.random.number(1000000000), + total_seconds_including_other_language:faker.random.number(1000000000), + }, + } + }) + } + } + } \ No newline at end of file diff --git a/source/app/web/statics/app.placeholder.js b/source/app/web/statics/app.placeholder.js index 62f0c246..5abf3ae4 100644 --- a/source/app/web/statics/app.placeholder.js +++ b/source/app/web/statics/app.placeholder.js @@ -415,6 +415,28 @@ return result } }) : null), + //Wakatime + ...(set.plugins.enabled.wakatime ? ({ + get wakatime() { + const stats = (array) => { + const elements = [] + let result = new Array(4+faker.random.number(2)).fill(null).map(_ => ({ + name:array ? faker.random.arrayElement(array) : faker.lorem.words(), + percent:faker.random.number(100)/100, total_seconds:faker.random.number(1000000), + })) + return result.filter(({name}) => elements.includes(name) ? false : (elements.push(name), true)).sort((a, b) => b.percent - a.percent) + } + return { + sections:options["wakatime.sections"].split(",").map(x => x.trim()).filter(x => x), + days:Number(options["wakatime.days"])||7, + time:{total:faker.random.number(100000), daily:faker.random.number(24)}, + editors:stats(["VS Code", "Chrome", "IntelliJ", "PhpStorm", "WebStorm", "Android Studio", "Visual Studio", "Sublime Text", "PyCharm", "Vim", "Atom", "Xcode"]), + languages:stats(["JavaScript", "TypeScript", "PHP", "Java", "Python", "Vue.js", "HTML", "C#", "JSON", "Dart", "SCSS", "Kotlin", "JSX", "Go", "Ruby", "YAML"]), + projects:stats(), + os:stats(["Mac", "Windows", "Linux"]), + } + } + }) : null), //Anilist ...(set.plugins.enabled.anilist ? ({ anilist:{ @@ -601,6 +623,12 @@ .replace(/(?<=[.])([1-9]*)(0+)$/, (m, a, b) => a) .replace(/[.]$/, "")}%` } + data.f.ellipsis = function (text, {length = 20} = {}) { + text = `${text}` + if (text.length < length) + return text + return `${text.substring(0, length)}…` + } //Render return await ejs.render(image, data, {async:true, rmWhitespace:true}) } diff --git a/source/plugins/wakatime/README.md b/source/plugins/wakatime/README.md new file mode 100644 index 00000000..be77308c --- /dev/null +++ b/source/plugins/wakatime/README.md @@ -0,0 +1,36 @@ +### ⏰ WakaTime plugin + +The *wakatime* plugin displays statistics from your [WakaTime](https://wakatime.com) account. + + + +
+ + +
+ +
+💬 Obtaining a WakaTime token + +Create a [WakaTime account](https://wakatime.com) and retrieve your API key in your [Account settings](https://wakatime.com/settings/account). + +![WakaTime API token](/.github/readme/imgs/plugin_wakatime_token.png) + +Then setup [WakaTime plugins](https://wakatime.com/plugins) to be ready to go! + +
+ +#### ℹ️ Examples workflows + +[➡️ Available options for this plugin](metadata.yml) + +```yaml +- uses: lowlighter/metrics@latest + with: + # ... other options + plugin_wakatime: yes # (🚧 @master feature) + plugin_wakatime_token: ${{ secrets.WAKATIME_TOKEN }} # Required + plugin_wakatime_days: 7 # Display last week stats + plugin_wakatime_sections: time, projects, projects-graphs # Display time and projects sections, along with projects graphs + plugin_wakatime_limit: 4 # Show 4 entries per graph +``` diff --git a/source/plugins/wakatime/index.mjs b/source/plugins/wakatime/index.mjs new file mode 100644 index 00000000..c29d0abc --- /dev/null +++ b/source/plugins/wakatime/index.mjs @@ -0,0 +1,45 @@ +//Setup + export default async function ({login, q, imports, data, account}, {enabled = false, token} = {}) { + //Plugin execution + try { + //Check if plugin is enabled and requirements are met + if ((!enabled)||(!q.wakatime)) + return null + + //Load inputs + let {sections, days, limit} = imports.metadata.plugins.wakatime.inputs({data, account, q}) + if (!limit) + limit = void(limit) + const range = {"7":"last_7_days", "30":"last_30_days", "180":"last_6_months", "365":"last_year"}[days] ?? "last_7_days" + + //Querying api and format result + //https://wakatime.com/developers#stats + console.debug(`metrics/compute/${login}/plugins > wakatime > querying api`) + const {data:{data:stats}} = await imports.axios.get(`https://wakatime.com/api/v1/users/current/stats/${range}?api_key=${token}`) + const result = { + sections, + days, + time:{ + total:stats.total_seconds/(60*60), + daily:stats.daily_average/(60*60), + }, + projects:stats.projects.map(({name, percent, total_seconds:total}) => ({name, percent:percent/100, total})).sort((a, b) => b.percent - a.percent).slice(0, limit), + languages:stats.languages.map(({name, percent, total_seconds:total}) => ({name, percent:percent/100, total})).sort((a, b) => b.percent - a.percent).slice(0, limit), + os:stats.operating_systems.map(({name, percent, total_seconds:total}) => ({name, percent:percent/100, total})).sort((a, b) => b.percent - a.percent).slice(0, limit), + editors:stats.editors.map(({name, percent, total_seconds:total}) => ({name, percent:percent/100, total})).sort((a, b) => b.percent - a.percent).slice(0, limit), + } + + //Result + return result + } + //Handle errors + catch (error) { + let message = "An error occured" + if (error.isAxiosError) { + const status = error.response?.status + message = `API returned ${status}` + error = error.response?.data ?? null + } + throw {error:{message, instance:error}} + } + } \ No newline at end of file diff --git a/source/plugins/wakatime/metadata.yml b/source/plugins/wakatime/metadata.yml new file mode 100644 index 00000000..c57af522 --- /dev/null +++ b/source/plugins/wakatime/metadata.yml @@ -0,0 +1,53 @@ +name: "⏰ WakaTime plugin" +cost: N/A +supports: + - user +inputs: + + # Enable or disable plugin + plugin_wakatime: + description: Display WakaTime stats + type: boolean + default: no + + # WakaTime API token + # See https://wakatime.com/settings/account get your API key + plugin_wakatime_token: + description: WakaTime API token + type: token + default: "" + + # Time range to use for displayed stats + plugin_wakatime_days: + description: WakaTime time range + type: string + values: + - 7 # Last week + - 30 # Last month + - 180 # Last 6 months + - 365 # Last year + default: 7 + + # Sections to display + plugin_wakatime_sections: + description: Sections to display + type: array + values: + - time # Show total coding time and daily average + - projects # Show most time spent project + - projects-graphs # Show most time spent projects graphs + - languages # Show most language + - languages-graphs # Show languages graphs + - editors # Show most used code editor + - editors-graphs # Show code editors graphs + - os # Show most used operating system + - os-graphs # Show code operating systems graphs + default: time, projects, projects-graphs, languages, languages-graphs, editors, os + + # Number of entries to display per graph + # Set to 0 to disable limitations + plugin_wakatime_limit: + description: Maximum number of entries to display per graph + type: number + default: 5 + min: 0 diff --git a/source/plugins/wakatime/tests.yml b/source/plugins/wakatime/tests.yml new file mode 100644 index 00000000..a8e29ff6 --- /dev/null +++ b/source/plugins/wakatime/tests.yml @@ -0,0 +1,15 @@ +- name: WakaTime plugin (default) + uses: lowlighter/metrics@latest + with: + token: NOT_NEEDED + plugin_wakatime_token: MOCKED_TOKEN + plugin_wakatime: yes + +- name: WakaTime plugin (complete) + uses: lowlighter/metrics@latest + with: + token: NOT_NEEDED + plugin_wakatime_token: MOCKED_TOKEN + plugin_wakatime: yes + plugin_wakatime_limit: 4 + plugin_wakatime_sections: time, projects, projects-graphs, languages, languages-graphs, editors, editors-graphs, os, os-graphs diff --git a/source/templates/classic/partials/_.json b/source/templates/classic/partials/_.json index cd6658ec..8a6bc5f9 100644 --- a/source/templates/classic/partials/_.json +++ b/source/templates/classic/partials/_.json @@ -17,5 +17,6 @@ "stargazers", "people", "activity", - "anilist" + "anilist", + "wakatime" ] \ No newline at end of file diff --git a/source/templates/classic/partials/wakatime.ejs b/source/templates/classic/partials/wakatime.ejs new file mode 100644 index 00000000..4fb9120a --- /dev/null +++ b/source/templates/classic/partials/wakatime.ejs @@ -0,0 +1,87 @@ +<% if (plugins.wakatime) { %> +
+

+ + WakaTime <%= plugins.wakatime?.days ? `(over last ${{7:"week", 30:"month", 180:"6 months", 365:"year"}[plugins.wakatime.days]})` : "" %> +

+ <% if (plugins.wakatime.error) { %> +
+
+
+ <%= plugins.wakatime.error.message %> +
+
+
+ <% } else { %> +
+
+ <% if (plugins.wakatime.sections.includes("time")) { %> +
+ + ~<%= f(Math.ceil(plugins.wakatime.time.total)) %> coding hour<%= s(plugins.wakatime.time.total) %> recorded +
+ <% } %> + <% if ((plugins.wakatime.sections.includes("projects"))&&(plugins.wakatime.projects?.length)) { %> +
+ + Working on <%= f.ellipsis(plugins.wakatime.projects[0]?.name, {length:16}) %> +
+ <% } %> + <% if ((plugins.wakatime.sections.includes("languages"))&&(plugins.wakatime.languages?.length)) { %> +
+ + Mostly coding in <%= plugins.wakatime.languages[0]?.name %> +
+ <% } %> +
+
+ <% if (plugins.wakatime.sections.includes("time")) { %> +
+ + ~<%= f(Math.ceil(plugins.wakatime.time.daily)) %> hour<%= s(plugins.wakatime.time.total) %> of coding per day +
+ <% } %> + <% if ((plugins.wakatime.sections.includes("editors"))&&(plugins.wakatime.editors?.length)) { %> +
+ + Coding with <%= plugins.wakatime.editors[0]?.name %> +
+ <% } %> + <% if ((plugins.wakatime.sections.includes("os"))&&(plugins.wakatime.os?.length)) { %> +
+ + Using <%= plugins.wakatime.os[0]?.name %> +
+ <% } %> +
+
+ + <% { const sections = plugins.wakatime.sections.filter(x => /-graphs$/.test(x)).map(x => x.replace(/-graphs$/, "")) %> + <% for (let i = 0; i < sections.length; i+=2) { %> +
+ <% for (let j = 0; j < 2; j++) { const key = sections[i+j] ; const section = plugins.wakatime[key] ; if (!key) continue %> +
+

<%= {languages:"Language activity", projects:"Projects activity", editors:"Code editors", os:"Operating systems"}[key] %>

+
+ <% if (section?.length) { %> + <% for (const {name, percent, total} of section) { %> +
+ <%= name %> +
+ <%= Math.round(100*percent) %>% +
+ <% } %> + <% } else { %> +
+
No activity
+
+ <% } %> +
+
+ <% } %> +
+ <% }} %> + + <% } %> +
+<% } %> \ No newline at end of file diff --git a/source/templates/classic/style.css b/source/templates/classic/style.css index 93d4cb8e..be74c297 100644 --- a/source/templates/classic/style.css +++ b/source/templates/classic/style.css @@ -361,6 +361,11 @@ font-size: 7px; } + .chart-bars .entry .empty { + width: 100%; + text-align: center; + } + .chart-bars .bar { width: 7px; background-color: var(--color-calendar-graph-day-bg); @@ -370,7 +375,6 @@ .chart-bars.horizontal { flex-direction: column; - align-items: space-between; height: 100%; } @@ -383,7 +387,10 @@ .chart-bars.horizontal .entry .name { flex-shrink: 0; text-align: right; - min-width: 30%; + width: 34%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .chart-bars .entry .bottom {