Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Cryptocurrency Widget #70

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* Clock
* Calendar
* Stocks
* Cryptocurrencies
* iframe
* Twitch channels & top games
* GitHub releases
Expand Down
66 changes: 66 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Binary file added docs/images/crypto-widget-preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions internal/assets/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
39 changes: 39 additions & 0 deletions internal/assets/templates/crypto.html
Original file line number Diff line number Diff line change
@@ -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 changes: 99 additions & 0 deletions internal/feed/coingecko.go
Original file line number Diff line number Diff line change
@@ -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 changes: 20 additions & 0 deletions internal/feed/primitives.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 72 additions & 0 deletions internal/feed/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]+:\/\/`)

Expand Down
46 changes: 46 additions & 0 deletions internal/widget/crypto.go
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions internal/widget/widget.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down