From 51b7e639cc30e2355354ee1d8607c4e68058b719 Mon Sep 17 00:00:00 2001
From: Alicia Sykes <sykes.alicia@gmail.com>
Date: Sun, 12 Dec 2021 16:30:07 +0000
Subject: [PATCH] :sparkles: Creates an stock price chart widget

---
 docs/widgets.md                            |  25 +++
 src/components/Widgets/StockPriceChart.vue | 176 +++++++++++++++++++++
 src/components/Widgets/WidgetBase.vue      |   3 +
 src/utils/defaults.js                      |   1 +
 4 files changed, 205 insertions(+)
 create mode 100644 src/components/Widgets/StockPriceChart.vue

diff --git a/docs/widgets.md b/docs/widgets.md
index 768d6a29..109bdf2a 100644
--- a/docs/widgets.md
+++ b/docs/widgets.md
@@ -236,6 +236,31 @@ Display current FX rates in your native currency
       - KPW
 ```
 
+### Stock Price History
+
+Shows recent price history for a given publicly-traded stock or share
+
+<p align="center"><img width="400" src="https://i.ibb.co/XZHRb4f/stock-price.png" /></p>
+
+##### Options
+
+**Field** | **Type** | **Required** | **Description**
+--- | --- | --- | ---
+**`apiKey`** | `string` |  Required | API key for [Alpha Vantage](https://www.alphavantage.co/), you can get a free API key [here](https://www.alphavantage.co/support/#api-key)
+**`stock`** | `string` | Required | The stock symbol for the asset to fetch data for
+**`priceTime`** | `string` |  _Optional_ | The time to fetch price for. Can be `high`, `low`, `open` or `close`. Defaults to `high`
+
+##### Example 
+
+```yaml
+- name: CloudFlare Stock Price
+  icon: fas fa-analytics
+  type: stock-price-chart
+  options:
+    stock: NET
+    apiKey: PGUWSWD6CZTXMT8N
+```
+
 ---
 
 ## Dynamic Widgets
diff --git a/src/components/Widgets/StockPriceChart.vue b/src/components/Widgets/StockPriceChart.vue
new file mode 100644
index 00000000..c4dcbf94
--- /dev/null
+++ b/src/components/Widgets/StockPriceChart.vue
@@ -0,0 +1,176 @@
+<template>
+<div class="crypto-price-chart" :id="chartId"></div>
+</template>
+
+<script>
+import { Chart } from 'frappe-charts/dist/frappe-charts.min.esm';
+import axios from 'axios';
+import WidgetMixin from '@/mixins/WidgetMixin';
+import ErrorHandler from '@/utils/ErrorHandler';
+import { widgetApiEndpoints } from '@/utils/defaults';
+
+export default {
+  mixins: [WidgetMixin],
+  components: {},
+  data() {
+    return {
+      chartData: null,
+      chartDom: null,
+    };
+  },
+  mounted() {
+    this.fetchData();
+  },
+  computed: {
+    /* The stock or share asset symbol to fetch data for */
+    stock() {
+      return this.options.stock;
+    },
+    /* The time interval between data points, in minutes */
+    interval() {
+      return `${(this.options.interval || 30)}min`;
+    },
+    /* The users API key for AlphaVantage */
+    apiKey() {
+      return this.options.apiKey;
+    },
+    /* The formatted GET request API endpoint to fetch stock data from */
+    endpoint() {
+      const func = 'TIME_SERIES_INTRADAY';
+      return `${widgetApiEndpoints.stockPriceChart}?function=${func}`
+      + `&symbol=${this.stock}&interval=${this.interval}&apikey=${this.apiKey}`;
+    },
+    /* The number of data points to render on the chart */
+    dataPoints() {
+      const userChoice = this.options.dataPoints;
+      if (!Number.isNaN(userChoice) && userChoice < 100 && userChoice > 5) {
+        return userChoice;
+      }
+      return 30;
+    },
+    /* A sudo-random ID for the chart DOM element */
+    chartId() {
+      return `stock-price-chart-${Math.round(Math.random() * 10000)}`;
+    },
+    /* Get color hex code for chart, from CSS variable, or user choice */
+    getChartColor() {
+      if (this.options.chartColor) return this.options.chartColor;
+      const cssVars = getComputedStyle(document.documentElement);
+      return cssVars.getPropertyValue('--widget-text-color').trim() || '#7cd6fd';
+    },
+    /* Which price for each interval should be used (API requires in stupid format) */
+    priceTime() {
+      const usersChoice = this.options.priceTime || 'high';
+      switch (usersChoice) {
+        case ('open'): return '1. open';
+        case ('high'): return '2. high';
+        case ('low'): return '3. low';
+        case ('close'): return '4. close';
+        case ('volume'): return '5. volume';
+        default: return '2. high';
+      }
+    },
+  },
+  methods: {
+    /* Create new chart, using the crypto data */
+    generateChart() {
+      return new Chart(`#${this.chartId}`, {
+        title: `${this.stock} Price Chart`,
+        data: this.chartData,
+        type: 'axis-mixed',
+        height: 200,
+        colors: [this.getChartColor, '#743ee2'],
+        truncateLegends: true,
+        lineOptions: {
+          regionFill: 1,
+          hideDots: 1,
+        },
+        axisOptions: {
+          xIsSeries: true,
+          xAxisMode: 'tick',
+        },
+        tooltipOptions: {
+          formatTooltipY: d => `$${d}`,
+        },
+      });
+    },
+    /* Make GET request to CoinGecko API endpoint */
+    fetchData() {
+      axios.get(this.endpoint)
+        .then((response) => {
+          if (response.data.note) {
+            ErrorHandler('API Error', response.data.Note);
+          } else if (response.data['Error Message']) {
+            ErrorHandler('API Error', response.data['Error Message']);
+          } else {
+            this.processData(response.data);
+          }
+        })
+        .catch((error) => {
+          ErrorHandler('Unable to fetch stock price data', error);
+        });
+    },
+    /* Convert data returned by API into a format that can be consumed by the chart
+     * To improve efficiency, only a certain amount of data points are plotted
+     */
+    processData(data) {
+      const priceLabels = [];
+      const priceValues = [];
+      const dataKey = `Time Series (${this.interval})`;
+      const rawMarketData = data[dataKey];
+      const interval = Math.round(Object.keys(rawMarketData).length / this.dataPoints);
+      Object.keys(rawMarketData).forEach((timeGroup, index) => {
+        if (index % interval === 0) {
+          priceLabels.push(this.formatDate(timeGroup));
+          priceValues.push(this.formatPrice(rawMarketData[timeGroup][this.priceTime]));
+        }
+      });
+      // // Combine results with chart config
+      this.chartData = {
+        labels: priceLabels,
+        datasets: [
+          { name: `Price ${this.priceTime}`, type: 'bar', values: priceValues },
+        ],
+      };
+      // // Call chart render function
+      this.renderChart();
+    },
+    /* Uses class data to render the line chart */
+    renderChart() {
+      this.chartDom = this.generateChart();
+    },
+    /* Format the date for a given time stamp, also include time if required */
+    formatDate(timestamp) {
+      const localFormat = navigator.language;
+      const dateFormat = { weekday: 'short', day: 'numeric', month: 'short' };
+      const date = new Date(timestamp).toLocaleDateString(localFormat, dateFormat);
+      return date;
+    },
+    /* Format the price, rounding to given number of decimal places */
+    formatPrice(priceStr) {
+      const price = parseInt(priceStr, 10);
+      let numDecimals = 0;
+      if (price < 10) numDecimals = 1;
+      if (price < 1) numDecimals = 2;
+      if (price < 0.1) numDecimals = 3;
+      if (price < 0.01) numDecimals = 4;
+      if (price < 0.001) numDecimals = 5;
+      return price.toFixed(numDecimals);
+    },
+  },
+};
+</script>
+
+<style lang="scss">
+.crypto-price-chart .chart-container {
+  text.title {
+    text-transform: capitalize;
+    color: var(--widget-text-color);
+  }
+  .axis, .chart-label {
+    fill: var(--widget-text-color);
+    opacity: var(--dimming-factor);
+    &:hover { opacity: 1; }
+  }
+}
+</style>
diff --git a/src/components/Widgets/WidgetBase.vue b/src/components/Widgets/WidgetBase.vue
index 61275373..ba78aae4 100644
--- a/src/components/Widgets/WidgetBase.vue
+++ b/src/components/Widgets/WidgetBase.vue
@@ -18,6 +18,7 @@
       <CryptoWatchList v-else-if="widgetType === 'crypto-watch-list'" :options="widgetOptions" />
       <XkcdComic v-else-if="widgetType === 'xkcd-comic'" :options="widgetOptions" />
       <ExchangeRates v-else-if="widgetType === 'exchange-rates'" :options="widgetOptions" />
