From f017555640b06dda1ceb808dc932c581a8cb8f3c Mon Sep 17 00:00:00 2001 From: Miguel Mota Date: Sun, 23 Dec 2018 00:10:41 -0800 Subject: [PATCH] portfolio view --- .gitignore | 2 + README.md | 52 ++++- cointop/actions.go | 2 + cointop/cointop.go | 40 ++-- cointop/config.go | 101 ++++++--- cointop/favorites.go | 2 + cointop/headers.go | 101 +++++---- cointop/keybindings.go | 26 ++- cointop/portfolio.go | 15 ++ cointop/quit.go | 36 +++ cointop/save.go | 6 +- cointop/shortcuts.go | 3 + cointop/sort.go | 22 +- cointop/statusbar.go | 4 +- cointop/table.go | 229 ++++++++++++++------ cointop/types.go | 6 +- pkg/api/impl/coinmarketcap/coinmarketcap.go | 2 +- pkg/color/color.go | 3 + 18 files changed, 482 insertions(+), 170 deletions(-) create mode 100644 cointop/portfolio.go create mode 100644 cointop/quit.go diff --git a/.gitignore b/.gitignore index e8af3a9..4d32b7d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ build-dir # do not ignore .flathub # do not ignore .rpm # do not ignore .copr + +todo.txt diff --git a/README.md b/README.md index 6140fe8..e05976f 100644 --- a/README.md +++ b/README.md @@ -242,12 +242,14 @@ Key|Action 2|Sort table by *[2]4 hour change* 7|Sort table by *[7] day change* a|Sort table by *[a]vailable supply* +b|Sort table by *[b]alance* c|Show currency convert menu f|Toggle coin as favorite F|Toggle show favorites g|Go to first line of page (vim inspired) G|Go to last line of page (vim inspired) h|Go to previous page (vim inspired) +h|Sort table by *[h]oldings* (portfolio view only) H|Go to top of table window (vim inspired) j|Move down (vim inspired) k|Move up (vim inspired) @@ -258,6 +260,7 @@ Key|Action n|Sort table by *[n]ame* o|[o]pen link to highlighted coin on [CoinMarketCap](https://coinmarketcap.com/) p|Sort table by *[p]rice* +P|Toggle show portfolio r|Sort table by *[r]ank* s|Sort table by *[s]ymbol* t|Sort table by *[t]otal supply* @@ -285,6 +288,9 @@ You can then configure the actions you want for each key: (default `~/.cointop/config`) ```toml +currency = "USD" +defaultView = "default" + [shortcuts] "$" = "last_page" 0 = "first_page" @@ -297,10 +303,13 @@ You can then configure the actions you want for each key: "]" = "next_chart_range" "{" = "first_chart_range" "}" = "last_chart_range" + C = "show_currency_convert_menu" G = "move_to_page_last_row" H = "move_to_page_visible_first_row" L = "move_to_page_visible_last_row" M = "move_to_page_visible_middle_row" + O = "open_link" + P = "toggle_portfolio" a = "sort_column_available_supply" "alt+down" = "sort_column_desc" "alt+left" = "sort_left_column" @@ -311,6 +320,7 @@ You can then configure the actions you want for each key: right = "next_page" up = "move_up" c = "show_currency_convert_menu" + b = "sort_column_balance" "ctrl+c" = "quit" "ctrl+d" = "page_down" "ctrl+f" = "open_search" @@ -386,7 +396,9 @@ Action|Description `sort_column_7d_change`|Sort table by column *7 day change* `sort_column_asc`|Sort highlighted column by ascending order `sort_column_available_supply`|Sort table by column *available supply* +`sort_column_balance`|Sort table by column *balance* `sort_column_desc`|Sort highlighted column by descending order +`sort_column_holdings`|Sort table by column *holdings* `sort_column_last_updated`|Sort table by column *last updated* `sort_column_market_cap`|Sort table by column *market cap* `sort_column_name`|Sort table by column *name* @@ -400,9 +412,13 @@ Action|Description `toggle_favorite`|Toggle coin as favorite `toggle_show_currency_convert_menu`|Toggle show currency convert menu `toggle_show_favorites`|Toggle show favorites +`toggle_portfolio`|Toggle portfolio view +`toggle_show_portfolio`|Toggle show portfolio view ## FAQ +Frequently asked questions: + - Q: Where is the data from? - A: The data is from [Coin Market Cap](https://coinmarketcap.com/). @@ -424,9 +440,13 @@ Action|Description export PATH=$PATH:$GOPATH/bin ``` -- Q: What is the size of the binary? +- Q: Where is the config file located? + + - A: The default configuration file is located under `~/.cointop/config` + +- Q: What format is the configuration file in? - - A: The executable is only ~1.9MB in size. + - A: The configuration file is in [TOML](https://en.wikipedia.org/wiki/TOML) format. - Q: How do I search? @@ -452,6 +472,14 @@ Action|Description - A: Press ctrl+s to save your favorites. +- Q: What does the yellow asterisk in the row mean? + + - A: The yellow asterisk or star means that you've selected that coin to be a favorite. + +- Q: How do I view all my portfolio? + + - A: Press P (shift+p) to toggle view your portfolio. + - Q: I'm getting question marks or weird symbols instead of the correct characters. - A: Make sure that your terminal has the encoding set to UTF-8 and that your terminal font supports UTF-8. @@ -526,6 +554,10 @@ Action|Description - A: Press ctrl+s to save the selected currency to convert to. +- Q: What does saving do? + + - A: The save command (ctrl+s) saves your selected currency, selected favorite coins, and portfolio coins to the cointop config file. + - Q: The data isn't refreshing! - A: The CoinMarketCap API has rate limits, so make sure to keep manual refreshes to a minimum. If you've hit the rate limit then wait about half an hour to be able to fetch the data again. Keep in mind that CoinMarketCap updates prices every 5 minutes so constant refreshes aren't necessary. @@ -538,6 +570,18 @@ Action|Description - A: Press q to quit the open view/window. +- Q: How do I set the favorites view to be the default view? + + - A: In `~/.cointop/config`, set `defaultView = "favorites"` + +- Q: How do I set the portfolio view to be the default view? + + - A: In `~/.cointop/config`, set `defaultView = "portfolio"` + +- Q: How do I set the table view to be the default view? + + - A: In `~/.cointop/config`, set `defaultView = "default"` + - Q: I'm getting the error `open /dev/tty: no such device or address`. -A: Usually this error occurs when cointop is running as a daemon or slave which means that there is no terminal allocated, so `/dev/tty` doesn't exist for that process. Try running it with the following environment variables: @@ -546,6 +590,10 @@ Action|Description DEV_IN=/dev/stdout DEV_OUT=/dev/stdout cointop ``` +- Q: What is the size of the binary? + + - A: The executable is only ~2MB in size. + ## Development ### Go diff --git a/cointop/actions.go b/cointop/actions.go index 23cbcf2..a3e585b 100644 --- a/cointop/actions.go +++ b/cointop/actions.go @@ -49,6 +49,8 @@ func actionsMap() map[string]bool { "toggle_show_currency_convert_menu": true, "show_currency_convert_menu": true, "hide_currency_convert_menu": true, + "toggle_portfolio": true, + "toggle_show_portfolio": true, } } diff --git a/cointop/cointop.go b/cointop/cointop.go index ef97234..a2d4546 100644 --- a/cointop/cointop.go +++ b/cointop/cointop.go @@ -35,6 +35,8 @@ type Cointop struct { tablecolumnorder []string table *table.Table maxtablewidth int + portfoliovisible bool + visible bool statusbarview *gocui.View statusbarviewname string sortdesc bool @@ -69,6 +71,18 @@ type Cointop struct { convertmenuview *gocui.View convertmenuviewname string convertmenuvisible bool + portfolio *portfolio +} + +// PortfolioEntry is portfolio entry +type portfolioEntry struct { + Coin string + Holdings float64 +} + +// Portfolio is portfolio structure +type portfolio struct { + Entries map[string]*portfolioEntry } // New initializes cointop @@ -81,14 +95,13 @@ func New() *Cointop { api: api.NewCMC(), refreshticker: time.NewTicker(1 * time.Minute), sortby: "rank", - sortdesc: false, page: 0, perpage: 100, forcerefresh: make(chan bool), maxtablewidth: 175, actionsmap: actionsMap(), shortcutkeys: defaultShortcuts(), - // DEPRECATED: favorites by 'symbol' is deprecated because of collisions. + // DEPRECATED: favorites by 'symbol' is deprecated because of collisions. Kept for backward compatibility. favoritesbysymbol: map[string]bool{}, favorites: map[string]bool{}, cache: cache.New(1*time.Minute, 2*time.Minute), @@ -142,6 +155,9 @@ func New() *Cointop { helpviewname: "help", convertmenuviewname: "convertmenu", currencyconversion: "USD", + portfolio: &portfolio{ + Entries: make(map[string]*portfolioEntry, 0), + }, } err := ct.setupConfig() if err != nil { @@ -202,23 +218,3 @@ func (ct *Cointop) Run() { log.Fatalf("main loop: %v", err) } } - -func (ct *Cointop) quit() error { - return gocui.ErrQuit -} - -func (ct *Cointop) quitView() error { - if ct.activeViewName() == ct.tableviewname { - return ct.quit() - } - return nil -} - -// Exit safely exit application -func (ct *Cointop) Exit() { - if ct.g != nil { - ct.g.Close() - } else { - os.Exit(0) - } -} diff --git a/cointop/config.go b/cointop/config.go index 6b2d5cb..a9e7cf9 100644 --- a/cointop/config.go +++ b/cointop/config.go @@ -13,9 +13,11 @@ import ( var fileperm = os.FileMode(0644) type config struct { - Shortcuts map[string]interface{} `toml:"shortcuts"` - Favorites map[string][]interface{} `toml:"favorites"` - Currency interface{} `toml:"currency"` + Shortcuts map[string]interface{} `toml:"shortcuts"` + Favorites map[string][]interface{} `toml:"favorites"` + Portfolio map[string]interface{} `toml:"portfolio"` + Currency interface{} `toml:"currency"` + DefaultView interface{} `toml:"defaultView"` } func (ct *Cointop) setupConfig() error { @@ -39,31 +41,17 @@ func (ct *Cointop) setupConfig() error { if err != nil { return err } + err = ct.loadPortfolioFromConfig() + if err != nil { + return err + } err = ct.loadCurrencyFromConfig() if err != nil { return err } - return nil -} - -func (ct *Cointop) loadFavoritesFromConfig() error { - for k, arr := range ct.config.Favorites { - // DEPRECATED: favorites by 'symbol' is deprecated because of collisions. - if k == "symbols" { - for _, ifc := range arr { - v, ok := ifc.(string) - if ok { - ct.favoritesbysymbol[strings.ToUpper(v)] = true - } - } - } else if k == "names" { - for _, ifc := range arr { - v, ok := ifc.(string) - if ok { - ct.favorites[v] = true - } - } - } + err = ct.loadDefaultViewFromConfig() + if err != nil { + return err } return nil } @@ -153,11 +141,19 @@ func (ct *Cointop) configToToml() ([]byte, error) { "names": favorites, } + portfolioIfc := map[string]interface{}{} + for name := range ct.portfolio.Entries { + entry := ct.portfolio.Entries[name] + var i interface{} = entry.Holdings + portfolioIfc[entry.Coin] = i + } + var currencyIfc interface{} = ct.currencyconversion var inputs = &config{ Shortcuts: shortcutsIfcs, Favorites: favoritesIfcs, + Portfolio: portfolioIfc, Currency: currencyIfc, } @@ -173,8 +169,7 @@ func (ct *Cointop) configToToml() ([]byte, error) { func (ct *Cointop) loadShortcutsFromConfig() error { for k, ifc := range ct.config.Shortcuts { - v, ok := ifc.(string) - if ok { + if v, ok := ifc.(string); ok { if !ct.actionExists(v) { continue } @@ -193,3 +188,57 @@ func (ct *Cointop) loadCurrencyFromConfig() error { } return nil } + +func (ct *Cointop) loadDefaultViewFromConfig() error { + if defaultView, ok := ct.config.DefaultView.(string); ok { + switch defaultView { + case "portfolio": + ct.portfoliovisible = true + case "favorites": + ct.filterByFavorites = true + case "default": + fallthrough + default: + ct.portfoliovisible = false + ct.filterByFavorites = false + } + } + return nil +} + +func (ct *Cointop) loadFavoritesFromConfig() error { + for k, arr := range ct.config.Favorites { + // DEPRECATED: favorites by 'symbol' is deprecated because of collisions. Kept for backward compatibility. + if k == "symbols" { + for _, ifc := range arr { + if v, ok := ifc.(string); ok { + ct.favoritesbysymbol[strings.ToUpper(v)] = true + } + } + } else if k == "names" { + for _, ifc := range arr { + if v, ok := ifc.(string); ok { + ct.favorites[v] = true + } + } + } + } + return nil +} + +func (ct *Cointop) loadPortfolioFromConfig() error { + for name, holdingsIfc := range ct.config.Portfolio { + var holdings float64 + var ok bool + if holdings, ok = holdingsIfc.(float64); !ok { + if holdingsInt, ok := holdingsIfc.(int64); ok { + holdings = float64(holdingsInt) + } + } + ct.portfolio.Entries[strings.ToLower(name)] = &portfolioEntry{ + Coin: name, + Holdings: holdings, + } + } + return nil +} diff --git a/cointop/favorites.go b/cointop/favorites.go index e0a5266..222547a 100644 --- a/cointop/favorites.go +++ b/cointop/favorites.go @@ -1,6 +1,7 @@ package cointop func (ct *Cointop) toggleFavorite() error { + ct.portfoliovisible = false coin := ct.highlightedRowCoin() if coin == nil { return nil @@ -18,6 +19,7 @@ func (ct *Cointop) toggleFavorite() error { } func (ct *Cointop) toggleShowFavorites() error { + ct.portfoliovisible = false ct.filterByFavorites = !ct.filterByFavorites ct.updateTable() return nil diff --git a/cointop/headers.go b/cointop/headers.go index 30139c1..08a1ade 100644 --- a/cointop/headers.go +++ b/cointop/headers.go @@ -8,58 +8,75 @@ import ( ) func (ct *Cointop) updateHeaders() { - cm := map[string]func(a ...interface{}) string{ - "rank": color.Black, - "name": color.Black, - "symbol": color.Black, - "price": color.Black, - "marketcap": color.Black, - "24hvolume": color.Black, - "1hchange": color.Black, - "24hchange": color.Black, - "7dchange": color.Black, - "totalsupply": color.Black, - "availablesupply": color.Black, - "lastupdated": color.Black, + var cols []string + + type t struct { + colorfn func(a ...interface{}) string + displaytext string + padleft int + padright int + arrow string } - sm := map[string]string{ - "rank": " ", - "name": " ", - "symbol": " ", - "price": " ", - "marketcap": " ", - "24hvolume": " ", - "1hchange": " ", - "24hchange": " ", - "7dchange": " ", - "totalsupply": " ", - "availablesupply": " ", - "lastupdated": " ", + + cm := map[string]*t{ + "rank": &t{color.Black, "[r]ank", 0, 1, " "}, + "name": &t{color.Black, "[n]ame", 0, 11, " "}, + "symbol": &t{color.Black, "[s]ymbol", 4, 0, " "}, + "price": &t{color.Black, "[p]rice", 2, 0, " "}, + "holdings": &t{color.Black, "[h]oldings", 5, 0, " "}, + "balance": &t{color.Black, "[b]alance", 5, 0, " "}, + "marketcap": &t{color.Black, "[m]arket cap", 5, 0, " "}, + "24hvolume": &t{color.Black, "24H [v]olume", 3, 0, " "}, + "1hchange": &t{color.Black, "[1]H%", 5, 0, " "}, + "24hchange": &t{color.Black, "[2]4H%", 3, 0, " "}, + "7dchange": &t{color.Black, "[7]D%", 4, 0, " "}, + "totalsupply": &t{color.Black, "[t]otal supply", 7, 0, " "}, + "availablesupply": &t{color.Black, "[a]vailable supply", 0, 0, " "}, + "lastupdated": &t{color.Black, "last [u]pdated", 3, 0, " "}, } + for k := range cm { + cm[k].arrow = " " if ct.sortby == k { - cm[k] = color.CyanBg + cm[k].colorfn = color.CyanBg if ct.sortdesc { - sm[k] = "▼" + cm[k].arrow = "▼" } else { - sm[k] = "▲" + cm[k].arrow = "▲" } } } + symbol := currencysymbols[ct.currencyconversion] - headers := []string{ - fmt.Sprintf("%s%s", cm["rank"](sm["rank"]+"[r]ank"), strings.Repeat(" ", 1)), - fmt.Sprintf("%s%s", cm["name"](sm["name"]+"[n]ame"), strings.Repeat(" ", 15)), - fmt.Sprintf("%s%s", cm["symbol"](sm["symbol"]+"[s]ymbol"), strings.Repeat(" ", 1)), - fmt.Sprintf("%s%s", strings.Repeat(" ", 0), cm["price"](sm["price"]+symbol+"[p]rice")), - fmt.Sprintf("%s%s", strings.Repeat(" ", 5), cm["marketcap"](sm["marketcap"]+"[m]arket cap")), - fmt.Sprintf("%s%s", strings.Repeat(" ", 3), cm["24hvolume"](sm["24hvolume"]+"24H [v]olume")), - fmt.Sprintf("%s%s", strings.Repeat(" ", 4), cm["1hchange"](sm["1hchange"]+"[1]H%")), - fmt.Sprintf("%s%s", strings.Repeat(" ", 3), cm["24hchange"](sm["24hchange"]+"[2]4H%")), - fmt.Sprintf("%s%s", strings.Repeat(" ", 3), cm["7dchange"](sm["7dchange"]+"[7]DH%")), - fmt.Sprintf("%s%s", strings.Repeat(" ", 6), cm["totalsupply"](sm["totalsupply"]+"[t]otal supply")), - fmt.Sprintf("%s%s", strings.Repeat(" ", 1), cm["availablesupply"](sm["availablesupply"]+"[a]vailable supply")), - fmt.Sprintf("%s%s", strings.Repeat(" ", 4), cm["lastupdated"](sm["lastupdated"]+"last [u]pdated")), + + if ct.portfoliovisible { + cols = []string{"rank", "name", "symbol", "price", + "holdings", "balance", "24hchange", "lastupdated"} + } else { + cols = []string{"rank", "name", "symbol", "price", + "marketcap", "24hvolume", "1hchange", "24hchange", + "7dchange", "totalsupply", "availablesupply", "lastupdated"} + } + + var headers []string + for _, v := range cols { + s, ok := cm[v] + if !ok { + continue + } + var str string + d := s.arrow + s.displaytext + if v == "price" || v == "balance" { + d = s.arrow + symbol + s.displaytext + } + + str = fmt.Sprintf( + "%s%s%s", + strings.Repeat(" ", s.padleft), + s.colorfn(d), + strings.Repeat(" ", s.padright), + ) + headers = append(headers, str) } ct.update(func() { diff --git a/cointop/keybindings.go b/cointop/keybindings.go index 5df8082..ce4797e 100644 --- a/cointop/keybindings.go +++ b/cointop/keybindings.go @@ -201,6 +201,9 @@ func (ct *Cointop) parseKeys(s string) (interface{}, gocui.Modifier) { func (ct *Cointop) keybindings(g *gocui.Gui) error { for k, v := range ct.shortcutkeys { + if k == "" { + continue + } v = strings.TrimSpace(strings.ToLower(v)) var fn func(g *gocui.Gui, v *gocui.View) error key, mod := ct.parseKeys(k) @@ -211,7 +214,7 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error { case "move_down": fn = ct.keyfn(ct.cursorDown) case "previous_page": - fn = ct.keyfn(ct.prevPage) + fn = ct.handleHkey() case "next_page": fn = ct.keyfn(ct.nextPage) case "page_down": @@ -268,7 +271,7 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error { case "move_to_page_visible_middle_row": fn = ct.keyfn(ct.navigatePageMiddleLine) case "sort_column_name": - fn = ct.sortfn("name", true) + fn = ct.sortfn("name", false) case "sort_column_price": fn = ct.sortfn("price", true) case "sort_column_rank": @@ -279,6 +282,10 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error { fn = ct.sortfn("lastupdated", true) case "sort_column_24h_volume": fn = ct.sortfn("24hvolume", true) + case "sort_column_balance": + fn = ct.sortfn("balance", true) + case "sort_column_holdings": + fn = ct.sortfn("holdings", true) case "last_page": fn = ct.keyfn(ct.lastPage) case "open_search": @@ -310,6 +317,10 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error { case "hide_currency_convert_menu": fn = ct.keyfn(ct.hideConvertMenu) view = "convertmenu" + case "toggle_portfolio": + fn = ct.keyfn(ct.togglePortfolio) + case "toggle_show_portfolio": + fn = ct.keyfn(ct.toggleShowPortfolio) default: fn = ct.keyfn(ct.noop) } @@ -360,6 +371,17 @@ func (ct *Cointop) keyfn(fn func() error) func(g *gocui.Gui, v *gocui.View) erro } } +func (ct *Cointop) handleHkey() func(g *gocui.Gui, v *gocui.View) error { + return func(g *gocui.Gui, v *gocui.View) error { + if ct.portfoliovisible { + ct.sortToggle("holdings", true) + } else { + ct.prevPage() + } + return nil + } +} + func (ct *Cointop) noop() error { return nil } diff --git a/cointop/portfolio.go b/cointop/portfolio.go new file mode 100644 index 0000000..9315bd1 --- /dev/null +++ b/cointop/portfolio.go @@ -0,0 +1,15 @@ +package cointop + +func (ct *Cointop) togglePortfolio() error { + ct.filterByFavorites = false + ct.portfoliovisible = !ct.portfoliovisible + ct.updateTable() + return nil +} + +func (ct *Cointop) toggleShowPortfolio() error { + ct.filterByFavorites = false + ct.portfoliovisible = true + ct.updateTable() + return nil +} diff --git a/cointop/quit.go b/cointop/quit.go new file mode 100644 index 0000000..60e8efa --- /dev/null +++ b/cointop/quit.go @@ -0,0 +1,36 @@ +package cointop + +import ( + "os" + + "github.com/miguelmota/cointop/pkg/gocui" +) + +func (ct *Cointop) quit() error { + return gocui.ErrQuit +} + +func (ct *Cointop) quitView() error { + if ct.portfoliovisible { + ct.portfoliovisible = false + return ct.updateTable() + } + if ct.filterByFavorites { + ct.filterByFavorites = false + return ct.updateTable() + } + if ct.activeViewName() == ct.tableviewname { + return ct.quit() + } + + return nil +} + +// Exit safely exit application +func (ct *Cointop) Exit() { + if ct.g != nil { + ct.g.Close() + } else { + os.Exit(0) + } +} diff --git a/cointop/save.go b/cointop/save.go index 158441e..460331a 100644 --- a/cointop/save.go +++ b/cointop/save.go @@ -1,8 +1,12 @@ package cointop +import "log" + func (ct *Cointop) save() error { ct.setSavingStatus() - ct.saveConfig() + if err := ct.saveConfig(); err != nil { + log.Fatal(err) + } return nil } diff --git a/cointop/shortcuts.go b/cointop/shortcuts.go index 0b38b71..7e6645e 100644 --- a/cointop/shortcuts.go +++ b/cointop/shortcuts.go @@ -35,6 +35,7 @@ func defaultShortcuts() map[string]string { "2": "sort_column_24h_change", "7": "sort_column_7d_change", "a": "sort_column_available_supply", + "b": "sort_column_balance", "c": "show_currency_convert_menu", "C": "show_currency_convert_menu", "f": "toggle_favorite", @@ -51,7 +52,9 @@ func defaultShortcuts() map[string]string { "M": "move_to_page_visible_middle_row", "n": "sort_column_name", "o": "open_link", + "O": "open_link", "p": "sort_column_price", + "P": "toggle_portfolio", "r": "sort_column_rank", "s": "sort_column_symbol", "t": "sort_column_total_supply", diff --git a/cointop/sort.go b/cointop/sort.go index fd712e0..5a2b4d9 100644 --- a/cointop/sort.go +++ b/cointop/sort.go @@ -24,6 +24,10 @@ func (ct *Cointop) sort(sortby string, desc bool, list []*coin) { return a.Symbol < b.Symbol case "price": return a.Price < b.Price + case "holdings": + return a.Holdings < b.Holdings + case "balance": + return a.Balance < b.Balance case "marketcap": return a.MarketCap < b.MarketCap case "24hvolume": @@ -47,15 +51,19 @@ func (ct *Cointop) sort(sortby string, desc bool, list []*coin) { ct.updateHeaders() } +func (ct *Cointop) sortToggle(sortby string, desc bool) error { + if ct.sortby == sortby { + desc = !ct.sortdesc + } + + ct.sort(sortby, desc, ct.coins) + ct.updateTable() + return nil +} + func (ct *Cointop) sortfn(sortby string, desc bool) func(g *gocui.Gui, v *gocui.View) error { return func(g *gocui.Gui, v *gocui.View) error { - if ct.sortby == sortby { - desc = !desc - } - - ct.sort(sortby, desc, ct.coins) - ct.updateTable() - return nil + return ct.sortToggle(sortby, desc) } } diff --git a/cointop/statusbar.go b/cointop/statusbar.go index 5291b95..48d7417 100644 --- a/cointop/statusbar.go +++ b/cointop/statusbar.go @@ -11,7 +11,7 @@ func (ct *Cointop) updateStatusbar(s string) { ct.statusbarview.Clear() currpage := ct.currentDisplayPage() totalpages := ct.totalPages() - base := fmt.Sprintf("%sQuit %sHelp %sChart %sRange %sSearch %sConvert %sFavorite %sSave", "[Q]", "[?]", "[Enter]", "[[ ]]", "[/]", "[C]", "[F]", "[CTRL-S]") + base := fmt.Sprintf("%sQuit %sHelp %sChart %sRange %sSearch %sConvert %sFavorites %sPortfolio %sSave", "[Q]", "[?]", "[Enter]", "[[ ]]", "[/]", "[C]", "[F]", "[P]", "[CTRL-S]") str := pad.Right(fmt.Sprintf("%v %sPage %v/%v %s", base, "[← →]", currpage, totalpages, s), ct.maxtablewidth, " ") v := fmt.Sprintf("v%s", ct.version()) str = str[:len(str)-len(v)+2] + v @@ -20,6 +20,6 @@ func (ct *Cointop) updateStatusbar(s string) { } func (ct *Cointop) refreshRowLink() { - url := ct.rowLink() + url := ct.rowLinkShort() ct.updateStatusbar(fmt.Sprintf("%sOpen %s", "[O]", url)) } diff --git a/cointop/table.go b/cointop/table.go index 9be2178..bc8100f 100644 --- a/cointop/table.go +++ b/cointop/table.go @@ -3,6 +3,7 @@ package cointop import ( "fmt" "math" + "sort" "strconv" "strings" "time" @@ -16,73 +17,117 @@ import ( func (ct *Cointop) refreshTable() error { maxX := ct.width() ct.table = table.New().SetWidth(maxX) - ct.table.AddCol("") - ct.table.AddCol("") - ct.table.AddCol("") - ct.table.AddCol("") - ct.table.AddCol("") - ct.table.AddCol("") - ct.table.AddCol("") - ct.table.AddCol("") - ct.table.AddCol("") - ct.table.AddCol("") - ct.table.AddCol("") - ct.table.AddCol("") ct.table.HideColumHeaders = true - for _, coin := range ct.coins { - unix, _ := strconv.ParseInt(coin.LastUpdated, 10, 64) - lastUpdated := time.Unix(unix, 0).Format("15:04:05 Jan 02") - namecolor := color.White - colorprice := color.Cyan - color1h := color.White - color24h := color.White - color7d := color.White - if coin.Favorite { - namecolor = color.Yellow - } - if coin.PercentChange1H > 0 { - color1h = color.Green - } - if coin.PercentChange1H < 0 { - color1h = color.Red - } - if coin.PercentChange24H > 0 { - color24h = color.Green - } - if coin.PercentChange24H < 0 { - color24h = color.Red - } - if coin.PercentChange7D > 0 { - color7d = color.Green - } - if coin.PercentChange7D < 0 { - color7d = color.Red - } - name := coin.Name - dots := "..." - star := " " - if coin.Favorite { - star = "*" + + if ct.portfoliovisible { + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + for _, coin := range ct.coins { + unix, _ := strconv.ParseInt(coin.LastUpdated, 10, 64) + lastUpdated := time.Unix(unix, 0).Format("15:04:05 Jan 02") + namecolor := color.White + colorprice := color.White + colorbalance := color.Cyan + color24h := color.White + if coin.PercentChange24H > 0 { + color24h = color.Green + } + if coin.PercentChange24H < 0 { + color24h = color.Red + } + name := coin.Name + dots := "..." + star := " " + rank := fmt.Sprintf("%s%v", color.Yellow(star), color.White(fmt.Sprintf("%6v ", coin.Rank))) + if len(name) > 20 { + name = fmt.Sprintf("%s%s", name[0:18], dots) + } + + ct.table.AddRow( + rank, + namecolor(pad.Right(fmt.Sprintf("%.22s", name), 21, " ")), + color.White(pad.Right(fmt.Sprintf("%.6s", coin.Symbol), 5, " ")), + colorprice(fmt.Sprintf("%13s", humanize.Commaf(coin.Price))), + color.White(fmt.Sprintf("%15s", humanize.Commaf(coin.Holdings))), + colorbalance(fmt.Sprintf("%15s", humanize.Commaf(coin.Balance))), + color24h(fmt.Sprintf("%8.2f%%", coin.PercentChange24H)), + color.White(pad.Right(fmt.Sprintf("%17s", lastUpdated), 80, " ")), + ) } - rank := fmt.Sprintf("%s%v", color.Yellow(star), color.White(fmt.Sprintf("%6v ", coin.Rank))) - if len(name) > 20 { - name = fmt.Sprintf("%s%s", name[0:18], dots) + } else { + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + ct.table.AddCol("") + for _, coin := range ct.coins { + unix, _ := strconv.ParseInt(coin.LastUpdated, 10, 64) + lastUpdated := time.Unix(unix, 0).Format("15:04:05 Jan 02") + namecolor := color.White + colorprice := color.Cyan + color1h := color.White + color24h := color.White + color7d := color.White + if coin.Favorite { + namecolor = color.Yellow + } + if coin.PercentChange1H > 0 { + color1h = color.Green + } + if coin.PercentChange1H < 0 { + color1h = color.Red + } + if coin.PercentChange24H > 0 { + color24h = color.Green + } + if coin.PercentChange24H < 0 { + color24h = color.Red + } + if coin.PercentChange7D > 0 { + color7d = color.Green + } + if coin.PercentChange7D < 0 { + color7d = color.Red + } + name := coin.Name + dots := "..." + star := " " + if coin.Favorite { + star = "*" + } + rank := fmt.Sprintf("%s%v", color.Yellow(star), color.White(fmt.Sprintf("%6v ", coin.Rank))) + if len(name) > 20 { + name = fmt.Sprintf("%s%s", name[0:18], dots) + } + ct.table.AddRow( + rank, + namecolor(pad.Right(fmt.Sprintf("%.22s", name), 21, " ")), + color.White(pad.Right(fmt.Sprintf("%.6s", coin.Symbol), 5, " ")), + colorprice(fmt.Sprintf("%12s", humanize.Commaf(coin.Price))), + color.White(fmt.Sprintf("%17s", humanize.Commaf(coin.MarketCap))), + color.White(fmt.Sprintf("%15s", humanize.Commaf(coin.Volume24H))), + color1h(fmt.Sprintf("%8.2f%%", coin.PercentChange1H)), + color24h(fmt.Sprintf("%8.2f%%", coin.PercentChange24H)), + color7d(fmt.Sprintf("%8.2f%%", coin.PercentChange7D)), + color.White(fmt.Sprintf("%21s", humanize.Commaf(coin.TotalSupply))), + color.White(fmt.Sprintf("%18s", humanize.Commaf(coin.AvailableSupply))), + color.White(fmt.Sprintf("%18s", lastUpdated)), + // TODO: add %percent of cap + ) } - ct.table.AddRow( - rank, - namecolor(pad.Right(fmt.Sprintf("%.22s", name), 21, " ")), - color.White(pad.Right(fmt.Sprintf("%.6s", coin.Symbol), 5, " ")), - colorprice(fmt.Sprintf("%12s", humanize.Commaf(coin.Price))), - color.White(fmt.Sprintf("%17s", humanize.Commaf(coin.MarketCap))), - color.White(fmt.Sprintf("%15s", humanize.Commaf(coin.Volume24H))), - color1h(fmt.Sprintf("%8.2f%%", coin.PercentChange1H)), - color24h(fmt.Sprintf("%8.2f%%", coin.PercentChange24H)), - color7d(fmt.Sprintf("%8.2f%%", coin.PercentChange7D)), - color.White(fmt.Sprintf("%21s", humanize.Commaf(coin.TotalSupply))), - color.White(fmt.Sprintf("%18s", humanize.Commaf(coin.AvailableSupply))), - color.White(fmt.Sprintf("%18s", lastUpdated)), - // add %percent of cap - ) } // highlight last row if current row is out of bounds (can happen when switching views) @@ -123,6 +168,51 @@ func (ct *Cointop) updateTable() error { return nil } + if ct.portfoliovisible { + for i := range ct.allcoins { + if len(ct.portfolio.Entries) == 0 { + break + } + coin := ct.allcoins[i] + var p *portfolioEntry + var ok bool + if p, ok = ct.portfolio.Entries[strings.ToLower(coin.Name)]; !ok { + // NOTE: if not found then try the symbol + if p, ok = ct.portfolio.Entries[strings.ToLower(coin.Symbol)]; !ok { + continue + } + } + holdingsstr := fmt.Sprintf("%.2f", p.Holdings) + if ct.currencyconversion == "ETH" || ct.currencyconversion == "BTC" { + holdingsstr = fmt.Sprintf("%.5f", p.Holdings) + } + holdings, _ := strconv.ParseFloat(holdingsstr, 64) + coin.Holdings = holdings + + balance := coin.Price * p.Holdings + balancestr := fmt.Sprintf("%.2f", balance) + if ct.currencyconversion == "ETH" || ct.currencyconversion == "BTC" { + balancestr = fmt.Sprintf("%.5f", balance) + } + balance, _ = strconv.ParseFloat(balancestr, 64) + coin.Balance = balance + sliced = append(sliced, coin) + } + + sort.Slice(sliced, func(i, j int) bool { + return sliced[i].Balance > sliced[j].Balance + }) + + for i, coin := range sliced { + coin.Rank = i + 1 + } + + ct.coins = sliced + ct.sort(ct.sortby, ct.sortdesc, ct.coins) + ct.refreshTable() + return nil + } + start := ct.page * ct.perpage end := start + ct.perpage allcoins := ct.allCoins() @@ -182,9 +272,20 @@ func (ct *Cointop) rowLink() string { return "" } slug := strings.ToLower(strings.Replace(coin.Name, " ", "-", -1)) + // TODO: dynamic return fmt.Sprintf("https://coinmarketcap.com/currencies/%s", slug) } +func (ct *Cointop) rowLinkShort() string { + coin := ct.highlightedRowCoin() + if coin == nil { + return "" + } + // TODO: dynamic + slug := strings.ToLower(strings.Replace(coin.Name, " ", "-", -1)) + return fmt.Sprintf("http://coinmarketcap.com/.../%s", slug) +} + func (ct *Cointop) allCoins() []*coin { if ct.filterByFavorites { var list []*coin diff --git a/cointop/types.go b/cointop/types.go index 4e624ed..01d4d6a 100644 --- a/cointop/types.go +++ b/cointop/types.go @@ -15,5 +15,9 @@ type coin struct { PercentChange24H float64 PercentChange7D float64 LastUpdated string - Favorite bool + // for favorites + Favorite bool + // for portfolio + Holdings float64 + Balance float64 } diff --git a/pkg/api/impl/coinmarketcap/coinmarketcap.go b/pkg/api/impl/coinmarketcap/coinmarketcap.go index 5651119..e29e6c8 100644 --- a/pkg/api/impl/coinmarketcap/coinmarketcap.go +++ b/pkg/api/impl/coinmarketcap/coinmarketcap.go @@ -75,7 +75,7 @@ func (s *Service) GetAllCoinData(convert string) (map[string]apitypes.Coin, erro for _, v := range coins { price := v.Quotes[convert].Price pricestr := fmt.Sprintf("%.2f", price) - if convert == "ETH" || convert == "BTC" { + if convert == "ETH" || convert == "BTC" || price < 1 { pricestr = fmt.Sprintf("%.5f", price) } price, _ = strconv.ParseFloat(pricestr, 64) diff --git a/pkg/color/color.go b/pkg/color/color.go index 40ddbfe..060c46c 100644 --- a/pkg/color/color.go +++ b/pkg/color/color.go @@ -2,6 +2,9 @@ package color import "github.com/fatih/color" +// Color struct +type Color color.Color + var ( // Black color Black = color.New(color.FgBlack).SprintFunc()