Sfoglia il codice sorgente

Add initial crypto widget

chand1012 1 anno fa
parent
commit
e6272e35ee

+ 1 - 0
internal/assets/templates.go

@@ -25,6 +25,7 @@ var (
 	VideosTemplate                = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
 	VideosGridTemplate            = compileTemplate("videos-grid.html", "widget-base.html", "video-card-contents.html")
 	StocksTemplate                = compileTemplate("stocks.html", "widget-base.html")
+	CryptoTemplate                = compileTemplate("crypto.html", "widget-base.html")
 	RSSListTemplate               = compileTemplate("rss-list.html", "widget-base.html")
 	RSSHorizontalCardsTemplate    = compileTemplate("rss-horizontal-cards.html", "widget-base.html")
 	RSSHorizontalCards2Template   = compileTemplate("rss-horizontal-cards-2.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

@@ -209,3 +209,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
+	})
+}

+ 73 - 0
internal/feed/utils.go

@@ -77,3 +77,76 @@ 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
+}

+ 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

@@ -33,6 +33,8 @@ func New(widgetType string) (Widget, error) {
 		return &Videos{}, nil
 	case "stocks":
 		return &Stocks{}, nil
+	case "crypto":
+		return &Crypto{}, nil
 	case "reddit":
 		return &Reddit{}, nil
 	case "rss":