+      <StockPriceChart v-else-if="widgetType === 'stock-price-chart'" :options="widgetOptions" />
     </Collapsable>
   </div>
 </template>
@@ -31,6 +32,7 @@ import CryptoPriceChart from '@/components/Widgets/CryptoPriceChart.vue';
 import CryptoWatchList from '@/components/Widgets/CryptoWatchList.vue';
 import XkcdComic from '@/components/Widgets/XkcdComic.vue';
 import ExchangeRates from '@/components/Widgets/ExchangeRates.vue';
+import StockPriceChart from '@/components/Widgets/StockPriceChart.vue';
 import Collapsable from '@/components/LinkItems/Collapsable.vue';
 
 export default {
@@ -45,6 +47,7 @@ export default {
     CryptoWatchList,
     XkcdComic,
     ExchangeRates,
+    StockPriceChart,
   },
   props: {
     widget: Object,
diff --git a/src/utils/defaults.js b/src/utils/defaults.js
index c2273693..8f978675 100644
--- a/src/utils/defaults.js
+++ b/src/utils/defaults.js
@@ -212,6 +212,7 @@ module.exports = {
     cryptoWatchList: 'https://api.coingecko.com/api/v3/coins/markets/',
     xkcdComic: 'https://xkcd.vercel.app/',
     exchangeRates: 'https://v6.exchangerate-api.com/v6/',
+    stockPriceChart: 'https://www.alphavantage.co/query',
   },
   /* URLs for web search engines */
   searchEngineUrls: {