Compare commits

..

No commits in common. "master" and "v0.0.5" have entirely different histories.

21 changed files with 191 additions and 346 deletions

View File

@ -17,12 +17,12 @@ jobs:
name: Set up Go name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: 1.23.1 go-version: 1.22.1
- -
name: Run linter name: Run linter
uses: golangci/golangci-lint-action@v3 uses: golangci/golangci-lint-action@v3
with: with:
version: v1.61 version: v1.56
- -
name: Run tests name: Run tests
run: go test ./... run: go test ./...
@ -31,7 +31,7 @@ jobs:
uses: goreleaser/goreleaser-action@v5 uses: goreleaser/goreleaser-action@v5
with: with:
distribution: goreleaser distribution: goreleaser
version: 2.2.0 version: 1.24.0
args: release --clean --snapshot args: release --clean --snapshot
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@ -17,13 +17,13 @@ jobs:
name: Set up Go name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: 1.23.1 go-version: 1.22.1
- -
name: Run GoReleaser name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5 uses: goreleaser/goreleaser-action@v5
with: with:
distribution: goreleaser distribution: goreleaser
version: 2.2.0 version: 1.24.0
args: release --clean args: release --clean
env: env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@ -1,15 +0,0 @@
linters:
disable-all: true
enable:
- errcheck
- godot
- goimports
- gosimple
- govet
- ineffassign
- nilerr
- nilnil
- staticcheck
- typecheck
- unused
- usestdlibvars

View File

@ -6,7 +6,7 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj # vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2 version: 1
before: before:
hooks: hooks:

1
.idea/.gitignore vendored
View File

@ -1 +0,0 @@
workspace.xml

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/moonmath.iml" filepath="$PROJECT_DIR$/.idea/moonmath.iml" />
</modules>
</component>
</project>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

20
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch",
"type": "go",
"request": "launch",
"mode": "debug",
"console": "integratedTerminal",
"program": "${workspaceFolder}/moonmath.go",
"args": [
"--config-file",
"moonmath.toml"
]
}
]
}

18
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"cSpell.words": [
"alecthomas",
"bitcoinity",
"Bitfinex",
"Bitstamp",
"bubbletea",
"CDPR",
"charmbracelet",
"Coindesk",
"knadh",
"koanf",
"lipgloss",
"moonmath",
"OHLC",
"sourcegraph"
]
}

View File

