Rename Stocks to Markets

Also fix bug that would remove markets if a network request failed
and not show them again until Glance was restarted
This commit is contained in:
Svilen Markov 2024-05-28 18:01:26 +01:00
parent d2f5dbbc26
commit 1bebb88d0e
9 changed files with 74 additions and 68 deletions

View File

@ -20,7 +20,7 @@
- [Bookmarks](#bookmarks) - [Bookmarks](#bookmarks)
- [Calendar](#calendar) - [Calendar](#calendar)
- [Clock](#clock) - [Clock](#clock)
- [Stocks](#stocks) - [Markets](#markets)
- [Twitch Channels](#twitch-channels) - [Twitch Channels](#twitch-channels)
- [Twitch Top Games](#twitch-top-games) - [Twitch Top Games](#twitch-top-games)
- [iframe](#iframe) - [iframe](#iframe)
@ -80,8 +80,8 @@ pages:
- type: weather - type: weather
location: London, United Kingdom location: London, United Kingdom
- type: stocks - type: markets
stocks: markets:
- symbol: SPY - symbol: SPY
name: S&P 500 name: S&P 500
- symbol: BTC-USD - symbol: BTC-USD
@ -1146,14 +1146,14 @@ Preview:
> >
> There is currently no customizability available for the calendar. Extra features will be added in the future. > There is currently no customizability available for the calendar. Extra features will be added in the future.
### Stocks ### Markets
Display a list of stocks, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance. Display a list of markets, their current value, change for the day and a small 21d chart. Data is taken from Yahoo Finance.
Example: Example:
```yaml ```yaml
- type: stocks - type: markets
stocks: markets:
- symbol: SPY - symbol: SPY
name: S&P 500 name: S&P 500
- symbol: BTC-USD - symbol: BTC-USD
@ -1168,21 +1168,21 @@ Example:
Preview: Preview:
![](images/stocks-widget-preview.png) ![](images/markets-widget-preview.png)
#### Properties #### Properties
| Name | Type | Required | | Name | Type | Required |
| ---- | ---- | -------- | | ---- | ---- | -------- |
| stocks | array | yes | | markets | array | yes |
| sort-by | string | no | | sort-by | string | no |
| style | string | no | | style | string | no |
##### `stocks` ##### `markets`
An array of stocks for which to display information about. An array of markets for which to display information about.
##### `sort-by` ##### `sort-by`
By default the stocks are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change. By default the markets are displayed in the order they were defined. You can customize their ordering by setting the `sort-by` property to `absolute-change` for descending order based on the stock's absolute price change.
##### `style` ##### `style`
To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option. To make the widget scale appropriately in a `full` size column, set the style to the experimental `dynamic-columns-experimental` option.

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

@ -622,16 +622,16 @@ kbd:active {
color: var(--color-text-highlight); color: var(--color-text-highlight);
} }
.stock-chart { .market-chart {
margin-left: auto; margin-left: auto;
width: 6.5rem; width: 6.5rem;
} }
.stock-chart svg { .market-chart svg {
width: 100%; width: 100%;
} }
.stock-values { .market-values {
min-width: 8rem; min-width: 8rem;
} }

View File

@ -26,7 +26,7 @@ var (
ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html") ChangeDetectionTemplate = compileTemplate("change-detection.html", "widget-base.html")
VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html") VideosTemplate = compileTemplate("videos.html", "widget-base.html", "video-card-contents.html")
VideosGridTemplate = compileTemplate("videos-grid.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") MarketsTemplate = compileTemplate("markets.html", "widget-base.html")
RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html") RSSListTemplate = compileTemplate("rss-list.html", "widget-base.html")
RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html") RSSDetailedListTemplate = compileTemplate("rss-detailed-list.html", "widget-base.html")
RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html") RSSHorizontalCardsTemplate = compileTemplate("rss-horizontal-cards.html", "widget-base.html")

View File

@ -3,31 +3,31 @@
{{ define "widget-content" }} {{ define "widget-content" }}
{{ if ne .Style "dynamic-columns-experimental" }} {{ if ne .Style "dynamic-columns-experimental" }}
<ul class="list list-gap-20 list-with-separator"> <ul class="list list-gap-20 list-with-separator">
{{ range .Stocks }} {{ range .Markets }}
<li class="flex items-center gap-15"> <li class="flex items-center gap-15">
{{ template "stock" . }} {{ template "market" . }}
</li> </li>
{{ end }} {{ end }}
</ul> </ul>
{{ else }} {{ else }}
<div class="dynamic-columns"> <div class="dynamic-columns">
{{ range .Stocks }} {{ range .Markets }}
<div class="flex items-center gap-15"> <div class="flex items-center gap-15">
{{ template "stock" . }} {{ template "market" . }}
</div> </div>
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}
{{ end }} {{ end }}
{{ define "stock" }} {{ define "market" }}
<div class="min-width-0"> <div class="min-width-0">
<a{{ if ne "" .SymbolLink }} href="{{ .SymbolLink }}" target="_blank" rel="noreferrer"{{ end }} class="color-highlight size-h3 block text-truncate">{{ .Symbol }}</a> <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 class="text-truncate">{{ .Name }}</div>
</div> </div>
<a class="stock-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}> <a class="market-chart" {{ if ne "" .ChartLink }} href="{{ .ChartLink }}" target="_blank" rel="noreferrer"{{ end }}>
<svg class="stock-chart shrink-0" viewBox="0 0 100 50"> <svg class="market-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> <polyline fill="none" stroke="var(--color-text-subdue)" stroke-width="1.5px" points="{{ .SvgChartPoints }}" vector-effect="non-scaling-stroke"></polyline>
</svg> </svg>
</a> </a>

View File

@ -85,20 +85,24 @@ var currencyToSymbol = map[string]string{
"PHP": "₱", "PHP": "₱",
} }
type Stock struct { type MarketRequest struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Symbol string `yaml:"symbol"` Symbol string `yaml:"symbol"`
ChartLink string `yaml:"chart-link"` ChartLink string `yaml:"chart-link"`
SymbolLink string `yaml:"symbol-link"` SymbolLink string `yaml:"symbol-link"`
}
type Market struct {
MarketRequest
Currency string `yaml:"-"` Currency string `yaml:"-"`
Price float64 `yaml:"-"` Price float64 `yaml:"-"`
PercentChange float64 `yaml:"-"` PercentChange float64 `yaml:"-"`
SvgChartPoints string `yaml:"-"` SvgChartPoints string `yaml:"-"`
} }
type Stocks []Stock type Markets []Market
func (t Stocks) SortByAbsChange() { func (t Markets) SortByAbsChange() {
sort.Slice(t, func(i, j int) bool { sort.Slice(t, func(i, j int) bool {
return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange) return math.Abs(t[i].PercentChange) > math.Abs(t[j].PercentChange)
}) })

View File

@ -6,7 +6,7 @@ import (
"net/http" "net/http"
) )
type stockResponseJson struct { type marketResponseJson struct {
Chart struct { Chart struct {
Result []struct { Result []struct {
Meta struct { Meta struct {
@ -25,30 +25,30 @@ type stockResponseJson struct {
} }
// TODO: allow changing chart time frame // TODO: allow changing chart time frame
const stockChartDays = 21 const marketChartDays = 21
func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) { func FetchMarketsDataFromYahoo(marketRequests []MarketRequest) (Markets, error) {
requests := make([]*http.Request, 0, len(stockRequests)) requests := make([]*http.Request, 0, len(marketRequests))
for i := range stockRequests { for i := range marketRequests {
request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", stockRequests[i].Symbol), nil) request, _ := http.NewRequest("GET", fmt.Sprintf("https://query1.finance.yahoo.com/v8/finance/chart/%s?range=1mo&interval=1d", marketRequests[i].Symbol), nil)
requests = append(requests, request) requests = append(requests, request)
} }
job := newJob(decodeJsonFromRequestTask[stockResponseJson](defaultClient), requests) job := newJob(decodeJsonFromRequestTask[marketResponseJson](defaultClient), requests)
responses, errs, err := workerPoolDo(job) responses, errs, err := workerPoolDo(job)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNoContent, err) return nil, fmt.Errorf("%w: %v", ErrNoContent, err)
} }
stocks := make(Stocks, 0, len(responses)) markets := make(Markets, 0, len(responses))
var failed int var failed int
for i := range responses { for i := range responses {
if errs[i] != nil { if errs[i] != nil {
failed++ failed++
slog.Error("Failed to fetch stock data", "symbol", stockRequests[i].Symbol, "error", errs[i]) slog.Error("Failed to fetch market data", "symbol", marketRequests[i].Symbol, "error", errs[i])
continue continue
} }
@ -56,14 +56,14 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
if len(response.Chart.Result) == 0 { if len(response.Chart.Result) == 0 {
failed++ failed++
slog.Error("Stock response contains no data", "symbol", stockRequests[i].Symbol) slog.Error("Market response contains no data", "symbol", marketRequests[i].Symbol)
continue continue
} }
prices := response.Chart.Result[0].Indicators.Quote[0].Close prices := response.Chart.Result[0].Indicators.Quote[0].Close
if len(prices) > stockChartDays { if len(prices) > marketChartDays {
prices = prices[len(prices)-stockChartDays:] prices = prices[len(prices)-marketChartDays:]
} }
previous := response.Chart.Result[0].Meta.RegularMarketPrice previous := response.Chart.Result[0].Meta.RegularMarketPrice
@ -80,13 +80,10 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
currency = response.Chart.Result[0].Meta.Currency currency = response.Chart.Result[0].Meta.Currency
} }
stocks = append(stocks, Stock{ markets = append(markets, Market{
Name: stockRequests[i].Name, MarketRequest: marketRequests[i],
Symbol: response.Chart.Result[0].Meta.Symbol, Price: response.Chart.Result[0].Meta.RegularMarketPrice,
SymbolLink: stockRequests[i].SymbolLink, Currency: currency,
ChartLink: stockRequests[i].ChartLink,
Price: response.Chart.Result[0].Meta.RegularMarketPrice,
Currency: currency,
PercentChange: percentChange( PercentChange: percentChange(
response.Chart.Result[0].Meta.RegularMarketPrice, response.Chart.Result[0].Meta.RegularMarketPrice,
previous, previous,
@ -95,13 +92,13 @@ func FetchStocksDataFromYahoo(stockRequests Stocks) (Stocks, error) {
}) })
} }
if len(stocks) == 0 { if len(markets) == 0 {
return nil, ErrNoContent return nil, ErrNoContent
} }
if failed > 0 { if failed > 0 {
return stocks, fmt.Errorf("%w: could not fetch data for %d stock(s)", ErrPartialContent, failed) return markets, fmt.Errorf("%w: could not fetch data for %d market(s)", ErrPartialContent, failed)
} }
return stocks, nil return markets, nil
} }

View File

@ -9,34 +9,39 @@ import (
"github.com/glanceapp/glance/internal/feed" "github.com/glanceapp/glance/internal/feed"
) )
// TODO: rename to Markets at some point type Markets struct {
type Stocks struct { widgetBase `yaml:",inline"`
widgetBase `yaml:",inline"` StocksRequests []feed.MarketRequest `yaml:"stocks"`
Stocks feed.Stocks `yaml:"stocks"` MarketRequests []feed.MarketRequest `yaml:"markets"`
Sort string `yaml:"sort-by"` Sort string `yaml:"sort-by"`
Style string `yaml:"style"` Style string `yaml:"style"`
Markets feed.Markets `yaml:"-"`
} }
func (widget *Stocks) Initialize() error { func (widget *Markets) Initialize() error {
widget.withTitle("Stocks").withCacheDuration(time.Hour) widget.withTitle("Markets").withCacheDuration(time.Hour)
if len(widget.MarketRequests) == 0 {
widget.MarketRequests = widget.StocksRequests
}
return nil return nil
} }
func (widget *Stocks) Update(ctx context.Context) { func (widget *Markets) Update(ctx context.Context) {
stocks, err := feed.FetchStocksDataFromYahoo(widget.Stocks) markets, err := feed.FetchMarketsDataFromYahoo(widget.MarketRequests)
if !widget.canContinueUpdateAfterHandlingErr(err) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
} }
if widget.Sort == "absolute-change" { if widget.Sort == "absolute-change" {
stocks.SortByAbsChange() markets.SortByAbsChange()
} }
widget.Stocks = stocks widget.Markets = markets
} }
func (widget *Stocks) Render() template.HTML { func (widget *Markets) Render() template.HTML {
return widget.render(widget, assets.StocksTemplate) return widget.render(widget, assets.MarketsTemplate)
} }

View File

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