Compare commits

...

9 Commits

Author SHA1 Message Date
Sam Fredrickson eda4200585 Switch to GoLand.
Build & Test / Main (push) Successful in 1m26s Details
2024-03-31 01:07:31 -07:00
Sam Fredrickson 5c22d85e2b Add custom .golangci.yaml config.
Build & Test / Main (push) Successful in 1m0s Details
Release / Release (push) Successful in 1m35s Details
2024-03-29 19:29:29 -07:00
Sam Fredrickson f67323c5f4 Support hardcoded starting prices.
Build & Test / Main (push) Successful in 1m0s Details
The Coindesk API doesn't have data going all the way back. But since history
isn't changing, we can simply put in known prices.

Also, extend the CDPR cells to have four digits instead of just two.
2024-03-29 19:24:10 -07:00
Sam Fredrickson 4d5dcc46d2 Misc small improvements.
Build & Test / Main (push) Successful in 1m0s Details
2024-03-29 18:40:33 -07:00
Sam Fredrickson 9e6abb1112 Naming things is hard.
Build & Test / Main (push) Successful in 2m26s Details
2024-03-29 01:15:59 -07:00
Sam Fredrickson 2d991880ce Schedule refreshes more consistently.
Build & Test / Main (push) Successful in 1m1s Details
Instead of returning the `scheduleRefresh` command only after receiving
an `update` message, do it while handling the `refresh` message. For
this not to cause weird behavior, the refresh deadline should be shorter
than the refresh interval.
2024-03-24 23:01:51 -07:00
Sam Fredrickson 7b445a02a2 Refresh indicator goes red on error.
Build & Test / Main (push) Successful in 1m0s Details
Release / Release (push) Successful in 1m3s Details
It will stay flashing red until the next refresh, at which point it goes
back to its normal color. On a successful refresh, it still stops.

Also, add a deadline of 15 seconds to the refresh command.
2024-03-24 02:29:45 -07:00
Sam Fredrickson 97f4793ec3 Write errors to log file.
Build & Test / Main (push) Successful in 1m1s Details
2024-03-22 22:55:41 -07:00
Sam Fredrickson 270534c0d5 Pause spinner ticks when not refreshing.
Build & Test / Main (push) Successful in 59s Details
Also, add a quick-and-dirty model for displaying basic performance
stats, currently just the number of calls to the root Update() and
View() methods.
2024-03-22 21:14:34 -07:00
18 changed files with 339 additions and 184 deletions

15
.golangci.yaml Normal file
View File

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

1
.idea/.gitignore vendored Normal file
View File

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

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?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>

9
.idea/moonmath.iml Normal file
View File

@ -0,0 +1,9 @@
<?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>

6
.idea/vcs.xml Normal file
View File

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

20
.vscode/launch.json vendored
View File

