Add Currency widget

master
rubenwardy 2021-08-13 21:54:53 +01:00
parent 4cad559e8e
commit 9ef893ea1a
10 changed files with 450 additions and 0 deletions

View File

@ -83,6 +83,10 @@
"description": "Form field hint (Image)",
"message": "Images are stored locally on your browser, and never uploaded"
},
"4dzBeG": {
"description": "Currencies widget: form field label",
"message": "From"
},
"4syC1B": {
"message": "Help and Requests"
},
@ -169,6 +173,10 @@
"description": "Web Comic widget description",
"message": "Shows the most recent image from an Atom or RSS feed, useful for WebComics."
},
"Ddd3ZY": {
"description": "Currencies widget: form field label",
"message": "To"
},
"Dk7Khi": {
"description": "Weather widget: Fahrenheit unit",
"message": "Fahrenheit"
@ -317,6 +325,10 @@
"description": "Theme settings: form field hint (Custom CSS)",
"message": "Please note: this is an experimental feature and CSS selectors may break in future releases."
},
"QV6YlB": {
"description": "Currencies widget: form field label",
"message": "Exchange Rates"
},
"QcExtH": {
"description": "General settings: privacy policy button",
"message": "Privacy Policy"
@ -374,6 +386,10 @@
"description": "Image background mode",
"message": "Image"
},
"UgAgeT": {
"description": "Currencies widget: credit to data provider",
"message": "Powered by exchangerate.host"
},
"V1EIOe": {
"description": "IFrame widget description",
"message": "Shows a webpage"
@ -482,6 +498,10 @@
"a0L2KE": {
"message": "Try including the country name or initials."
},
"aXrD3p": {
"description": "Currencies widget",
"message": "Currencies"
},
"aaxFkJ": {
"description": "Todo List widget: prompt",
"message": "Create a new todo item"
@ -698,6 +718,10 @@
"description": "Top Sites Widget",
"message": "Top Sites"
},
"pG7wIz": {
"description": "Currencies widget description",
"message": "Shows exchange rates, supporting forex currencies and crypto (BitCoin etc)"
},
"pHo6u+": {
"description": "Web Comic widget: no images found error",
"message": "No images found on feed"
@ -744,6 +768,10 @@
"description": "Feed widget: form field label",
"message": "Filter Articles"
},
"rx+w+M": {
"description": "Currencies widget: error",
"message": "Unknown currency {currency}"
},
"sPl3nI": {
"description": "Form field label",
"message": "Title"

30
src/app/scss/_stats.scss Normal file
View File

@ -0,0 +1,30 @@
.stats {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
.singlestat {
width: 100px;
flex-grow: 1;
}
}
.singlestat {
display: flex;
flex-direction: column;
.title, .value {
text-align: center;
display: block;
}
.title {
opacity: 0.7;
font-size: 90%;
}
.value {
font-size: 120%;
font-weight: bold;
}
}

View File

@ -174,6 +174,7 @@ main .scroll-wrap {
@import "onboarding";
@import "utils";
@import "scrollbar";
@import "stats";
@import "iconbar";
@import "links";
@import "todolist";

View File

@ -0,0 +1,112 @@
import ErrorView from 'app/components/ErrorView';
import Panel from 'app/components/Panel';
import { useAPI } from 'app/hooks';
import Schema, { type } from 'app/utils/Schema';
import { Vector2 } from 'app/utils/Vector2';
import { WidgetProps } from 'app/Widget';
import { calculateExchangeRate, CurrencyInfo } from 'common/api/currencies';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
title: {
defaultMessage: "Currencies",
description: "Currencies widget",
},
description: {
defaultMessage: "Shows exchange rates, supporting forex currencies and crypto (BitCoin etc)",
description: "Currencies widget description",
},
rates: {
defaultMessage: "Exchange Rates",
description: "Currencies widget: form field label",
},
editHint: {
defaultMessage: "Powered by exchangerate.host",
description: "Currencies widget: credit to data provider",
},
from: {
defaultMessage: "From",
description: "Currencies widget: form field label",
},
to: {
defaultMessage: "To",
description: "Currencies widget: form field label",
},
unknownCurrency: {
defaultMessage: "Unknown currency {currency}",
description: "Currencies widget: error",
},
});
interface CurrenciesProps {
rates: { from: string, to: string }[];
}
export default function Currencies(widget: WidgetProps<CurrenciesProps>) {
const props = widget.props;
const intl = useIntl();
const [ currencies, error ] = useAPI<Record<string, CurrencyInfo>>(`/currencies/`, {}, []);
if (!currencies) {
return (<ErrorView error={error} loading={true} />)
}
for (const { from, to } of props.rates) {
if (!currencies[from]) {
const msg = intl.formatMessage(messages.unknownCurrency, { currency: from });
return (<ErrorView error={msg} />);
}
if (!currencies[to]) {
const msg = intl.formatMessage(messages.unknownCurrency, { currency: from });
return (<ErrorView error={msg} />);
}
}
return (
<Panel {...widget.theme}>
<div className="stats">
{props.rates.map(({from, to}) => (
<div className="singlestat" key={`${from}-${to}`}>
<span className="title">
{`${from} 🠒 ${to}`}
</span>
<span className="value">
{calculateExchangeRate(currencies, from.toUpperCase(), to.toUpperCase()).toFixed(2)}
</span>
</div>))}
</div>
</Panel>);
}
Currencies.title = messages.title;
Currencies.description = messages.description;
Currencies.editHint = messages.editHint;
Currencies.initialProps = {
rates: [
{ from: "GBP", to: "EUR" },
{ from: "BTC", to: "USD" },
]
} as CurrenciesProps;
const rateSchema: Schema = {
from: type.string(messages.from),
to: type.string(messages.to),
};
Currencies.schema = {
rates: type.array(rateSchema, messages.rates),
} as Schema;
Currencies.defaultSize = new Vector2(5, 3);

View File

@ -4,6 +4,7 @@ import Age from "./Age";
import Bookmarks from "./Bookmarks";
import Button from "./Button";
import Clock from "./Clock";
import Currencies from "./Currencies";
import DailyGoal from "./DailyGoal";
import Feed from "./Feed";
import Greeting from "./Greeting";
@ -28,6 +29,7 @@ export const WidgetTypes: { [name: string]: WidgetType<any> } = {
Bookmarks,
Button,
Clock,
Currencies,
DailyGoal,
Feed,
Greeting,

View File

@ -0,0 +1,23 @@
export interface CurrencyInfo {
code: string;
description: string;
is_crypto: boolean;
value_in_usd: number;
}
/**
* Calculates exchange rate from `from` to `to.
*
* ie: 1 `from` is how many `to`?
*
* @param currencies Dictionary of currency information
* @param from
* @param to
* @returns Exchange rage
*/
export function calculateExchangeRate(currencies: Record<string, CurrencyInfo>, from: string, to: string): number {
const usdInFrom = currencies[from].value_in_usd;
const usdInTo = currencies[to].value_in_usd;
return usdInTo / usdInFrom;
}

View File

@ -4,6 +4,36 @@ import { IS_DEBUG } from "server";
type AnyFunc<R> = (...args: any[]) => R;
type GetKeyFunc = (...args: any[]) => string;
/**
*
* @param func Function to be cached
* @param timeout In minutes
* @returns Function with same signature as `func`, but cached
*/
export function makeSingleCache<R>(func: AnyFunc<R>, timeout: number) {
let cache: (R | undefined) = undefined;
if (!IS_DEBUG) {
setInterval(() => {
cache = undefined;
}, timeout * 60 * 1000);
}
return (...args: any[]) => {
if (cache) {
return cache
}
const ret = func(...args);
cache = ret;
if (ret instanceof Promise) {
ret.catch((e) => {
console.error(e);
cache = undefined;
});
}
return ret;
}
}
/**
* Wraps a function `func` with in-memory caching

120
src/server/currencies.ts Normal file
View File

@ -0,0 +1,120 @@
import { CurrencyInfo } from "common/api/currencies";
import { UA_DEFAULT } from "server";
import { makeSingleCache } from "./cache";
import fetchCatch, {Request} from "./http";
const shitCoins: Record<string, string> = {
"BTC": "Bitcoin",
"ETH": "Ethereum",
"USDT": "Tether",
"BNB": "Binance Coin",
"ADA": "Cardana",
"DOGE": "Dogecoin",
"XRP": "XRP",
"USDC": "USD Coin",
"DOT": "Polkadot",
"UNI": "Uniswap",
};
async function fetchSymbols(): Promise<Record<string, CurrencyInfo>> {
const ret = await fetchCatch(new Request("https://api.exchangerate.host/symbols", {
method: "GET",
size: 0.1 * 1000 * 1000,
timeout: 10000,
headers: {
"User-Agent": UA_DEFAULT,
"Accept": "application/json",
},
}));
const retval: Record<string, CurrencyInfo> = {};
Object.entries((await ret.json()).symbols as Record<string, CurrencyInfo>)
.forEach(([key, {code, description}]) => {
retval[key] = {
code,
description,
value_in_usd: NaN,
is_crypto: false,
};
});
Object.entries(shitCoins).forEach(([code, description]) => {
retval[code] = {
code,
description,
value_in_usd: NaN,
is_crypto: true,
}
});
return retval;
}
async function fetchForexRates(rates: Record<string, number>): Promise<void> {
const url = new URL("https://api.exchangerate.host/latest");
url.searchParams.set("base", "USD");
url.searchParams.set("places", "10");
const ret = await fetchCatch(new Request(url, {
method: "GET",
timeout: 10000,
headers: {
"User-Agent": UA_DEFAULT,
"Accept": "application/json",
},
}));
const json = await ret.json();
Object.entries(json.rates as Record<string, string>).forEach(([key, value]) => {
rates[key] = parseFloat(value);
});
}
async function fetchCryptoRates(rates: Record<string, number>): Promise<void> {
const url = new URL("https://api.exchangerate.host/latest");
url.searchParams.set("base", "USD");
url.searchParams.set("source", "crypto");
url.searchParams.set("places", "10");
url.searchParams.set("symbols", Object.keys(shitCoins).join(","));
const ret = await fetchCatch(new Request(url, {
method: "GET",
timeout: 10000,
headers: {
"User-Agent": UA_DEFAULT,
"Accept": "application/json",
},
}));
const json = await ret.json();
Object.entries(json.rates as Record<string, string>).forEach(([key, value]) => {
rates[key] = parseFloat(value);
});
}
async function fetchCurrencies(): Promise<Record<string, CurrencyInfo>> {
const symbols = await fetchSymbols();
const rates: Record<string, number> = {};
await fetchForexRates(rates);
await fetchCryptoRates(rates);
for (const key in symbols) {
const currency = symbols[key];
currency.value_in_usd = rates[key];
if (!currency.value_in_usd || isNaN(currency.value_in_usd)) {
delete symbols[key];
}
}
return symbols;
}
export const getCurrencies = makeSingleCache(fetchCurrencies, 15);

View File

@ -33,6 +33,7 @@ import getImageFromUnsplash from "./backgrounds/unsplash";
import { compareString } from "common/utils/string";
import SpaceLaunch from "common/api/SpaceLaunch";
import { getQuote, getQuoteCategories } from "./quotes";
import { getCurrencies } from "./currencies";
const app = express();
@ -386,6 +387,18 @@ app.get("/api/quotes/", async (req: express.Request, res: express.Response) => {
}
});
app.get("/api/currencies/", async (req: express.Request, res: express.Response) => {
notifyAPIRequest("currency");
try {
const base = req.query.base ?? "USD";
const result = await getCurrencies(base);
res.json(result);
} catch (ex) {
writeClientError(res, ex.message);
}
});
app.use(Sentry.Handlers.errorHandler());
app.listen(PORT, () => {

91
utils/check_currencies.ts Normal file
View File

@ -0,0 +1,91 @@
const nodeFetch = require("node-fetch");
const cache = new Map<string, Record<string, number>>();
/**
* Gets exchange rates between `base` and all other currencies
*
* @param base 3 letter code
* @returns Exchange rates
*/
async function getBase(base: string): Promise<Record<string, number>> {
if (cache.has(base)) {
return cache.get(base)!;
}
const url = new URL("https://api.exchangerate.host/latest");
url.searchParams.set("base", base);
url.searchParams.set("places", "30");
const ret = await nodeFetch(new nodeFetch.Request(url.toString(), {
method: "GET",
timeout: 10000,
headers: {
"Accept": "application/json",
},
}));
const rates = (await ret.json()).rates as Record<string, any>;
if (typeof rates != "object") {
throw new Error("Invalid response from API");
}
for (const key in rates) {
rates[key] = parseFloat(rates[key]);
}
cache.set(base, rates);
return rates;
}
/**
* Get how much 1 `from` is in `to`
*
* @param from 3 letter code
* @param to 3 letter code
* @returns
*/
async function getDirect(from: string, to: string): Promise<number> {
const rates = await getBase(from);
if (rates[to] == undefined) {
throw new Error(`Unable to get ${to} in ${from}`);
}
return rates[to];
}
/**
* Get how much 1 `from` is in `to`
*
* @param from 3 letter code
* @param to 3 letter code
* @returns
*/
async function getIndirect(from: string, to: string): Promise<number> {
const rates = await getBase("USD");
if (rates[from] == undefined) {
throw new Error(`Unable to get ${from} in USD`);
}
if (rates[to] == undefined) {
throw new Error(`Unable to get ${to} in USD}`);
}
const usdInFrom = rates[from];
const usdInTo = rates[to];
return usdInTo / usdInFrom;
}
async function getReport(from: string, to: string) {
const direct = await getDirect(from, to);
const indirect = await getIndirect(from, to);
const diff = indirect - direct;
const perc = 100 * (1 - Math.abs(diff) / direct);
return `${from}🠒${to}: ${direct.toFixed(2)} direct, ${indirect.toFixed(2)} indirect: ${perc.toFixed(3)}% accuracy, ${diff.toFixed(2)} off`;
}
Promise.all([
getReport("GBP", "EUR"),
getReport("GBP", "JPY"),
getReport("BTC", "GBP"),
getReport("BTC", "USD"),
]).then(x => x.join("\n")).then(console.log).catch(console.error)