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.
+
+
+
+
+
+#### ℹ️ 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;