@ -1,20 +0,0 @@
{
// 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
View File

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

View File

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

View File

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

View File

@ -28,6 +28,12 @@ defaults:
time: 2019-12-31T16:00:00-08:00
- name: 2017-
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:
ETH:
@ -44,6 +50,11 @@ assets:
value: 20000
- name: $25k
value: 25000
constantBases:
- name: 2020-
time: 2019-12-31T16:00:00-08:00
- name: 2017-
time: 2016-12-31T16:00:00-08:00
LTC:
goals:
- name: $100

3
go.mod
View File

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

9
go.sum
View File

@ -17,7 +17,6 @@ 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/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
@ -75,14 +74,10 @@ 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/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
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/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
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=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
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/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=

View File

@ -2,6 +2,7 @@ package moon
import (
"context"
"errors"
"fmt"
"math"
"time"
@ -75,30 +76,10 @@ func (m *Math) Refresh(ctx context.Context) (err error) {
tasks.WithMaxGoroutines(len(m.Columns))
//tasks.WithMaxGoroutines(1)
now := time.Now()
for i := range m.Columns {
c := &m.Columns[i]
tasks.Go(func() error {
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
return c.project(ctx, m, now)
})
}
err = tasks.Wait()
@ -115,46 +96,49 @@ type Column struct {
Projections Projection
}
func (c *Column) Column() (entries []string) {
entries = append(entries, fmt.Sprintf("$%.2f", c.StartingPrice))
entries = append(entries, fmt.Sprintf("%.2f%%", (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)
func (c *Column) project(ctx context.Context, m *Math, now time.Time) (err error) {
err = c.fillStartingPrice(ctx, m.Asset, now)
if err != nil {
return
}
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
}
var DefaultGoals = []Goal{
{"$100k", 100000},
{"$150k", 150000},
{"$200k", 200000},
{"$250k", 250000},
{"$300k", 300000},
{"$500k", 500000},
{"$1m", 1000000},
func (c *Column) fillStartingPrice(
ctx context.Context, asset coindesk.Asset, now time.Time,
) error {
// if base provides a hardcoded starting price, use it
c.StartingDate = c.Base.From(now)
c.StartingPrice = coindesk.Price(c.Base.GetStartingPrice())
if c.StartingPrice != 0 {
return nil
}
// 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 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},
}
var errEmptyPriceEntries = errors.New("price values response has no entries")
type Goal struct {
Name string `koanf:"name"`
@ -165,12 +149,14 @@ type Goal struct {
type Base interface {
From(now time.Time) time.Time
Label() string
GetStartingPrice() float64
}
// ConstantBase is a base that is a constant time, e.g. 2020-01-01.
type ConstantBase struct {
Name string `koanf:"name"`
Time time.Time `koanf:"time"`
Name string `koanf:"name"`
Time time.Time `koanf:"time"`
StartingPrice float64 `koanf:"startingPrice"`
}
func (cb ConstantBase) From(_ time.Time) time.Time {
@ -181,17 +167,25 @@ func (cb ConstantBase) Label() string {
return cb.Name
}
// RelativeBase is a base that is relative, e.g. "90 days ago."
func (cb ConstantBase) GetStartingPrice() float64 {
return cb.StartingPrice
}
// RelativeBase is a base that is relative, e.g. "90 days ago".
type RelativeBase struct {
Name string `koanf:"name"`
Offset time.Duration `koanf:"offset"`
}
func (rb RelativeBase) From(now time.Time) time.Time {
then := now.Add(time.Duration(rb.Offset))
then := now.Add(rb.Offset)
return then
}
func (rb RelativeBase) Label() string {
return rb.Name
}
func (rb RelativeBase) GetStartingPrice() float64 {
return 0
}

View File

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

View File

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

View File

@ -3,6 +3,7 @@ package asset
import (
"context"
"fmt"
"log/slog"
"time"
"code.humancabbage.net/sam/moonmath/coindesk"
@ -29,7 +30,7 @@ type Msg struct {
inner tea.Msg
}
func New(cfg config.Data) (m Model) {
func New(cfg config.Asset) (m Model) {
m.math = moon.NewMath(
cfg.Asset,
cfg.Goals,
@ -83,16 +84,11 @@ func New(cfg config.Data) (m Model) {
m.indicator = spinner.New()
m.indicator.Spinner = spinner.Points
m.indicator.Style = lipgloss.NewStyle().
Foreground(lipgloss.Color("69"))
m.indicator.Style = indicatorNormalStyle
return
}
func (m Model) Handles(a coindesk.Asset) bool {
return m.math.Asset == a
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
m.indicator.Tick,
@ -102,39 +98,41 @@ 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) {
switch msg := msg.(type) {
case Msg:
switch msg := msg.inner.(type) {
case refresh:
m.refreshing = true
return m, func() tea.Msg {
// TODO: log errors
_ = m.math.Refresh(context.TODO())
return Msg{m.math.Asset, m.math}
}
case moon.Math:
m.math = msg
refillProperties(&m)
refillProjections(&m)
m.indicator.Style = indicatorNormalStyle
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{}}
}),
m.resumeIndicator,
m.refresh,
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 stopIndicator:
m.refreshing = false
return m, nil
}
case spinner.TickMsg:
if !m.refreshing {
return m, nil
}
var cmd tea.Cmd
m.indicator, cmd = m.indicator.Update(msg)
return m, cmd
@ -143,8 +141,51 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
type refresh struct{}
type update struct {
math moon.Math
err error
}
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) {
rows := []table.Row{
{"Asset", string(m.math.Asset)},
@ -154,14 +195,34 @@ func refillProperties(m *Model) {
}
func refillProjections(m *Model) {
rows := []table.Row{m.math.Labels}
cols := []table.Row{m.math.Labels}
for i := range m.math.Columns {
rows = append(rows, m.math.Columns[i].Column())
entries := renderEntries(m.math.Columns[i])
cols = append(cols, entries)
}
rows = transpose(rows)
rows := transpose(cols)
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 {
xl := len(slice[0])
yl := len(slice)
@ -199,3 +260,9 @@ func (m Model) View() string {
var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("240"))
var indicatorNormalStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("69"))
var indicatorErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("160"))

42
tui/perf/perf.go Normal file
View File

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