diff --git a/.github/readme/imgs/plugin_stock_token.png b/.github/readme/imgs/plugin_stock_token.png new file mode 100644 index 00000000..2cba722c Binary files /dev/null and b/.github/readme/imgs/plugin_stock_token.png differ diff --git a/source/app/metrics/utils.mjs b/source/app/metrics/utils.mjs index 438fec20..ab5a64d5 100644 --- a/source/app/metrics/utils.mjs +++ b/source/app/metrics/utils.mjs @@ -28,10 +28,12 @@ } /**Formatter */ - export function format(n, {sign = false} = {}) { - for (const {u, v} of [{u:"b", v:10**9}, {u:"m", v:10**6}, {u:"k", v:10**3}]) { - if (n/v >= 1) - return `${(sign)&&(n > 0) ? "+" : ""}${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")}${u}` + export function format(n, {sign = false, unit = true} = {}) { + if (unit) { + for (const {u, v} of [{u:"b", v:10**9}, {u:"m", v:10**6}, {u:"k", v:10**3}]) { + if (n/v >= 1) + return `${(sign)&&(n > 0) ? "+" : ""}${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")}${u}` + } } return `${(sign)&&(n > 0) ? "+" : ""}${n}` } diff --git a/source/app/mocks/api/axios/get/yahoo.mjs b/source/app/mocks/api/axios/get/yahoo.mjs new file mode 100644 index 00000000..ac2e68e8 --- /dev/null +++ b/source/app/mocks/api/axios/get/yahoo.mjs @@ -0,0 +1,75 @@ +/**Mocked data */ + export default function({faker, url, options, login = faker.internet.userName()}) { + //Wakatime api + if (/^https:..apidojo-yahoo-finance-v1.p.rapidapi.com.stock.v2.*$/.test(url)) { + //Get company profile + if (/get-profile/.test(url)) { + console.debug(`metrics/compute/mocks > mocking yahoo finance api result > ${url}`) + return ({ + status:200, + data:{ + price:{ + marketCap:{ + raw:faker.random.number(1000000000), + }, + symbol:"OCTO", + }, + quoteType:{ + shortName:faker.company.companyName(), + longName:faker.company.companyName(), + exchangeTimezoneName:faker.address.timeZone(), + symbol:"OCTO", + }, + calendarEvents:{}, + summaryDetail:{}, + symbol:"OCTO", + assetProfile:{ + fullTimeEmployees:faker.random.number(10000), + city:faker.address.city(), + country:faker.address.country(), + }, + }, + }) + } + //Get stock chart + if (/get-chart/.test(url)) { + console.debug(`metrics/compute/mocks > mocking yahoo finance api result > ${url}`) + return ({ + status:200, + data:{ + chart:{ + result:[ + { + meta:{ + currency:"USD", + symbol:"OCTO", + regularMarketPrice:faker.random.number(10000)/100, + chartPreviousClose:faker.random.number(10000)/100, + previousClose:faker.random.number(10000)/100, + }, + timestamp:new Array(1000).fill(Date.now()).map((x, i) => x+i*60000), + indicators:{ + quote:[ + { + close:new Array(1000).fill(null).map(_ => faker.random.number(10000)/100), + get low() { + return this.close + }, + get high() { + return this.close + }, + get open() { + return this.close + }, + volume:[], + }, + ], + }, + }, + ], + }, + }, + }) + } + } + } \ 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 9e3eae4c..c4bdfc22 100644 --- a/source/app/web/statics/app.placeholder.js +++ b/source/app/web/statics/app.placeholder.js @@ -230,7 +230,7 @@ favorites:distribution(7).map((value, index, array) => ({name:faker.lorem.word(), color:faker.internet.color(), value, size:faker.random.number(1000000), x:array.slice(0, index).reduce((a, b) => a + b, 0)})) } }) : null), - //Languages + //RSS ...(set.plugins.enabled.rss ? ({ rss:{ source:faker.lorem.words(), @@ -242,6 +242,20 @@ })), } }) : null), + //Stock price + ...(set.plugins.enabled.stock ? ({ + stock:{ + chart:"(stock chart is not displayed in placeholder)", + currency:"USD", + price:faker.random.number(10000)/100, + previous:faker.random.number(10000)/100, + get delta() { return this.price-this.previous }, + symbol:options["stock.symbol"], + company:faker.company.companyName(), + interval:options["stock.interval"], + duration:options["stock.duration"], + } + }) : null), //Habits ...(set.plugins.enabled.habits ? ({ habits:{ diff --git a/source/plugins/stock/README.md b/source/plugins/stock/README.md new file mode 100644 index 00000000..532f3662 --- /dev/null +++ b/source/plugins/stock/README.md @@ -0,0 +1,34 @@ +### 💹 Stock prices + +The *stock* plugin lets you display the stock market price of a given company. + + + +
+ + +
+ +
+💬 Obtaining a RapidAPI Yahoo Finance token + +Create a [RapidAPI account](https://rapidapi.com) and subscribe to [Yahoo Finance API](https://rapidapi.com/apidojo/api/yahoo-finance1) to get a token. + +![RapidAPI token](/.github/readme/imgs/plugin_stock_token.png) + +
+ +#### ℹ️ Examples workflows + +[➡️ Available options for this plugin](metadata.yml) + +```yaml +- uses: lowlighter/metrics@latest + with: + # ... other options + plugin_stock: yes + plugin_stock_token: ${{ secrets.STOCK_TOKEN }} # RapidAPI Yahoo Finance token + plugin_stock_symbol: TSLA # Display Tesla stock price + plugin_stock_duration: 1d # Display last day of market + plugin_stock_interval: 5m # Use precision of 5 minutes for each record +``` \ No newline at end of file diff --git a/source/plugins/stock/index.mjs b/source/plugins/stock/index.mjs new file mode 100644 index 00000000..e2b036d2 --- /dev/null +++ b/source/plugins/stock/index.mjs @@ -0,0 +1,60 @@ +//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.stock)) + return null + + //Load inputs + let {symbol, interval, duration} = imports.metadata.plugins.stock.inputs({data, account, q}) + if (!token) + throw {error:{message:"A token is required"}} + if (!symbol) + throw {error:{message:"A company stock symbol is required"}} + symbol = symbol.toLocaleUpperCase() + + //Query API for company informations + console.debug(`metrics/compute/${login}/plugins > stock > querying api for company`) + const {data:{quoteType:{shortName:company}}} = await imports.axios.get("https://apidojo-yahoo-finance-v1.p.rapidapi.com/stock/v2/get-profile", { + params:{symbol, region:"US"}, + headers:{"x-rapidapi-key":token}, + }) + + //Query API for sotck charts + console.debug(`metrics/compute/${login}/plugins > stock > querying api for stock`) + const {data:{chart:{result:[{meta, timestamp, indicators:{quote:[{close}]}}]}}} = await imports.axios.get("https://apidojo-yahoo-finance-v1.p.rapidapi.com/stock/v2/get-chart", { + params:{interval, symbol, range:duration, region:"US"}, + headers:{"x-rapidapi-key":token}, + }) + const {currency, regularMarketPrice:price, previousClose:previous} = meta + + //Generating chart + console.debug(`metrics/compute/${login}/plugins > stock > generating chart`) + const chart = await imports.chartist("line", { + width:480, + height:160, + showPoint:false, + axisX:{showGrid:false, labelInterpolationFnc:(value, index) => index%Math.floor(close.length/4) === 0 ? value : null}, + axisY:{scaleMinSpace:20}, + showArea:true, + }, { + labels:timestamp.map(timestamp => new Intl.DateTimeFormat("en-GB", {month:"2-digit", day:"2-digit", hour:"2-digit", minute:"2-digit"}).format(new Date(timestamp*1000))), + series:[close], + }) + + //Results + return {chart, currency, price, previous, delta:price-previous, symbol, company, interval, duration} + } + //Handle errors + catch (error) { + let message = "An error occured" + if (error.isAxiosError) { + const status = error.response?.status + const description = error.response?.data?.message ?? null + message = `API returned ${status}${description ? ` (${description})` : ""}` + error = error.response?.data ?? null + } + throw {error:{message, instance:error}} + } + } \ No newline at end of file diff --git a/source/plugins/stock/metadata.yml b/source/plugins/stock/metadata.yml new file mode 100644 index 00000000..0b544e4d --- /dev/null +++ b/source/plugins/stock/metadata.yml @@ -0,0 +1,58 @@ +name: "💹 Stock prices" +cost: N/A +categorie: other +index: 1 +supports: + - user + - organization +inputs: + + # Enable or disable plugin + plugin_stock: + description: Display stock prices of a given company + type: boolean + default: no + + # RapidAPI Yahoo finance token + # Case insensitive + plugin_stock_token: + description: Yahoo Finance token + type: token + default: "" + + # Company stock symbol (required) + plugin_stock_symbol: + description: Company stock symbol + type: string + default: "" + + # Time range to display (relative to current date) + plugin_stock_duration: + description: Time range to display + type: string + default: 1d + values: + - 1d # Today + - 5d # 5 days + - 1mo # 1 month + - 3mo # 3 months + - 6mo # 6 months + - 1y # 1 year + - 2y # 2 years + - 5y # 5 years + - 10y # 10 years + - ytd # Year to date + - max # All time + + # Time invervals between each records over the given time range + plugin_stock_interval: + description: Time intervals between records + type: string + default: 5m + values: + - 1m # 1 minute + - 2m # 2 minutes + - 5m # 5 minutes + - 15m # 15 minutes + - 60m # 60 minutes + - 1d # 1 day diff --git a/source/plugins/stock/tests.yml b/source/plugins/stock/tests.yml new file mode 100644 index 00000000..c4cf3fe7 --- /dev/null +++ b/source/plugins/stock/tests.yml @@ -0,0 +1,17 @@ +- name: Stock plugin (default) + uses: lowlighter/metrics@latest + with: + token: NOT_NEEDED + plugin_stock: yes + plugin_stock_token: MOCKED_TOKEN + plugin_stock_symbol: OCTO + +- name: Stock plugin (complete) + uses: lowlighter/metrics@latest + with: + token: NOT_NEEDED + plugin_stock: yes + plugin_stock_token: MOCKED_TOKEN + plugin_stock_symbol: OCTO + plugin_stock_duration: 5d + plugin_stock_interval: 5m \ No newline at end of file diff --git a/source/templates/classic/partials/_.json b/source/templates/classic/partials/_.json index 18c62c73..3fa4541d 100644 --- a/source/templates/classic/partials/_.json +++ b/source/templates/classic/partials/_.json @@ -25,5 +25,6 @@ "wakatime", "skyline", "stackoverflow", + "stock", "achievements" ] \ No newline at end of file diff --git a/source/templates/classic/partials/stock.ejs b/source/templates/classic/partials/stock.ejs new file mode 100644 index 00000000..f25287c3 --- /dev/null +++ b/source/templates/classic/partials/stock.ejs @@ -0,0 +1,48 @@ +<% if (plugins.stock) { %> +
+

+ + Stock prices <%= plugins.stock.symbol ? `for ${plugins.stock.symbol}` : "" %> +

+ <% if (plugins.stock.error) { %> +
+
+
+ + <%= plugins.stock.error.message %> +
+
+
+ <% } else { %> +
+
+
+ + <%= plugins.stock.company %> +
+
+ + Valued at <%= plugins.stock.price.toFixed(2) %> <%= plugins.stock.currency %> +
+
+
+
+ + <%= {"1d":"Today", "5d":"Last five days", "1mo":"Last month", "3mo":"Last trimester", "6mo":"Last semester", "1y":"Last year", "2y":"Last two years", "5y":"Last five years", "10y":"Last ten years", ytd:"Year to date", max:"All-time"}[plugins.stock.duration] %> +
+
+ <% if (plugins.stock.delta > 0) { %> + + <% } else { %> + + <% } %> + <%= f(plugins.stock.delta.toFixed(2), {sign:true}) %> (<%= f((100*plugins.stock.delta/plugins.stock.price).toFixed(2), {sign:true}) %>%) +
+
+
+
+ <%- plugins.stock.chart %> +
+ <% } %> +
+<% } %> \ No newline at end of file diff --git a/source/templates/classic/style.css b/source/templates/classic/style.css index e3ebbc1d..8ae3584b 100644 --- a/source/templates/classic/style.css +++ b/source/templates/classic/style.css @@ -916,6 +916,22 @@ color: #666666; } +/* Charts */ + .ct-line { + stroke-width: 2px !important; + stroke: #58A6FF !important; + } + .ct-area { + fill: #58A6FF !important; + } + .ct-label { + fill: rgba(127, 127, 127, 0.8) !important; + color: rgba(127, 127, 127, 0.8) !important; + } + .ct-grid { + stroke: rgba(127, 127, 127, 0.4) !important; + } + /* Fade animation */ .af { opacity: 0;