From 7c43e871cdca4f2b9522396a1124b258c9e78e1a Mon Sep 17 00:00:00 2001
From: Simon Lecoq <22963968+lowlighter@users.noreply.github.com>
Date: Fri, 12 Mar 2021 14:33:07 +0100
Subject: [PATCH] Add achievements plugin (#182)
---
.../github/graphql/achievements.default.mjs | 66 ++++
.../github/graphql/achievements.metrics.mjs | 8 +
.../github/graphql/achievements.octocat.mjs | 8 +
.../github/graphql/achievements.ranking.mjs | 10 +
source/app/web/statics/app.placeholder.js | 19 ++
source/plugins/achievements/README.md | 29 ++
source/plugins/achievements/index.mjs | 290 ++++++++++++++++++
source/plugins/achievements/metadata.yml | 55 ++++
.../achievements/queries/achievements.graphql | 95 ++++++
.../achievements/queries/metrics.graphql | 8 +
.../achievements/queries/octocat.graphql | 8 +
.../achievements/queries/ranking.graphql | 14 +
source/plugins/achievements/tests.yml | 13 +
source/templates/classic/partials/_.json | 3 +-
.../classic/partials/achievements.ejs | 45 +++
source/templates/classic/style.css | 58 ++++
16 files changed, 728 insertions(+), 1 deletion(-)
create mode 100644 source/app/mocks/api/github/graphql/achievements.default.mjs
create mode 100644 source/app/mocks/api/github/graphql/achievements.metrics.mjs
create mode 100644 source/app/mocks/api/github/graphql/achievements.octocat.mjs
create mode 100644 source/app/mocks/api/github/graphql/achievements.ranking.mjs
create mode 100644 source/plugins/achievements/README.md
create mode 100644 source/plugins/achievements/index.mjs
create mode 100644 source/plugins/achievements/metadata.yml
create mode 100644 source/plugins/achievements/queries/achievements.graphql
create mode 100644 source/plugins/achievements/queries/metrics.graphql
create mode 100644 source/plugins/achievements/queries/octocat.graphql
create mode 100644 source/plugins/achievements/queries/ranking.graphql
create mode 100644 source/plugins/achievements/tests.yml
create mode 100644 source/templates/classic/partials/achievements.ejs
diff --git a/source/app/mocks/api/github/graphql/achievements.default.mjs b/source/app/mocks/api/github/graphql/achievements.default.mjs
new file mode 100644
index 00000000..febf670a
--- /dev/null
+++ b/source/app/mocks/api/github/graphql/achievements.default.mjs
@@ -0,0 +1,66 @@
+/**Mocked data */
+ export default function({faker, query, login = faker.internet.userName()}) {
+ console.debug("metrics/compute/mocks > mocking graphql api result > achievements/metrics")
+ return ({
+ user:{
+ repositories:{
+ nodes:[
+ {
+ createdAt:faker.date.recent(),
+ nameWithOwner:`${faker.internet.userName()}/${faker.lorem.slug()}`,
+ },
+ ],
+ totalCount:faker.random.number(100),
+ },
+ forks:{
+ nodes:[
+ {
+ createdAt:faker.date.recent(),
+ nameWithOwner:`${faker.internet.userName()}/${faker.lorem.slug()}`,
+ },
+ ],
+ totalCount:faker.random.number(100),
+ },
+ popular:{
+ nodes:[{stargazers:{totalCount:faker.random.number(50000)}}],
+ },
+ pullRequests:{
+ nodes:[
+ {
+ createdAt:faker.date.recent(),
+ title:faker.lorem.sentence(),
+ repository:{nameWithOwner:`${faker.internet.userName()}/${faker.lorem.slug()}`},
+ },
+ ],
+ totalCount:faker.random.number(50000),
+ },
+ contributionsCollection:{
+ pullRequestReviewContributions:{
+ nodes:[
+ {
+ occurredAt:faker.date.recent(),
+ pullRequest:{
+ title:faker.lorem.sentence(),
+ number:faker.random.number(1000),
+ repository:{nameWithOwner:`${faker.internet.userName()}/${faker.lorem.slug()}`},
+ },
+ },
+ ],
+ totalCount:faker.random.number(1000),
+ },
+ },
+ projects:{totalCount:faker.random.number(100)},
+ packages:{totalCount:faker.random.number(100)},
+ organizations:{nodes:[], totalCount:faker.random.number(5)},
+ gists:{
+ nodes:[{createdAt:faker.date.recent(), name:faker.lorem.slug()}],
+ totalCount:faker.random.number(1000),
+ },
+ starredRepositories:{totalCount:faker.random.number(1000)},
+ followers:{totalCount:faker.random.number(10000)},
+ following:{totalCount:faker.random.number(10000)},
+ bio:faker.lorem.sentence(),
+ status:{message:faker.lorem.paragraph()},
+ },
+ })
+ }
diff --git a/source/app/mocks/api/github/graphql/achievements.metrics.mjs b/source/app/mocks/api/github/graphql/achievements.metrics.mjs
new file mode 100644
index 00000000..92f1b640
--- /dev/null
+++ b/source/app/mocks/api/github/graphql/achievements.metrics.mjs
@@ -0,0 +1,8 @@
+/**Mocked data */
+ export default function({faker, query, login = faker.internet.userName()}) {
+ console.debug("metrics/compute/mocks > mocking graphql api result > achievements/metrics")
+ return ({
+ repository:{viewerHasStarred:faker.random.boolean()},
+ viewer:{login},
+ })
+ }
diff --git a/source/app/mocks/api/github/graphql/achievements.octocat.mjs b/source/app/mocks/api/github/graphql/achievements.octocat.mjs
new file mode 100644
index 00000000..1fe8cc42
--- /dev/null
+++ b/source/app/mocks/api/github/graphql/achievements.octocat.mjs
@@ -0,0 +1,8 @@
+/**Mocked data */
+ export default function({faker, query, login = faker.internet.userName()}) {
+ console.debug("metrics/compute/mocks > mocking graphql api result > achievements/octocat")
+ return ({
+ user:{viewerIsFollowing:faker.random.boolean()},
+ viewer:{login},
+ })
+ }
diff --git a/source/app/mocks/api/github/graphql/achievements.ranking.mjs b/source/app/mocks/api/github/graphql/achievements.ranking.mjs
new file mode 100644
index 00000000..66d402b7
--- /dev/null
+++ b/source/app/mocks/api/github/graphql/achievements.ranking.mjs
@@ -0,0 +1,10 @@
+/**Mocked data */
+ export default function({faker, query, login = faker.internet.userName()}) {
+ console.debug("metrics/compute/mocks > mocking graphql api result > achievements/ranking")
+ return ({
+ repo_rank:{repositoryCount:faker.random.number(100000)},
+ user_rank:{userCount:faker.random.number(100000)},
+ repo_total:{repositoryCount:faker.random.number(100000)},
+ user_total:{userCount:faker.random.number(100000)},
+ })
+ }
diff --git a/source/app/web/statics/app.placeholder.js b/source/app/web/statics/app.placeholder.js
index e3aad293..1e00621a 100644
--- a/source/app/web/statics/app.placeholder.js
+++ b/source/app/web/statics/app.placeholder.js
@@ -193,6 +193,25 @@
days:options["reactions.days"]
}
}) : null),
+ //Achievements
+ ...(set.plugins.enabled.achievements ? ({
+ achievements:{
+ list:new Array(8).fill(null).map(_ => ({
+ title:faker.lorem.word(),
+ unlock:null,
+ text:faker.lorem.sentence(),
+ icon:``,
+ rank:faker.random.arrayElement(["A", "B", "C", "X", "$"]),
+ progress:faker.random.number(100)/100,
+ value:faker.random.number(1000),
+ }))
+ .filter(({rank}) => options["achievements.secrets"] ? true : rank !== "$")
+ .filter(({rank}) => ({S:5, A:4, B:3, C:2, $:1, X:0}[rank] >= {S:5, A:4, B:3, C:2, $:1, X:0}[options["achievements.threshold"]]))
+ .sort((a, b) => ({S:5, A:4, B:3, C:2, $:1, X:0}[b.rank]+b.progress*0.99) - ({S:5, A:4, B:3, C:2, $:1, X:0}[a.rank]+a.progress*0.99))
+ .slice(0, options["achievements.limit"] || Infinity)
+ ,
+ }
+ }) : null),
//Introduction
...(set.plugins.enabled.introduction ? ({
introduction:{
diff --git a/source/plugins/achievements/README.md b/source/plugins/achievements/README.md
new file mode 100644
index 00000000..258ce21c
--- /dev/null
+++ b/source/plugins/achievements/README.md
@@ -0,0 +1,29 @@
+### 🏆 Achievements
+
+The *achievements* plugin displays several highlights about what you achieved on GitHub.
+
+
+
+
+
+ |
+
+
+Achievements are mostly related to features offered by GitHub, so by unlocking achivements ranks you'll be mastering GitHub in no time!
+
+A few achievements contains actual real ranking (based on [GitHub search](github.com/search) results)!
+
+#### ℹ️ Examples workflows
+
+[➡️ Available options for this plugin](metadata.yml)
+
+```yaml
+- uses: lowlighter/metrics@latest
+ with:
+ # ... other options
+ plugin_achievements: yes
+ plugin_achievements_threshold: B # Display achievements with rank B or higher
+ plugin_achievements_secrets: yes # Display unlocked secrets achievements
+ plugin_achievements_ignored: octonaut # Hide octonaut achievement
+ plugin_achievements_limit: 0 # Display all unlocked achievement matching threshold and secrets params
+```
\ No newline at end of file
diff --git a/source/plugins/achievements/index.mjs b/source/plugins/achievements/index.mjs
new file mode 100644
index 00000000..b2574f11
--- /dev/null
+++ b/source/plugins/achievements/index.mjs
@@ -0,0 +1,290 @@
+//Setup
+ export default async function({login, q, imports, data, computed, graphql, queries, account}, {enabled = false} = {}) {
+ //Plugin execution
+ try {
+ //Check if plugin is enabled and requirements are met
+ if ((!enabled)||(!q.achievements))
+ return null
+
+ //Load inputs
+ let {threshold, secrets, only, ignored, limit} = imports.metadata.plugins.achievements.inputs({data, q, account})
+
+ //Initinalization
+ const list = []
+ const {user} = await graphql(queries.achievements({login}))
+ const ranks = await graphql(queries.achievements.ranking({followers:user.followers.totalCount, stars:user.popular.nodes?.[0]?.stargazers?.totalCount ?? 0}))
+
+ //Developer
+ {
+ const value = user.repositories.totalCount
+ const unlock = user.repositories.nodes?.shift()
+ list.push({
+ title:"Developer",
+ text:`Published ${value} public repositor${imports.s(value, "y")}`,
+ icon:"",
+ ...rank(value, [1, 20, 50, 100]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Forker
+ {
+ const value = user.forks.totalCount
+ const unlock = user.forks.nodes?.shift()
+ list.push({
+ title:"Forker",
+ text:`Forked ${value} public repositor${imports.s(value, "y")}`,
+ icon:"",
+ ...rank(value, [1, 5, 10, 20]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Contributor
+ {
+ const value = user.pullRequests.totalCount
+ const unlock = user.pullRequests.nodes?.shift()
+
+ list.push({
+ title:"Contributor",
+ text:`Opened ${value} pull request${imports.s(value)}`,
+ icon:"",
+ ...rank(value, [1, 200, 500, 1000]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Manager
+ {
+ const value = user.projects.totalCount
+ const unlock = user.projects.nodes?.shift()
+
+ list.push({
+ title:"Manager",
+ text:`Created ${value} user project${imports.s(value)}`,
+ icon:"",
+ ...rank(value, [1, 2, 3, 4]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Reviewer
+ {
+ const value = user.contributionsCollection.pullRequestReviewContributions.totalCount
+ const unlock = user.contributionsCollection.pullRequestReviewContributions.nodes?.shift()
+
+ list.push({
+ title:"Reviewer",
+ text:`Reviewed ${value} pull request${imports.s(value)}`,
+ icon:"",
+ ...rank(value, [1, 200, 500, 1000]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Packager
+ {
+ const value = user.packages.totalCount
+ const unlock = user.packages.nodes?.shift()
+
+ list.push({
+ title:"Packager",
+ text:`Created ${value} package${imports.s(value)}`,
+ icon:"",
+ ...rank(value, [1, 5, 10, 20]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Scripter
+ {
+ const value = user.gists.totalCount
+ const unlock = user.gists.nodes?.shift()
+
+ list.push({
+ title:"Scripter",
+ text:`Published ${value} gist${imports.s(value)}`,
+ icon:"",
+ ...rank(value, [1, 20, 50, 100]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Worker
+ {
+ const value = user.organizations.totalCount
+ const unlock = user.organizations.nodes?.shift()
+
+ list.push({
+ title:"Worker",
+ text:`Joined ${value} organization${imports.s(value)}`,
+ icon:"",
+ ...rank(value, [1, 2, 3, 4]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Stargazer
+ {
+ const value = user.starredRepositories.totalCount
+ const unlock = user.starredRepositories.nodes?.shift()
+
+ list.push({
+ title:"Stargazer",
+ text:`Starred ${value} repositor${imports.s(value, "y")}`,
+ icon:"",
+ ...rank(value, [1, 200, 500, 1000]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Follower
+ {
+ const value = user.following.totalCount
+ const unlock = user.following.nodes?.shift()
+
+ list.push({
+ title:"Follower",
+ text:`Following ${value} user${imports.s(value)}`,
+ icon:"",
+ ...rank(value, [1, 200, 500, 1000]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Influencer
+ {
+ const value = user.followers.totalCount
+ const unlock = user.followers.nodes?.shift()
+
+ list.push({
+ title:"Influencer",
+ text:`Followed by ${value} user${imports.s(value)}`,
+ icon:"",
+ ...rank(value, [1, 200, 500, 1000]), value, unlock:new Date(unlock?.createdAt),
+ gh:Number(`1${"0".repeat(Math.ceil(Math.log10(ranks.user_rank.userCount)))}`),
+ })
+ }
+
+ //Maintainer
+ {
+ const value = user.popular.nodes?.shift()?.stargazers?.totalCount ?? 0
+ const unlock = null
+
+ list.push({
+ title:"Maintainer",
+ text:`Maintaining a repository with ${value} star${imports.s(value)}`,
+ icon:"",
+ ...rank(value, [1, 1000, 5000, 10000]), value, unlock:new Date(unlock?.createdAt),
+ gh:Number(`1${"0".repeat(Math.ceil(Math.log10(ranks.repo_rank.repositoryCount)))}`),
+ })
+ }
+
+ //Polyglot
+ {
+ const value = new Set(data.user.repositories.nodes.flatMap(repository => repository.languages.edges.map(({node:{name}}) => name))).size
+ const unlock = null
+
+ list.push({
+ title:"Polyglot",
+ text:`Using ${value} different programming language${imports.s(value)}`,
+ icon:"",
+ ...rank(value, [1, 4, 8, 16]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Member
+ {
+ const value = computed.registered.diff
+ const unlock = null
+
+ list.push({
+ title:"Member",
+ text:`Registered ${Math.floor(value)} year${imports.s(Math.floor(value))} ago`,
+ icon:"",
+ ...rank(value, [1, 3, 5, 10]), value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Verified
+ {
+ const value = !/This user hasn't uploaded any GPG keys/i.test((await imports.axios.get(`https://github.com/${login}.gpg`)).data)
+ const unlock = null
+
+ list.push({
+ title:"Verified",
+ text:"Registered a GPG key to sign commits",
+ icon:"",
+ rank:value ? "$" : "X", progress:1, value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Explorer
+ {
+ const value = !/doesn’t have any starred topics yet/i.test((await imports.axios.get(`https://github.com/stars/${login}/topics`)).data)
+ const unlock = null
+
+ list.push({
+ title:"Explorer",
+ text:"Starred a topic on GitHub Explore",
+ icon:"",
+ rank:value ? "$" : "X", progress:1, value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Automater
+ {
+ const value = process.env.GITHUB_ACTIONS
+ const unlock = null
+
+ list.push({
+ title:"Automater",
+ text:"Use GitHub Actions to automate profile updates",
+ icon:"",
+ rank:value ? "$" : "X", progress:1, value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Infographile
+ {
+ const {repository:{viewerHasStarred:value}, viewer:{login:_login}} = await graphql(queries.achievements.metrics())
+ const unlock = null
+
+ list.push({
+ title:"Infographile",
+ text:"Fervent supporter of metrics",
+ icon:"",
+ rank:(value)&&(login === _login) ? "$" : "X", progress:1, value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Octonaut
+ {
+ const {user:{viewerIsFollowing:value}, viewer:{login:_login}} = await graphql(queries.achievements.octocat())
+ const unlock = null
+
+ list.push({
+ title:"Octonaut",
+ text:"Following octocat",
+ icon:"",
+ rank:(value)&&(login === _login) ? "$" : "X", progress:1, value, unlock:new Date(unlock?.createdAt),
+ })
+ }
+
+ //Results
+ const order = {S:5, A:4, B:3, C:2, $:1, X:0}
+ const achievements = list
+ .filter(a => (order[a.rank] >= order[threshold])||((a.rank === "$")&&(secrets)))
+ .filter(a => (!only.length)||((only.length)&&(only.includes(a.title.toLocaleLowerCase()))))
+ .filter(a => !ignored.includes(a.title.toLocaleLowerCase()))
+ .sort((a, b) => (order[b.rank]+b.progress*0.99) - (order[a.rank]+a.progress*0.99))
+ .map(({title, unlock, ...achievement}) => ({title:({S:`Master ${title.toLocaleLowerCase()}`, A:`Super ${title.toLocaleLowerCase()}`, B:`Great ${title.toLocaleLowerCase()}`}[achievement.rank] ?? title), unlock:!/invalid date/i.test(unlock) ? `${imports.date(unlock, {timeStyle:"short", timeZone:data.config.timezone?.name})} on ${imports.date(unlock, {dateStyle:"short", timeZone:data.config.timezone?.name})}` : null, ...achievement}))
+ .slice(0, limit || Infinity)
+ return {list:achievements}
+ }
+ //Handle errors
+ catch (error) {
+ throw {error:{message:"An error occured", instance:error}}
+ }
+ }
+
+/**Rank */
+ function rank(x, [c, b, a, m]) {
+ if (x >= a)
+ return {rank:"A", progress:(x-a)/(m-a)}
+ else if (x >= b)
+ return {rank:"B", progress:(x-b)/(a-b)}
+ else if (x >= c)
+ return {rank:"C", progress:(x-c)/(b-c)}
+ return {rank:"X", progress:x/c}
+ }
\ No newline at end of file
diff --git a/source/plugins/achievements/metadata.yml b/source/plugins/achievements/metadata.yml
new file mode 100644
index 00000000..0d01db13
--- /dev/null
+++ b/source/plugins/achievements/metadata.yml
@@ -0,0 +1,55 @@
+name: "🏆 Achievements"
+cost: ~5 GraphQL request
+categorie: github
+index: 17
+supports:
+ - user
+inputs:
+
+ # Enable or disable plugin
+ plugin_achievements:
+ description: Display achievements
+ type: boolean
+ default: no
+
+ # Minimal rank to display
+ plugin_achievements_threshold:
+ description: Display rank minimal threshold
+ type: string
+ default: C
+ values:
+ - A
+ - B
+ - C
+ - X # Not unlocked
+
+ # Display secrets achievements unlocked
+ plugin_achievements_secrets:
+ description: Display unlocked secrets achievements
+ type: boolean
+ default: yes
+
+ # Number of achievements events to display
+ # Set to 0 to disable limitations
+ plugin_achievements_limit:
+ description: Maximum number of achievements to display
+ type: number
+ default: 0
+ min: 0
+
+ # List of unlocked achievements to hide
+ # Names must be given in lower case, without rank adjective
+ plugin_achievements_ignored:
+ description: Unlocked achievements to hide
+ type: array
+ format: comma-separated
+ default: ""
+
+ # List of unlocked achievements to display
+ # Names must be given in lower case, without rank adjective
+ # Using this option is equivalent of using "plugin_achievements_ignored" with all existing achievements but the ones listed
+ plugin_achievements_only:
+ description: Unlocked achievements to display
+ type: array
+ format: comma-separated
+ default: ""
\ No newline at end of file
diff --git a/source/plugins/achievements/queries/achievements.graphql b/source/plugins/achievements/queries/achievements.graphql
new file mode 100644
index 00000000..7d00e8d6
--- /dev/null
+++ b/source/plugins/achievements/queries/achievements.graphql
@@ -0,0 +1,95 @@
+query AchievementsDefault {
+
+ user(login: "$login") {
+ repositories(first: 1, privacy: PUBLIC, affiliations: OWNER, orderBy: {field: CREATED_AT, direction: ASC}) {
+ nodes {
+ createdAt
+ nameWithOwner
+ }
+ totalCount
+ }
+ forks:repositories(first: 1, privacy: PUBLIC, isFork: true, orderBy: {field: CREATED_AT, direction: ASC}) {
+ nodes {
+ createdAt
+ nameWithOwner
+ }
+ totalCount
+ }
+ popular:repositories(first:1, orderBy: {field: STARGAZERS, direction: DESC}) {
+ nodes {
+ stargazers {
+ totalCount
+ }
+ }
+ }
+ pullRequests(orderBy: {field: CREATED_AT, direction: ASC}, first: 1) {
+ nodes {
+ createdAt
+ title
+ repository {
+ nameWithOwner
+ }
+ }
+ totalCount
+ }
+ contributionsCollection {
+ pullRequestReviewContributions(first: 1, orderBy: {direction: ASC}) {
+ nodes {
+ occurredAt
+ pullRequest {
+ title
+ number
+ repository {
+ nameWithOwner
+ }
+ }
+ }
+ totalCount
+ }
+ }
+ projects(first: 1, orderBy: {field: CREATED_AT, direction: ASC}) {
+ totalCount
+ #nodes { This requires additional scopes :/
+ # name
+ #}
+ }
+ packages(first: 1, orderBy: {direction: ASC, field: CREATED_AT}) {
+ totalCount
+ #nodes { This requires additional scopes :/
+ # name
+ # packageType
+ # versions(first: 1, orderBy: {direction: ASC, field: CREATED_AT}) {
+ # nodes {
+ # id
+ # }
+ # }
+ #}
+ }
+ organizations(first: 1) {
+ nodes {
+ name
+ }
+ totalCount
+ }
+ gists(first: 1, orderBy: {field: CREATED_AT, direction: ASC}) {
+ nodes {
+ createdAt
+ name
+ }
+ totalCount
+ }
+ starredRepositories {
+ totalCount
+ }
+ followers {
+ totalCount
+ }
+ following {
+ totalCount
+ }
+ bio
+ status {
+ message
+ }
+ }
+}
diff --git a/source/plugins/achievements/queries/metrics.graphql b/source/plugins/achievements/queries/metrics.graphql
new file mode 100644
index 00000000..1c7e31c1
--- /dev/null
+++ b/source/plugins/achievements/queries/metrics.graphql
@@ -0,0 +1,8 @@
+query AchievementsMetrics {
+ repository(owner: "lowlighter", name: "metrics") {
+ viewerHasStarred
+ }
+ viewer {
+ login
+ }
+}
\ No newline at end of file
diff --git a/source/plugins/achievements/queries/octocat.graphql b/source/plugins/achievements/queries/octocat.graphql
new file mode 100644
index 00000000..d6fbdba5
--- /dev/null
+++ b/source/plugins/achievements/queries/octocat.graphql
@@ -0,0 +1,8 @@
+query AchievementsOctocat {
+ user(login: "octocat") {
+ viewerIsFollowing
+ }
+ viewer {
+ login
+ }
+}
diff --git a/source/plugins/achievements/queries/ranking.graphql b/source/plugins/achievements/queries/ranking.graphql
new file mode 100644
index 00000000..4901fa86
--- /dev/null
+++ b/source/plugins/achievements/queries/ranking.graphql
@@ -0,0 +1,14 @@
+query AchievementsRanking {
+ repo_rank:search(query: "stars:>$stars", type: REPOSITORY, first: 0) {
+ repositoryCount
+ }
+ user_rank:search(query: "followers:>$followers", type: USER, first: 0) {
+ userCount
+ }
+ repo_total:search(query: "stars:>-1", type: REPOSITORY, first: 0) {
+ repositoryCount
+ }
+ user_total:search(query: "followers:>-1", type: USER, first: 0) {
+ userCount
+ }
+}
\ No newline at end of file
diff --git a/source/plugins/achievements/tests.yml b/source/plugins/achievements/tests.yml
new file mode 100644
index 00000000..573f46ff
--- /dev/null
+++ b/source/plugins/achievements/tests.yml
@@ -0,0 +1,13 @@
+- name: Achievements plugin (default)
+ uses: lowlighter/metrics@latest
+ with:
+ token: MOCKED_TOKEN
+ plugin_achievements: yes
+
+- name: Achievements plugin (complete)
+ uses: lowlighter/metrics@latest
+ with:
+ token: MOCKED_TOKEN
+ plugin_achievements_threshold: A
+ plugin_achievements_secrets: no
+ plugin_achievements_ignored: octonaut
\ No newline at end of file
diff --git a/source/templates/classic/partials/_.json b/source/templates/classic/partials/_.json
index 543f7812..50ea4af3 100644
--- a/source/templates/classic/partials/_.json
+++ b/source/templates/classic/partials/_.json
@@ -23,5 +23,6 @@
"anilist",
"wakatime",
"skyline",
- "stackoverflow"
+ "stackoverflow",
+ "achievements"
]
\ No newline at end of file
diff --git a/source/templates/classic/partials/achievements.ejs b/source/templates/classic/partials/achievements.ejs
new file mode 100644
index 00000000..48438f99
--- /dev/null
+++ b/source/templates/classic/partials/achievements.ejs
@@ -0,0 +1,45 @@
+<% if (plugins.achievements) { %>
+
+
+
+ Achievements
+
+
+
+ <% if (plugins.achievements.error) { %>
+
+
+ <%= plugins.achievements.error.message %>
+
+ <% } else { %>
+ <% for (const {title, text, icon, rank, gh = NaN, progress = 0, unlock = null} of plugins.achievements.list) { %>
+ ">
+
+
+
+
+
+ <%= title %>
+ <% if ((Number.isFinite(gh))&&(gh < 100000)) { %>
+
+ Top <%= gh %>
+
+ <% } %>
+
+
<%= text %>
+
+
+ <% } %>
+ <% } %>
+
+
+
+<% } %>
\ No newline at end of file
diff --git a/source/templates/classic/style.css b/source/templates/classic/style.css
index 54899970..a7c97c02 100644
--- a/source/templates/classic/style.css
+++ b/source/templates/classic/style.css
@@ -836,6 +836,64 @@
width: 400px;
}
+/* Achievements */
+ .achievement {
+ display: flex;
+ margin: 4px 0;
+ }
+ .achievement .icon {
+ margin: 0 4px;
+ width: 44px;
+ height: 44px;
+ }
+ .achievement .text {
+ font-size: 12px;
+ color: #666666;
+ }
+ .achievement .unlock {
+ font-size: 9px;
+ color: #666666;
+ }
+ .achievement .title {
+ font-size: 14px;
+ color: #58A6FF;
+ }
+ .achievement.x .title {
+ color: #666666;
+ }
+ .achievement.x .icon {
+ filter: grayscale(1) opacity(.5);
+ }
+ .achievement.b .title {
+ color: #9D8FFF;
+ }
+ .achievement.b .icon {
+ filter: hue-rotate(35deg);
+ }
+ .achievement.a .title {
+ color: #D79533;
+ }
+ .achievement.a .icon {
+ filter: sepia() saturate(2);
+ }
+ .achievement.s .title {
+ color: #FF0000;
+ }
+ .achievement.s .icon {
+ filter: sepia() saturate(100);
+ }
+ .achievement.secret .title{
+ color: #FF76CD;
+ }
+ .achievement.secret .icon {
+ filter: hue-rotate(100deg);
+ }
+ .achievement .gh {
+ border: 1px solid currentColor;
+ border-radius: 16px;
+ font-size: 10px;
+ padding: 0 5px;
+ }
/* Fade animation */
.af {