Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 286 additions & 18 deletions MMM-SingleStock.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ Module.register('MMM-SingleStock', {
changeType: '',
colorized: false,
minimal: false,
label: 'symbol' // 'symbol' | 'companyName' | 'none'
label: 'symbol', // 'symbol' | 'companyName' | 'none'
api: 'iexcloud', // 'iexcloud' | 'tiingo'
crypto: ''
},

requiresVersion: '2.1.0',
url: '',
cryptoUrl: '',

getTranslations() {
return {
Expand All @@ -31,10 +35,18 @@ Module.register('MMM-SingleStock', {
this.viewModel = null;
this.hasData = false;

this._getData(() => self.updateDom());
if (this.config.crypto !== '') {
this._getCrypto(() => self.updateDom());
} else {
this._getData(() => self.updateDom());
}

setInterval(() => {
self._getData(() => self.updateDom());
if (this.config.crypto !== '') {
self._getCrypto(() => self.updateDom());
} else {
self._getData(() => self.updateDom());
}
}, this.config.updateInterval);
},

Expand Down Expand Up @@ -71,14 +83,11 @@ Module.register('MMM-SingleStock', {
? 'dimmed xsmall'
: 'dimmed small';

if (this.config.colorized)
{
if (this.viewModel.change > 0)
{
if (this.config.colorized) {
if (this.viewModel.change > 0) {
changeEl.style = 'color: #a3ea80';
}
if (this.viewModel.change < 0)
{
if (this.viewModel.change < 0) {
changeEl.style = 'color: #FF8E99';
}
}
Expand All @@ -87,21 +96,44 @@ Module.register('MMM-SingleStock', {
}
} else {
const loadingEl = document.createElement('span');
loadingEl.innerHTML = this.translate('LOADING', { symbol: this.config.stockSymbol });
loadingEl.innerHTML = this.translate('LOADING', { symbol: this.config.crypto ?? this.config.stockSymbol });
loadingEl.classList = 'dimmed small';
wrapper.appendChild(loadingEl);
}

return wrapper;
},

_getCrypto(onCompleteCallback) {
const self = this;

if (this.cryptoUrl === '') {
this.cryptoUrl = this._getTiingoUrl(`https://api.tiingo.com/tiingo/crypto/prices?tickers=${this.config.crypto}&resampleFreq=5min`);
}

const xhr = new XMLHttpRequest();
xhr.open('GET', this.cryptoUrl, true);
xhr.onreadystatechange = function onReadyStateChange() {
if (this.readyState === 4) {
if (this.status === 200) {
self._processCryptoResponse(this.response);
onCompleteCallback();
} else {
Log.error(self.name, `MMM-SingleStock: Failed to load crypto data. XHR status: ${this.status}`);
}
}
};

xhr.send();
},

_getData(onCompleteCallback) {
const self = this;

const url = `https://cloud.iexapis.com/v1/stock/${this.config.stockSymbol}/quote?token=${this.config.apiToken}`;
self._SetUrl(this.config.api);

const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.open('GET', self.url, true);
xhr.onreadystatechange = function onReadyStateChange() {
if (this.readyState === 4) {
if (this.status === 200) {
Expand All @@ -117,27 +149,27 @@ Module.register('MMM-SingleStock', {
},

_processResponse(responseBody) {
const response = JSON.parse(responseBody);
const response = this._setResponse(responseBody);

this.viewModel = {
price: response.latestPrice
price: this._setPrice(response)
};

switch (this.config.changeType) {
case 'percent':
this.viewModel.change = (response.changePercent * 100).toFixed(2);
this.viewModel.change = this._setPercentChange(response);
break;
default:
this.viewModel.change = response.change > 0 ? `+${response.change}` : `${response.change}`;
this.viewModel.change = this._setNumericChange(response);
break;
}

switch (this.config.label) {
case 'symbol':
this.viewModel.label = response.symbol;
this.viewModel.label = response.ticker;
break;
case 'companyName':
this.viewModel.label = response.companyName;
this.viewModel.label = response.ticker;
break;
case 'none':
this.viewModel.label = '';
Expand All @@ -152,5 +184,241 @@ Module.register('MMM-SingleStock', {
}

this.hasData = true;
},

_processCryptoResponse(responseBody) {
const parsed = JSON.parse(responseBody);
// eslint-disable-next-line prefer-destructuring
const response = parsed[0];
// eslint-disable-next-line prefer-destructuring
const openingPriceData = response.priceData[0];
const priceData = response.priceData[response.priceData.length - 1];

Log.info(priceData);

this.viewModel = {
price: priceData.close.toFixed(2)
};

switch (this.config.changeType) {
case 'percent':
this.viewModel.change = (
((priceData.close - openingPriceData.open) / openingPriceData.open)
* 100
).toFixed(2);
break;
default:
this.viewModel.change = (priceData.close - openingPriceData.open).toFixed(2);
break;
}

switch (this.config.label) {
case 'symbol':
this.viewModel.label = response.baseCurrency.toUpperCase();
break;
case 'none':
this.viewModel.label = '';
break;
default:
this.viewModel.label = this.config.label;
break;
}

if (!this.hasData) {
this.updateDom();
}

this.hasData = true;
},

_setResponse(responseBody) {
let response = '';
switch (this.config.api) {
case 'iexcloud': {
response = JSON.parse(responseBody);
break;
}
case 'tiingo': {
const parsed = JSON.parse(responseBody);
// eslint-disable-next-line prefer-destructuring
response = parsed[0];
break;
}
default:
break;
}

return response;
},

_setPrice(response) {
let value = '';
switch (this.config.api) {
case 'iexcloud':
value = response.latestPrice;
break;
case 'tiingo':
value = response.last;
break;
default:
break;
}

return value;
},

_setNumericChange(response) {
let value = '';
switch (this.config.api) {
case 'iexcloud':
value = response.change > 0 ? `+${response.change}` : `${response.change}`;
break;
case 'tiingo':
value = (response.last - response.prevClose).toFixed(2);
break;
default:
break;
}

return value;
},

_setPercentChange(response) {
let value = '';
switch (this.config.api) {
case 'iexcloud':
value = (response.changePercent * 100).toFixed(2);
break;
case 'tiingo':
value = (
((response.last - response.prevClose) / response.prevClose)
* 100
).toFixed(2);
break;
default:
break;
}

return value;
},

_SetUrl(api) {
switch (api) {
case 'iexcloud':
this.url = `https://cloud.iexapis.com/v1/stock/${this.config.stockSymbol}/quote?token=${this.config.apiToken}`;
break;
case 'tiingo':
if (this.url === '') {
this.url = this._getTiingoUrl(`https://api.tiingo.com/iex/?tickers=${this.config.stockSymbol}`);
}
break;
default:
Log.error(this.name, `Invalid api config: ${api}`);
break;
}
},

_getTiingoUrl(baseUrl) {
const expectedResponseHeaders = [
'server',
'date',
'content-type',
'content-length',
'vary',
'x-frame-options'
];
const requestHeaders = [
{ name: 'Content-Type', value: 'application/json' },
{ name: 'Authorization', value: `Token ${this.config.apiToken}` }
];

const url = this._getCorsUrl(
baseUrl,
requestHeaders,
expectedResponseHeaders
);

return url;
},

/**
* Gets a URL that will be used when calling the CORS-method on the server.
*
* @param {string} url the url to fetch from
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to recieve
* @returns {string} to be used as URL when calling CORS-method on server.
*/
_getCorsUrl(url, requestHeaders, expectedResponseHeaders) {
if (!url || url.length < 1) {
throw new Error(`Invalid URL: ${url}`);
} else {
// eslint-disable-next-line no-restricted-globals
let corsUrl = `${location.protocol}//${location.host}/cors?`;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙋‍♂️ I'm not familiar with this endpoint, is this a CORS proxy service of MagicMirror? Does it really need for Tiingo, it does not support browsers?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm honestly primarily a backend dev so dealing with CORS was a bit new for me but it was required for Tiingo. I pretty much pulled the code from the default magic mirror utils code. I couldn't figure out how to directly use it.


const requestHeaderString = this._getRequestHeaderString(requestHeaders);
if (requestHeaderString) corsUrl = `${corsUrl}sendheaders=${requestHeaderString}`;

// eslint-disable-next-line max-len
const expectedResponseHeadersString = this._getExpectedResponseHeadersString(expectedResponseHeaders);
if (requestHeaderString && expectedResponseHeadersString) {
corsUrl = `${corsUrl}&expectedheaders=${expectedResponseHeadersString}`;
} else if (expectedResponseHeadersString) {
corsUrl = `${corsUrl}expectedheaders=${expectedResponseHeadersString}`;
}

if (requestHeaderString || expectedResponseHeadersString) {
return `${corsUrl}&url=${url}`;
}
return `${corsUrl}url=${url}`;
}
},

/**
* Gets the part of the CORS URL that represents the HTTP headers to send.
*
* @param {Array.<{name: string, value:string}>} requestHeaders the HTTP headers to send
* @returns {string} to be used as request-headers component in CORS URL.
*/
_getRequestHeaderString(requestHeaders) {
let requestHeaderString = '';
if (requestHeaders) {
// eslint-disable-next-line no-restricted-syntax
for (const header of requestHeaders) {
if (requestHeaderString.length === 0) {
requestHeaderString = `${header.name}:${encodeURIComponent(
header.value
)}`;
} else {
requestHeaderString = `${requestHeaderString},${
header.name
}:${encodeURIComponent(header.value)}`;
}
}
return requestHeaderString;
}
return undefined;
},

/**
* Gets the part of the CORS URL that represents the expected HTTP headers to recieve.
*
* @param {Array.<string>} expectedResponseHeaders the expected HTTP headers to recieve
* @returns {string} to be used as the expected HTTP-headers component in CORS URL.
*/
_getExpectedResponseHeadersString(expectedResponseHeaders) {
let expectedResponseHeadersString = '';
if (expectedResponseHeaders) {
// eslint-disable-next-line no-restricted-syntax
for (const header of expectedResponseHeaders) {
if (expectedResponseHeadersString.length === 0) {
expectedResponseHeadersString = `${header}`;
} else {
expectedResponseHeadersString = `${expectedResponseHeadersString},${header}`;
}
}
return expectedResponseHeaders;
}
return undefined;
}
});
Loading