Add initial crypto widget

This commit is contained in:
chand1012 2024-05-15 21:02:44 -04:00
parent 7743664527
commit e6272e35ee
No known key found for this signature in database
GPG key ID: CCCFE98EEFB1BDD0
7 changed files with 280 additions and 0 deletions

View file

@ -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")

View file

@ -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 }}

View file

@ -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
}

View file

@ -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
})
}

View file

@ -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
internal/widget/crypto.go Normal file
View file

@ -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)
}

View file

@ -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":