Browse Source

Merge 662dbb9f3aacd9d91aec0f9133a88d757b6e7b88 into 7ce87c7168f20bb8662cbdcbaa16384e074beb19

Chandler 10 months ago
parent
commit
a699d66b72

+ 1 - 0
README.md

@@ -16,6 +16,7 @@
 * Clock
 * Calendar
 * Stocks
+* Cryptocurrencies
 * iframe
 * Twitch channels & top games
 * GitHub releases

+ 66 - 0
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.
 

BIN
docs/images/crypto-widget-preview.png


+ 1 - 0
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")

+ 39 - 0
internal/assets/templates/crypto.html

@@ -0,0 +1,39 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+{{ if ne .Style "dynamic-columns-experimental" }}
+<ul class="list list-gap-20 list-with-separator">
+    {{ range .Cryptos }}
+    <li class="flex items-center gap-15">
+        {{ template "crypto" . }}
+    </li>
+    {{ end }}
+</ul>
+{{ else }}
+<div class="dynamic-columns">
+    {{ range .Cryptos }}
+    <div class="flex items-center gap-15">
+        {{ template "crypto" . }}
+    </div>
+    {{ end }}
+</div>
+{{ end }}
+{{ end }}
+
+{{ define "crypto" }}
+<div class="shrink min-width-0">
+    <a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a>
+    <div class="text-truncate">{{ .Name }}</div>
+</div>
+
+<a class="stock-chart" {{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }}>
+    <svg class="stock-chart shrink-0" viewBox="0 0 100 50">
+        <polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
+    </svg>
+</a>
+
+<div class="stock-values shrink-0">
+    <div class="size-h3 text-right {{ if eq .PercentChange 0.0 }}{{ else if gt .PercentChange 0.0 }}color-positive{{ else }}color-negative{{ end }}">{{ printf "%+.2f" .PercentChange }}%</div>
+    <div class="text-right">{{ .Currency }}{{ .Price | formatPrice }}</div>
+</div>
+{{ end }}

+ 99 - 0
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
+}

+ 20 - 0
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
+	})
+}

+ 72 - 0
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]+:\/\/`)
 

+ 46 - 0
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)
+}

+ 2 - 0
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":