@ -1,6 +1,5 @@
package coindesk package coindesk
//goland:noinspection ALL
const ( const (
BTC Asset = "BTC" BTC Asset = "BTC"
ETH Asset = "ETH" ETH Asset = "ETH"

View File

@ -16,58 +16,62 @@ import (
var k = koanf.New(".") var k = koanf.New(".")
func Load(filePath string) (r Root, err error) { func Load(path string) (all All, err error) {
err = k.Load(structs.Provider(Default, "koanf"), nil) err = k.Load(structs.Provider(Default, "koanf"), nil)
if err != nil { if err != nil {
return return
} }
if filePath != "" { if path != "" {
err = k.Load(file.Provider(filePath), yaml.Parser()) err = k.Load(file.Provider(path), yaml.Parser())
if err != nil { if err != nil {
return return
} }
} }
err = k.Unmarshal("", &r) err = k.Unmarshal("", &all)
if err != nil {
return
}
return return
} }
var Default Root var Default All
type Root struct { type All struct {
Defaults Asset `koanf:"defaults"` Defaults Data `koanf:"defaults"`
Assets map[coindesk.Asset]Asset `koanf:"assets"` Assets map[coindesk.Asset]Data `koanf:"assets"`
} }
type Asset struct { type Data struct {
Asset coindesk.Asset `koanf:"asset"` Asset coindesk.Asset `koanf:"asset"`
Goals []moon.Goal `koanf:"goals"` Goals []moon.Goal `koanf:"goals"`
ConstantBases []moon.ConstantBase `koanf:"constantBases"` ConstantBases []moon.ConstantBase `koanf:"constantBases"`
RelativeBases []moon.RelativeBase `koanf:"relativeBases"` RelativeBases []moon.RelativeBase `koanf:"relativeBases"`
} }
func (r Root) ForAsset(a coindesk.Asset) (cfg Asset) { func (all All) GetData(asset coindesk.Asset) (data Data) {
cfg = merge(r.Assets[a], r.Defaults) data, ok := all.Assets[asset]
cfg.Asset = a if !ok {
data = all.Defaults
}
if data.Asset == "" {
data.Asset = asset
}
if data.Goals == nil || len(data.Goals) == 0 {
data.Goals = all.Defaults.Goals
}
if data.ConstantBases == nil || len(data.ConstantBases) == 0 {
data.ConstantBases = all.Defaults.ConstantBases
}
if data.RelativeBases == nil || len(data.RelativeBases) == 0 {
data.RelativeBases = all.Defaults.RelativeBases
}
return return
} }
func merge(dst, src Asset) Asset {
if len(dst.Goals) == 0 {
dst.Goals = src.Goals
}
if len(dst.ConstantBases) == 0 {
dst.ConstantBases = src.ConstantBases
}
if len(dst.RelativeBases) == 0 {
dst.RelativeBases = src.RelativeBases
}
return dst
}
// GetBases returns the concatenation of the constant and relative bases, sorted // GetBases returns the concatenation of the constant and relative bases, sorted
// from most recent to least recent in time. // from most recent to least recent in time.
func GetBases(d *Asset) (bases []moon.Base) { func GetBases(d *Data) (bases []moon.Base) {
for _, b := range d.ConstantBases { for _, b := range d.ConstantBases {
bases = append(bases, b) bases = append(bases, b)
} }

View File

@ -28,12 +28,6 @@ defaults:
time: 2019-12-31T16:00:00-08:00 time: 2019-12-31T16:00:00-08:00
- name: 2017- - name: 2017-
time: 2016-12-31T16:00:00-08:00 time: 2016-12-31T16:00:00-08:00
- name: 2013-
time: 2012-12-31T16:00:00-08:00
startingPrice: 13.30
- name: 2011-
time: 2010-12-31T16:00:00-08:00
startingPrice: 0.30
assets: assets:
ETH: ETH:
@ -50,11 +44,6 @@ assets:
value: 20000 value: 20000
- name: $25k - name: $25k
value: 25000 value: 25000
constantBases:
- name: 2020-
time: 2019-12-31T16:00:00-08:00
- name: 2017-
time: 2016-12-31T16:00:00-08:00
LTC: LTC:
goals: goals:
- name: $100 - name: $100

3
go.mod
View File

@ -36,7 +36,8 @@ require (
github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/sync v0.6.0 // indirect golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.18.0 // indirect golang.org/x/sys v0.18.0 // indirect
golang.org/x/term v0.18.0 // indirect golang.org/x/term v0.18.0 // indirect

9
go.sum
View File

@ -17,6 +17,7 @@ github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy12
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro= github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
@ -74,10 +75,14 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=

View File

@ -2,7 +2,6 @@ package moon
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math" "math"
"time" "time"
@ -76,10 +75,30 @@ func (m *Math) Refresh(ctx context.Context) (err error) {
tasks.WithMaxGoroutines(len(m.Columns)) tasks.WithMaxGoroutines(len(m.Columns))
//tasks.WithMaxGoroutines(1) //tasks.WithMaxGoroutines(1)
now := time.Now() now := time.Now()
for i := range m.Columns { for i := range m.Columns {
c := &m.Columns[i] c := &m.Columns[i]
tasks.Go(func() error { tasks.Go(func() error {
return c.project(ctx, m, now) c.StartingDate = c.Base.From(now)
nextDay := c.StartingDate.Add(time.Hour * 24)
resp, err := coindesk.GetPriceValues(ctx,
m.Asset, c.StartingDate, nextDay)
if err != nil {
return err
}
if len(resp.Data.Entries) == 0 {
c.Projections.Dates = nil
return nil
}
c.StartingPrice = resp.Data.Entries[0].Price
c.Gain = float64(m.CurrentPrice) / float64(c.StartingPrice)
days := now.Sub(c.StartingDate).Hours() / 24
c.CDPR = CDPR(days, c.Gain)
c.Projections = ProjectDates(
now, float64(m.CurrentPrice),
c.CDPR, m.Goals,
)
return nil
}) })
} }
err = tasks.Wait() err = tasks.Wait()
@ -96,49 +115,46 @@ type Column struct {
Projections Projection Projections Projection
} }
func (c *Column) project(ctx context.Context, m *Math, now time.Time) (err error) { func (c *Column) Column() (entries []string) {
err = c.fillStartingPrice(ctx, m.Asset, now) entries = append(entries, fmt.Sprintf("$%.2f", c.StartingPrice))
if err != nil { entries = append(entries, fmt.Sprintf("%.2f%%", (c.CDPR-1)*100))
return never := c.CDPR <= 1
for i := range c.Projections.Dates {
var cell string
if never {
cell = "NEVER!!!!!"
} else {
cell = c.
Projections.
Dates[i].
Format("2006-01-02")
}
entries = append(entries, cell)
} }
c.Gain = float64(m.CurrentPrice) / float64(c.StartingPrice)
days := now.Sub(c.StartingDate).Hours() / 24
c.CDPR = CDPR(days, c.Gain)
c.Projections = ProjectDates(
now, float64(m.CurrentPrice),
c.CDPR, m.Goals,
)
return return
} }
func (c *Column) fillStartingPrice( var DefaultGoals = []Goal{
ctx context.Context, asset coindesk.Asset, now time.Time, {"$100k", 100000},
) error { {"$150k", 150000},
// if base provides a hardcoded starting price, use it {"$200k", 200000},
c.StartingDate = c.Base.From(now) {"$250k", 250000},
c.StartingPrice = coindesk.Price(c.Base.GetStartingPrice()) {"$300k", 300000},
if c.StartingPrice != 0 { {"$500k", 500000},
return nil {"$1m", 1000000},
}
// otherwise, look up the starting price via Coindesk
nextDay := c.StartingDate.Add(time.Hour * 24)
resp, err := coindesk.GetPriceValues(ctx, asset, c.StartingDate, nextDay)
if err != nil {
err = fmt.Errorf("getting price for %s on %v: %w",
asset, c.StartingDate, err)
return err
}
if len(resp.Data.Entries) == 0 {
c.Projections.Dates = nil
return errEmptyPriceEntries
}
c.StartingPrice = resp.Data.Entries[0].Price
return nil
} }
var errEmptyPriceEntries = errors.New("price values response has no entries") var DefaultConstantBases = []ConstantBase{
{"2020-", time.Unix(1577836800, 0)},
{"2017-", time.Unix(1483228800, 0)},
}
var DefaultRelativeBases = []RelativeBase{
{"Month", time.Duration(-30) * time.Hour * 24},
{"Quarter", time.Duration(-90) * time.Hour * 24},
{"Half-Year", time.Duration(-182) * time.Hour * 24},
{"Year", time.Duration(-365) * time.Hour * 24},
}
type Goal struct { type Goal struct {
Name string `koanf:"name"` Name string `koanf:"name"`
@ -149,14 +165,12 @@ type Goal struct {
type Base interface { type Base interface {
From(now time.Time) time.Time From(now time.Time) time.Time
Label() string Label() string
GetStartingPrice() float64
} }
// ConstantBase is a base that is a constant time, e.g. 2020-01-01. // ConstantBase is a base that is a constant time, e.g. 2020-01-01.
type ConstantBase struct { type ConstantBase struct {
Name string `koanf:"name"` Name string `koanf:"name"`
Time time.Time `koanf:"time"` Time time.Time `koanf:"time"`
StartingPrice float64 `koanf:"startingPrice"`
} }
func (cb ConstantBase) From(_ time.Time) time.Time { func (cb ConstantBase) From(_ time.Time) time.Time {
@ -167,25 +181,17 @@ func (cb ConstantBase) Label() string {
return cb.Name return cb.Name
} }
func (cb ConstantBase) GetStartingPrice() float64 { // RelativeBase is a base that is relative, e.g. "90 days ago."
return cb.StartingPrice
}
// RelativeBase is a base that is relative, e.g. "90 days ago".
type RelativeBase struct { type RelativeBase struct {
Name string `koanf:"name"` Name string `koanf:"name"`
Offset time.Duration `koanf:"offset"` Offset time.Duration `koanf:"offset"`
} }
func (rb RelativeBase) From(now time.Time) time.Time { func (rb RelativeBase) From(now time.Time) time.Time {
then := now.Add(rb.Offset) then := now.Add(time.Duration(rb.Offset))
return then return then
} }
func (rb RelativeBase) Label() string { func (rb RelativeBase) Label() string {
return rb.Name return rb.Name
} }
func (rb RelativeBase) GetStartingPrice() float64 {
return 0
}

View File

@ -11,14 +11,6 @@ func TestCDPR(t *testing.T) {
} }
func TestProjection(t *testing.T) { func TestProjection(t *testing.T) {
p := moon.ProjectDates(time.Now(), 68900, 1.0055, []moon.Goal{ p := moon.ProjectDates(time.Now(), 68900, 1.0055, moon.DefaultGoals)
{"$100k", 100000},
{"$150k", 150000},
{"$200k", 200000},
{"$250k", 250000},
{"$300k", 300000},
{"$500k", 500000},
{"$1m", 1000000},
})
_ = p _ = p
} }

View File

@ -2,30 +2,21 @@ package main
import ( import (
"fmt" "fmt"
"io"
"log/slog"
"os" "os"
"path/filepath"
"strings" "strings"
"code.humancabbage.net/sam/moonmath/coindesk" "code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config" "code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/tui" "code.humancabbage.net/sam/moonmath/tui"
"github.com/alecthomas/kong" "github.com/alecthomas/kong"
tea "github.com/charmbracelet/bubbletea"
) )
var CLI struct { var CLI struct {
Asset []string `short:"a" default:"BTC" help:"Asset(s) to project."` Asset []string `short:"a" default:"BTC" help:"Asset(s) to project."`
ConfigFile string `short:"c" help:"Path to YAML configuration file."` ConfigFile string `short:"c" help:"Path to YAML configuration file."`
Perf bool `help:"Display internal performance stats."`
} }
func main() { func main() {
logFile := setupLogging()
defer func() {
_ = logFile.Close()
}()
ctx := kong.Parse(&CLI) ctx := kong.Parse(&CLI)
if ctx.Error != nil { if ctx.Error != nil {
fail(ctx.Error) fail(ctx.Error)
@ -39,45 +30,12 @@ func main() {
asset := coindesk.Asset(strings.ToUpper(CLI.Asset[i])) asset := coindesk.Asset(strings.ToUpper(CLI.Asset[i]))
assets = append(assets, asset) assets = append(assets, asset)
} }
m := tui.New(assets, allCfg, CLI.Perf) p := tui.New(assets, allCfg)
p := tea.NewProgram(m,
tea.WithAltScreen(),
tea.WithFPS(30),
)
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fail(err) fail(err)
} }
} }
func setupLogging() io.Closer {
homePath, err := os.UserHomeDir()
if err != nil {
panic(err)
}
programConfigPath := filepath.Join(homePath, ".moonmath")
err = os.MkdirAll(programConfigPath, 0755)
if err != nil {
panic(err)
}
errLogPath := filepath.Join(programConfigPath, "errors.log")
errLogFile, err := os.OpenFile(
errLogPath,
os.O_CREATE|os.O_APPEND|os.O_WRONLY,
0600,
)
if err != nil {
panic(err)
}
slog.SetDefault(slog.New(
slog.NewTextHandler(errLogFile, &slog.HandlerOptions{
Level: slog.LevelError,
})))
return errLogFile
}
func fail(err error) { func fail(err error) {
fmt.Printf("program error: %v\n", err) fmt.Printf("program error: %v\n", err)
os.Exit(1) os.Exit(1)

View File

@ -3,7 +3,6 @@ package asset
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"time" "time"
"code.humancabbage.net/sam/moonmath/coindesk" "code.humancabbage.net/sam/moonmath/coindesk"
@ -30,7 +29,7 @@ type Msg struct {
inner tea.Msg inner tea.Msg
} }
func New(cfg config.Asset) (m Model) { func New(cfg config.Data) (m Model) {
m.math = moon.NewMath( m.math = moon.NewMath(
cfg.Asset, cfg.Asset,
cfg.Goals, cfg.Goals,
@ -84,11 +83,16 @@ func New(cfg config.Asset) (m Model) {
m.indicator = spinner.New() m.indicator = spinner.New()
m.indicator.Spinner = spinner.Points m.indicator.Spinner = spinner.Points
m.indicator.Style = indicatorNormalStyle m.indicator.Style = lipgloss.NewStyle().
Foreground(lipgloss.Color("69"))
return return
} }
func (m Model) Handles(a coindesk.Asset) bool {
return m.math.Asset == a
}
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch( return tea.Batch(
m.indicator.Tick, m.indicator.Tick,
@ -98,41 +102,39 @@ func (m Model) Init() tea.Cmd {
) )
} }
func (m Model) Handles(a coindesk.Asset) bool {
return m.math.Asset == a
}
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case Msg: case Msg:
switch msg := msg.inner.(type) { switch msg := msg.inner.(type) {
case refresh: case refresh:
m.refreshing = true m.refreshing = true
m.indicator.Style = indicatorNormalStyle return m, func() tea.Msg {
return m, tea.Batch( // TODO: log errors
m.resumeIndicator, _ = m.math.Refresh(context.TODO())
m.refresh, return Msg{m.math.Asset, m.math}
m.scheduleRefresh(),
)
case update:
var cmd tea.Cmd
if msg.err == nil {
m.math = msg.math
refillProperties(&m)
refillProjections(&m)
cmd = m.stopIndicator()
} else {
m.indicator.Style = indicatorErrorStyle
} }
return m, cmd case moon.Math:
m.math = msg
refillProperties(&m)
refillProjections(&m)
return m, tea.Batch(
// schedule the next refresh
tea.Tick(time.Second*30,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, refresh{}}
}),
// wait a bit to stop the indicator, so that it's more obvious
// even when the refresh completes quickly.
tea.Tick(time.Millisecond*500,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, stopIndicator{}}
}),
)
case stopIndicator: case stopIndicator:
m.refreshing = false m.refreshing = false
return m, nil return m, nil
} }
case spinner.TickMsg: case spinner.TickMsg:
if !m.refreshing {
return m, nil
}
var cmd tea.Cmd var cmd tea.Cmd
m.indicator, cmd = m.indicator.Update(msg) m.indicator, cmd = m.indicator.Update(msg)
return m, cmd return m, cmd
@ -141,51 +143,8 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
} }
type refresh struct{} type refresh struct{}
type update struct {
math moon.Math
err error
}
type stopIndicator struct{} type stopIndicator struct{}
func (m Model) refresh() tea.Msg {
ctx, cancel := context.WithTimeout(
context.Background(),
refreshTimeout)
defer cancel()
err := m.math.Refresh(ctx)
if err != nil {
slog.Error("refresh",
"asset", m.math.Asset,
"err", err,
)
}
return Msg{m.math.Asset, update{m.math, err}}
}
func (m Model) resumeIndicator() tea.Msg {
return m.indicator.Tick()
}
func (m Model) scheduleRefresh() tea.Cmd {
return tea.Tick(refreshInterval,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, refresh{}}
})
}
func (m Model) stopIndicator() tea.Cmd {
// wait a bit to stop the indicator, so that it's more obvious
// even when the refresh completes quickly.
return tea.Tick(stopIndicatorDelay,
func(t time.Time) tea.Msg {
return Msg{m.math.Asset, stopIndicator{}}
})
}
var refreshInterval = time.Second * 30
var refreshTimeout = time.Second * 15
var stopIndicatorDelay = time.Millisecond * 500
func refillProperties(m *Model) { func refillProperties(m *Model) {
rows := []table.Row{ rows := []table.Row{
{"Asset", string(m.math.Asset)}, {"Asset", string(m.math.Asset)},
@ -195,34 +154,14 @@ func refillProperties(m *Model) {
} }
func refillProjections(m *Model) { func refillProjections(m *Model) {
cols := []table.Row{m.math.Labels} rows := []table.Row{m.math.Labels}
for i := range m.math.Columns { for i := range m.math.Columns {
entries := renderEntries(m.math.Columns[i]) rows = append(rows, m.math.Columns[i].Column())
cols = append(cols, entries)
} }
rows := transpose(cols) rows = transpose(rows)
m.projections.SetRows(rows) m.projections.SetRows(rows)
} }
func renderEntries(c moon.Column) (entries []string) {
entries = append(entries, fmt.Sprintf("$%.2f", c.StartingPrice))
entries = append(entries, fmt.Sprintf("%.4f%%", (c.CDPR-1)*100))
never := c.CDPR <= 1
for i := range c.Projections.Dates {
var cell string
if never {
cell = "NEVER!!!!!"
} else {
cell = c.
Projections.
Dates[i].
Format("2006-01-02")
}
entries = append(entries, cell)
}
return
}
func transpose(slice []table.Row) []table.Row { func transpose(slice []table.Row) []table.Row {
xl := len(slice[0]) xl := len(slice[0])
yl := len(slice) yl := len(slice)
@ -260,9 +199,3 @@ func (m Model) View() string {
var baseStyle = lipgloss.NewStyle(). var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()). BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240")) BorderForeground(lipgloss.Color("240"))
var indicatorNormalStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("69"))
var indicatorErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("160"))

View File

@ -1,42 +0,0 @@
package perf
import (
"fmt"
"sync/atomic"
tea "github.com/charmbracelet/bubbletea"
)
type Model struct {
updates *atomic.Int64
views *atomic.Int64
}
func New() (m Model) {
m.updates = new(atomic.Int64)
m.views = new(atomic.Int64)
return
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(_ tea.Msg) (Model, tea.Cmd) {
return m, nil
}
func (m Model) View() string {
updates := m.updates.Load()
views := m.views.Load()
s := fmt.Sprintf("updates: %d\tviews: %d", updates, views)
return s
}
func (m Model) AddUpdate() {
m.updates.Add(1)
}
func (m Model) AddView() {
m.views.Add(1)
}

View File

@ -6,27 +6,33 @@ import (
"code.humancabbage.net/sam/moonmath/coindesk" "code.humancabbage.net/sam/moonmath/coindesk"
"code.humancabbage.net/sam/moonmath/config" "code.humancabbage.net/sam/moonmath/config"
"code.humancabbage.net/sam/moonmath/tui/asset" "code.humancabbage.net/sam/moonmath/tui/asset"
"code.humancabbage.net/sam/moonmath/tui/perf"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
type Model struct { type Model struct {
assets []asset.Model assets []asset.Model
stats perf.Model
displayStats bool
} }
func New(assets []coindesk.Asset, cfg config.Root, displayStats bool) (m Model) { func New(assets []coindesk.Asset, cfg config.All) (p *tea.Program) {
m.stats = perf.New() model := newModel(assets, cfg)
m.displayStats = displayStats p = tea.NewProgram(
// construct models for each asset, but remove dupes model,
tea.WithAltScreen(),
tea.WithFPS(30),
)
return
}
func newModel(assets []coindesk.Asset, cfg config.All) (m Model) {
// construct models for each asset, but don't filter out dupes
seen := map[coindesk.Asset]struct{}{} seen := map[coindesk.Asset]struct{}{}
for _, a := range assets { for _, a := range assets {
if _, ok := seen[a]; ok { _, ok := seen[a]
if ok {
continue continue
} }
assetCfg := cfg.ForAsset(a) assetCfg := cfg.GetData(a)
assetModel := asset.New(assetCfg) assetModel := asset.New(assetCfg)
m.assets = append(m.assets, assetModel) m.assets = append(m.assets, assetModel)
seen[a] = struct{}{} seen[a] = struct{}{}
@ -46,7 +52,6 @@ func (m Model) Init() tea.Cmd {
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stats.AddUpdate()
switch msg := msg.(type) { switch msg := msg.(type) {
// handle keys for quitting // handle keys for quitting
case tea.KeyMsg: case tea.KeyMsg:
@ -73,15 +78,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
func (m Model) View() string { func (m Model) View() string {
m.stats.AddView()
var ss []string var ss []string
for i := range m.assets { for i := range m.assets {
s := m.assets[i].View() s := m.assets[i].View()
ss = append(ss, s) ss = append(ss, s)
} }
if m.displayStats {
ss = append(ss, m.stats.View())
}
r := lipgloss.JoinVertical(lipgloss.Center, ss...) r := lipgloss.JoinVertical(lipgloss.Center, ss...)
return r return r
} }