This commit is contained in:
Chandler 2024-08-30 19:01:34 +00:00 committed by GitHub
commit a699d66b72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 346 additions and 0 deletions

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

View file

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

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

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

View file

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

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