Add Currency widget
parent
4cad559e8e
commit
9ef893ea1a
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -174,6 +174,7 @@ main .scroll-wrap {
|
|||
@import "onboarding";
|
||||
@import "utils";
|
||||
@import "scrollbar";
|
||||
@import "stats";
|
||||
@import "iconbar";
|
||||
@import "links";
|
||||
@import "todolist";
|
||||
|
|
|
@ -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);
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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);
|
|
@ -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, () => {
|
||||
|
|
|
@ -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)
|
Loading…
Reference in New Issue