diff --git a/README.md b/README.md index 25db4dfe..fe21a6ac 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ * Clock * Calendar * Stocks +* Cryptocurrencies * iframe * Twitch channels & top games * GitHub releases diff --git a/docs/configuration.md b/docs/configuration.md index 7d4669f0..448dba8d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1314,6 +1314,72 @@ The link to go to when clicking on the symbol. `chart-link` The link to go to when clicking on the chart. +### Crypto +Display a list of cryptocurrencies, their current value, change for the day and a small single day chart. Data is taken from CoinGecko. + +Example: +```yaml +- type: crypto + cryptos: + - name: Bitcoin + symbol: BTC + id: bitcoin + - name: Ethereum + symbol: ETH + id: ethereum + - name: Avalanche + symbol: AVAX + id: avalanche-2 + - name: Solana + symbol: SOL + id: solana + - name: Dogecoin + symbol: DOGE + id: dogecoin +``` + +Preview: +![](images/crypto-widget-preview.png) + +#### Properties +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| cryptos | array | yes | | +| style | string | no | | +| sort-by | string | no | | +| days | string | no | 1 | + +#### Properties for each cryptocurrency +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| symbol | string | yes | | +| name | string | yes | | +| id | string | yes | | +| currency | string | no | usd | +| symbol-link | string | no | | +| days | string | no | | + +##### `cryptos` +An array of cryptocurrencies for which to display information about. + +##### `currency` +The currency in which to display the value of the cryptocurrency. Possible values are `usd`, `eur`, `gbp`, `jpy`, `cny`, `krw`, `inr`, `rub`, `brl`, `cad`, `aud`, `chf`, `hkd`, `twd`, `sgd`, `thb`, `idr`, `myr`, `php`, `zar`, `sek`, `nok`, `dkk`, `pln`, `huf`, `czk`, `ils`, `try`, `clp`, `mxn`, `php`, `cop`, `ars`, `pen`, `vef`, `ngn`, `kes`, `egp`, `aed`, `sar`, `qar`, `omr`, `kwd`, `bhd`, `jod`, `ils`, `lbp`, `jmd`, `tt`, `ttd`, `bsd`, `gyd`, `htg`, `npr`, `lkr`, `mvr`, `mur`, `scr`, `xaf`, `xof`, `xpf`, `xdr`, `xag`, `xau`, `bits`, `sats`. + +##### `symbol-link` +The link to go to when clicking on the symbol. Defaults to the CoinGecko page for the cryptocurrency. + +##### `days` +Number of days to show in the chart. + +##### `symbol` +The symbol of the cryptocurrency. + +##### `name` +The name of the cryptocurrency. + +##### `id` +The ID of the cryptocurrency as seen on CoinGecko. + ### Twitch Channels Display a list of channels from Twitch. diff --git a/docs/images/crypto-widget-preview.png b/docs/images/crypto-widget-preview.png new file mode 100644 index 00000000..4128f0cf Binary files /dev/null and b/docs/images/crypto-widget-preview.png differ diff --git a/internal/assets/templates.go b/internal/assets/templates.go index 53ae871f..d73dd9c3 100644 --- a/internal/assets/templates.go +++ b/internal/assets/templates.go @@ -26,6 +26,7 @@ var ( ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html") VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") VideosGridTemplate = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html") + CryptoTemplate = compileTemplate("crypto.html", "widget-base.html") MarketsTemplate = compileTemplate("markets.html", "widget-base.html") RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html") RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html") diff --git a/internal/assets/templates/crypto.html b/internal/assets/templates/crypto.html new file mode 100644 index 00000000..9a1b66aa --- /dev/null +++ b/internal/assets/templates/crypto.html @@ -0,0 +1,39 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +{{ if ne .Style "dynamic-columns-experimental" }} + +{{ else }} +
+ {{ range .Cryptos }} +
+ {{ template "crypto" . }} +
+ {{ end }} +
+{{ end }} +{{ end }} + +{{ define "crypto" }} +
+ {{ .Symbol }} +
{{ .Name }}
+
+ + + + + + + +
+
{{ printf "%+.2f" .PercentChange }}%
+
{{ .Currency }}{{ .Price | formatPrice }}
+
+{{ end }} diff --git a/internal/feed/coingecko.go b/internal/feed/coingecko.go new file mode 100644 index 00000000..162c3973 --- /dev/null +++ b/internal/feed/coingecko.go @@ -0,0 +1,99 @@ +package feed + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "sync" +) + +type coingeckoMarketChartResponse struct { + Prices [][]float64 `json:"prices"` + MarketCaps [][]float64 `json:"market_caps"` + TotalVolumes [][]float64 `json:"total_volumes"` +} + +func fetchSingle(crypto *Cryptocurrency, days int) error { + if crypto.Days == 0 { + crypto.Days = days + } + + if crypto.Currency == "" { + crypto.Currency = "usd" + } + + if crypto.SymbolLink == "" { + crypto.SymbolLink = fmt.Sprintf("https://www.coingecko.com/en/coins/%s", crypto.ID) + } + + reqURL := fmt.Sprintf("https://api.coingecko.com/api/v3/coins/%s/market_chart?vs_currency=%s&days=%d", crypto.ID, crypto.Currency, crypto.Days) + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return err + } + // perform request + resp, err := defaultClient.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code %d for %s", resp.StatusCode, reqURL) + } + + var response coingeckoMarketChartResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return err + } + + if len(response.Prices) == 0 { + return fmt.Errorf("no data in response for %s", reqURL) + } + + crypto.Price = response.Prices[len(response.Prices)-1][1] + + // calculate the percent change + crypto.PercentChange = percentChange(crypto.Price, response.Prices[0][1]) + + var prices []float64 + for _, price := range response.Prices { + prices = append(prices, price[1]) + } + + crypto.SvgChartPoints = SvgPolylineCoordsFromYValues(100, 50, maybeCopySliceWithoutZeroValues(prices)) + + // finally convert the currency into the proper symbol + crypto.Currency = currencyCodeToSymbol(crypto.Currency) + return nil +} + +func FetchCryptoDataFromCoinGecko(cryptos Cryptocurrencies, days int) (Cryptocurrencies, error) { + // truncate down to 30 cryptos + // this is to prevent the rate limit from being hit + // as the free tier of the CoinGecko API only allows 30 requests per minute + + if len(cryptos) > 30 { + cryptos = cryptos[:30] + } + + var wg sync.WaitGroup + wg.Add(len(cryptos)) + + for i := range cryptos { + go func(crypto *Cryptocurrency) { + defer wg.Done() + err := fetchSingle(crypto, days) + if err != nil { + slog.Error("Failed to fetch crypto data", "error", err) + } + }(&cryptos[i]) + } + + wg.Wait() + + return cryptos, nil +} diff --git a/internal/feed/primitives.go b/internal/feed/primitives.go index 6e0c98f3..c892b494 100644 --- a/internal/feed/primitives.go +++ b/internal/feed/primitives.go @@ -214,3 +214,23 @@ func (v Videos) SortByNewest() Videos { return v } + +type Cryptocurrency struct { + Name string `yaml:"name"` + ID string `yaml:"id"` // see https://api.coingecko.com/api/v3/coins/list for a list of ids + Currency string `yaml:"currency"` // Currency is the currency to convert the price to + Days int `yaml:"days"` // Days is the number of days to fetch the price for. If 0, use the stock's default of 21 days + SymbolLink string `yaml:"symbol-link"` // SymbolLink is the link to the symbol. Defaults to CoinGecko + Symbol string `yaml:"symbol"` // Symbol is for display + Price float64 `yaml:"-"` + PercentChange float64 `yaml:"-"` + SvgChartPoints string `yaml:"-"` +} + +type Cryptocurrencies []Cryptocurrency + +func (t Cryptocurrencies) SortByPercentChange() { + sort.Slice(t, func(i, j int) bool { + return t[i].PercentChange > t[j].PercentChange + }) +} diff --git a/internal/feed/utils.go b/internal/feed/utils.go index 16c376be..74415119 100644 --- a/internal/feed/utils.go +++ b/internal/feed/utils.go @@ -79,6 +79,78 @@ func maybeCopySliceWithoutZeroValues[T int | float64](values []T) []T { return values } +func currencyCodeToSymbol(code string) string { + currencySymbols := map[string]string{ + "btc": "₿", + "eth": "Ξ", + "ltc": "Ł", + "bch": "₡", + "bnb": "Ƀ", + "eos": "", // no symbol + "xrp": "", // no symbol + "xlm": "", // no symbol + "link": "", // no symbol + "dot": "", // no symbol + "yfi": "", // no symbol + "usd": "$", + "aed": "د.إ", + "ars": "$", + "aud": "AU$", + "bdt": "৳", + "bhd": ".د.ب", + "bmd": "$", + "brl": "R$", + "cad": "CA$", + "chf": "CHF", + "clp": "$", + "cny": "¥", + "czk": "Kč", + "dkk": "kr", + "eur": "€", + "gbp": "£", + "gel": "ლ", + "hkd": "HK$", + "huf": "Ft", + "idr": "Rp", + "ils": "₪", + "inr": "₹", + "jpy": "¥", + "krw": "₩", + "kwd": "د.ك", + "lkr": "Rs", + "mmk": "K", + "mxn": "$", + "myr": "RM", + "ngn": "₦", + "nok": "kr", + "nzd": "NZ$", + "php": "₱", + "pkr": "Rs", + "pln": "zł", + "rub": "₽", + "sar": "ر.س", + "sek": "kr", + "sgd": "SGD", + "thb": "฿", + "try": "₺", + "twd": "NT$", + "uah": "₴", + "vef": "Bs", + "vnd": "₫", + "zar": "R", + "xdr": "", // no symbol + "xag": "", // no symbol + "xau": "", // no symbol + "bits": "", // no symbol + "sats": "", // no symbol + } + + symbol, ok := currencySymbols[code] + if !ok { + symbol = strings.ToUpper(code) + " " + } + return symbol +} var urlSchemePattern = regexp.MustCompile(`^[a-z]+:\/\/`) diff --git a/internal/widget/crypto.go b/internal/widget/crypto.go new file mode 100644 index 00000000..1066cf64 --- /dev/null +++ b/internal/widget/crypto.go @@ -0,0 +1,46 @@ +package widget + +import ( + "context" + "html/template" + "time" + + "github.com/glanceapp/glance/internal/assets" + "github.com/glanceapp/glance/internal/feed" +) + +type Crypto struct { + widgetBase `yaml:",inline"` + Cryptos feed.Cryptocurrencies `yaml:"cryptos"` + Sort string `yaml:"sort-by"` + Style string `yaml:"style"` + Days int `yaml:"days"` +} + +func (widget *Crypto) Initialize() error { + widget.withTitle("Crypto").withCacheDuration(time.Hour) + + if widget.Days == 0 { + widget.Days = 1 + } + + return nil +} + +func (widget *Crypto) Update(ctx context.Context) { + cryptos, err := feed.FetchCryptoDataFromCoinGecko(widget.Cryptos, widget.Days) + + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + if widget.Sort == "percent-change" { + cryptos.SortByPercentChange() + } + + widget.Cryptos = cryptos +} + +func (widget *Crypto) Render() template.HTML { + return widget.render(widget, assets.CryptoTemplate) +} diff --git a/internal/widget/widget.go b/internal/widget/widget.go index 0ccb3de4..4290ef51 100644 --- a/internal/widget/widget.go +++ b/internal/widget/widget.go @@ -35,6 +35,8 @@ func New(widgetType string) (Widget, error) { return &Releases{}, nil case "videos": return &Videos{}, nil + case "crypto": + return &Crypto{}, nil case "markets", "stocks": return &Markets{}, nil case "reddit":