| | 1 | = Use-case 0007 Implementation - Manage watchlist = |
| | 2 | |
| | 3 | '''Initiating actor:''' Trader |
| | 4 | |
| | 5 | '''Other actors:''' — |
| | 6 | |
| | 7 | A logged-in Trader keeps a list of crypto assets they want to monitor, with the last |
| | 8 | price of each. The first time the watchlist is opened the system creates a default |
| | 9 | watchlist named "Favorites" for the Trader. From a sub-menu the Trader can list the |
| | 10 | watchlist, add a crypto or remove one. The Trader never types a symbol: for adding, the |
| | 11 | system lists, numbered, only the cryptos that are not on the watchlist yet, and for |
| | 12 | removing, only the cryptos that are on it; the Trader picks one by its number. Adding |
| | 13 | a crypto that is already on the list is a no-op (idempotent), and a number that is not |
| | 14 | in the list is refused without touching the database. |
| | 15 | |
| | 16 | Original use-case description (P3): [wiki:UseCase0007]. |
| | 17 | Implementation: `server/watchlist.go`, functions |
| | 18 | `ManageWatchlist`, `ensureDefaultWatchlist`, `listWatchlist`, `addToWatchlist` and |
| | 19 | `removeFromWatchlist`, with `pickNumber` from |
| | 20 | `server/market.go` (the code is shown at the end of this page). |
| | 21 | |
| | 22 | All statements run on the `project` schema (the connection sets |
| | 23 | `search_path=project,public` in `server/db/db.go`). The SQL below is copied from the |
| | 24 | Go code; only the Go source indentation is removed. |
| | 25 | |
| | 26 | The run shown is user `alice` on the seed data, whose watchlist contains BTC, ETH and |
| | 27 | SOL. She lists it, adds ADA, tries to remove a number that is not in the list, removes |
| | 28 | SOL and lists the result. |
| | 29 | |
| | 30 | == Scenario == |
| | 31 | |
| | 32 | 1. '''Trader''' chooses `[8] Manage watchlist` in the authenticated menu (types `8`). |
| | 33 | 2. '''System''' makes sure the Trader has a watchlist and takes the id of the oldest one |
| | 34 | (`ensureDefaultWatchlist`; `$1` = the logged-in user's id): |
| | 35 | |
| | 36 | {{{ |
| | 37 | SELECT id FROM watchlists WHERE user_id = $1 ORDER BY created_at LIMIT 1 |
| | 38 | }}} |
| | 39 | |
| | 40 | Only if this returns no row, it creates the default watchlist and uses its id: |
| | 41 | |
| | 42 | {{{ |
| | 43 | INSERT INTO watchlists (user_id, name) VALUES ($1, 'Favorites') RETURNING id |
| | 44 | }}} |
| | 45 | |
| | 46 | (alice already has the seed watchlist "Favorites", so only the `SELECT` runs.) The |
| | 47 | watchlist id is kept in Go and used as `$1` in all statements below. |
| | 48 | |
| | 49 | 3. '''System''' shows the sub-menu `-- Watchlist --` with `[1] List items`, |
| | 50 | `[2] Add crypto`, `[3] Remove crypto` and `[0] Back`. |
| | 51 | |
| | 52 | [[Image(uc0007_1_3_menu.png)]] |
| | 53 | |
| | 54 | === List items === |
| | 55 | |
| | 56 | 4. '''Trader''' chooses `[1] List items`. |
| | 57 | 5. '''System''' lists the cryptos on the watchlist with their last price against USD |
| | 58 | (`listWatchlist`; `$1` = watchlist id): |
| | 59 | |
| | 60 | {{{ |
| | 61 | SELECT c.symbol, c.name, COALESCE(lp.price, 0) |
| | 62 | FROM watchlist_items wi |
| | 63 | JOIN crypto c ON c.id = wi.crypto_id |
| | 64 | LEFT JOIN markets m ON m.crypto_id = c.id AND m.quote_currency = 'USD' |
| | 65 | LEFT JOIN v_latest_prices lp ON lp.market_id = m.id |
| | 66 | WHERE wi.watchlist_id = $1 |
| | 67 | ORDER BY c.symbol |
| | 68 | }}} |
| | 69 | |
| | 70 | For alice it prints `BTC Bitcoin 67140.000000`, `ETH Ethereum 3520.000000` and |
| | 71 | `SOL Solana 166.100000` (an empty watchlist prints `(watchlist is empty)`), then |
| | 72 | shows the sub-menu again. |
| | 73 | |
| | 74 | [[Image(uc0007_list.png)]] |
| | 75 | |
| | 76 | === Add a crypto === |
| | 77 | |
| | 78 | 6. '''Trader''' chooses `[2] Add crypto`. |
| | 79 | 7. '''System''' lists, numbered, the cryptos that are not on the watchlist yet |
| | 80 | (`addToWatchlist`; `$1` = watchlist id): |
| | 81 | |
| | 82 | {{{ |
| | 83 | SELECT c.id, c.symbol, c.name |
| | 84 | FROM crypto c |
| | 85 | WHERE NOT EXISTS (SELECT 1 FROM watchlist_items wi |
| | 86 | WHERE wi.watchlist_id = $1 AND wi.crypto_id = c.id) |
| | 87 | ORDER BY c.symbol |
| | 88 | }}} |
| | 89 | |
| | 90 | For alice it prints `1 ADA Cardano` and `2 DOGE Dogecoin` and asks |
| | 91 | `Crypto # to add:`. Go keeps each row's crypto id in memory. (If every crypto is |
| | 92 | already on the watchlist, it prints `Every crypto is already on your watchlist.` |
| | 93 | instead.) |
| | 94 | |
| | 95 | [[Image(uc0007_add_1_list.png)]] |
| | 96 | |
| | 97 | 8. '''Trader''' picks the crypto by its number in the list: `1` (ADA). |
| | 98 | 9. '''System''' adds the crypto of row 1 to the watchlist (`$1` = watchlist id, |
| | 99 | `$2` = the chosen crypto's id); thanks to the unique constraint and |
| | 100 | `ON CONFLICT ... DO NOTHING`, adding a crypto that is already there changes |
| | 101 | nothing: |
| | 102 | |
| | 103 | {{{ |
| | 104 | INSERT INTO watchlist_items (watchlist_id, crypto_id) |
| | 105 | VALUES ($1, $2) |
| | 106 | ON CONFLICT (watchlist_id, crypto_id) DO NOTHING |
| | 107 | }}} |
| | 108 | |
| | 109 | It prints `Added ADA.` and shows the sub-menu again. |
| | 110 | |
| | 111 | [[Image(uc0007_add_2_added.png)]] |
| | 112 | |
| | 113 | === Remove a crypto === |
| | 114 | |
| | 115 | 10. '''Trader''' chooses `[3] Remove crypto`. |
| | 116 | 11. '''System''' lists, numbered, the cryptos that are on the watchlist |
| | 117 | (`removeFromWatchlist`; `$1` = watchlist id): |
| | 118 | |
| | 119 | {{{ |
| | 120 | SELECT c.id, c.symbol, c.name |
| | 121 | FROM watchlist_items wi |
| | 122 | JOIN crypto c ON c.id = wi.crypto_id |
| | 123 | WHERE wi.watchlist_id = $1 |
| | 124 | ORDER BY c.symbol |
| | 125 | }}} |
| | 126 | |
| | 127 | For alice it now prints `1 ADA Cardano`, `2 BTC Bitcoin`, `3 ETH Ethereum` and |
| | 128 | `4 SOL Solana` and asks `Crypto # to remove:`. Go keeps each row's crypto id in |
| | 129 | memory. (If the watchlist is empty, it prints `Your watchlist is empty.` instead.) |
| | 130 | |
| | 131 | [[Image(uc0007_remove_1_list.png)]] |
| | 132 | |
| | 133 | 12. '''Trader''' picks the crypto by its number in the list: `4` (SOL). |
| | 134 | 13. '''System''' removes the crypto of row 4 from the watchlist (`$1` = watchlist id, |
| | 135 | `$2` = the chosen crypto's id): |
| | 136 | |
| | 137 | {{{ |
| | 138 | DELETE FROM watchlist_items WHERE watchlist_id = $1 AND crypto_id = $2 |
| | 139 | }}} |
| | 140 | |
| | 141 | It prints `Removed SOL.` and shows the sub-menu again. |
| | 142 | |
| | 143 | [[Image(uc0007_remove_2_removed.png)]] |
| | 144 | |
| | 145 | ==== Alternate flow 12a — number not in the list ==== |
| | 146 | |
| | 147 | Before removing SOL, alice first chose `[3] Remove crypto` and, at step 12, entered `5` |
| | 148 | while only numbers 1–4 were listed. `pickNumber` prints |
| | 149 | `Invalid choice, enter a number from 1 to 4.`, the `DELETE` is not run and the sub-menu |
| | 150 | is shown again; she then chose `[3]` once more, which returned the scenario to step 11. |
| | 151 | The same check applies to the number entered at step 8. |
| | 152 | |
| | 153 | [[Image(uc0007_remove_invalid.png)]] |
| | 154 | |
| | 155 | === Verification — list after the changes === |
| | 156 | |
| | 157 | Choosing `[1] List items` again runs the query from step 5, which now returns |
| | 158 | `ADA Cardano 0.453750`, `BTC Bitcoin 67140.000000` and `ETH Ethereum 3520.000000`: |
| | 159 | ADA was added and SOL removed. `[0] Back` returns to the authenticated menu. |
| | 160 | |
| | 161 | [[Image(uc0007_list_after.png)]] |
| | 162 | |
| | 163 | == Source code == |
| | 164 | |
| | 165 | `server/watchlist.go` — `ManageWatchlist`, `ensureDefaultWatchlist`, `listWatchlist`, `addToWatchlist` and `removeFromWatchlist`: |
| | 166 | |
| | 167 | {{{ |
| | 168 | // ManageWatchlist - UC0007 |
| | 169 | // Ensures the user has a default watchlist, then allows listing, adding, |
| | 170 | // removing entries. |
| | 171 | func ManageWatchlist(s *Session) { |
| | 172 | wlID, err := ensureDefaultWatchlist(s.UserID) |
| | 173 | if err != nil { |
| | 174 | fmt.Println("Error:", err) |
| | 175 | return |
| | 176 | } |
| | 177 | for { |
| | 178 | fmt.Println("\n-- Watchlist --") |
| | 179 | fmt.Println("[1] List items") |
| | 180 | fmt.Println("[2] Add crypto") |
| | 181 | fmt.Println("[3] Remove crypto") |
| | 182 | fmt.Println("[0] Back") |
| | 183 | switch prompt("> ") { |
| | 184 | case "1": |
| | 185 | listWatchlist(wlID) |
| | 186 | case "2": |
| | 187 | addToWatchlist(wlID) |
| | 188 | case "3": |
| | 189 | removeFromWatchlist(wlID) |
| | 190 | case "0": |
| | 191 | return |
| | 192 | default: |
| | 193 | fmt.Println("Unknown option.") |
| | 194 | } |
| | 195 | } |
| | 196 | } |
| | 197 | |
| | 198 | func ensureDefaultWatchlist(userID string) (string, error) { |
| | 199 | var id string |
| | 200 | err := db.DB.QueryRow( |
| | 201 | `SELECT id FROM watchlists WHERE user_id = $1 ORDER BY created_at LIMIT 1`, |
| | 202 | userID, |
| | 203 | ).Scan(&id) |
| | 204 | if err == sql.ErrNoRows { |
| | 205 | err = db.DB.QueryRow( |
| | 206 | `INSERT INTO watchlists (user_id, name) VALUES ($1, 'Favorites') RETURNING id`, |
| | 207 | userID, |
| | 208 | ).Scan(&id) |
| | 209 | return id, err |
| | 210 | } |
| | 211 | return id, err |
| | 212 | } |
| | 213 | |
| | 214 | func listWatchlist(wlID string) { |
| | 215 | rows, err := db.DB.Query(` |
| | 216 | SELECT c.symbol, c.name, COALESCE(lp.price, 0) |
| | 217 | FROM watchlist_items wi |
| | 218 | JOIN crypto c ON c.id = wi.crypto_id |
| | 219 | LEFT JOIN markets m ON m.crypto_id = c.id AND m.quote_currency = 'USD' |
| | 220 | LEFT JOIN v_latest_prices lp ON lp.market_id = m.id |
| | 221 | WHERE wi.watchlist_id = $1 |
| | 222 | ORDER BY c.symbol`, wlID) |
| | 223 | if err != nil { |
| | 224 | fmt.Println("Error:", err) |
| | 225 | return |
| | 226 | } |
| | 227 | defer rows.Close() |
| | 228 | |
| | 229 | fmt.Println() |
| | 230 | fmt.Printf(" %-8s %-20s %15s\n", "Symbol", "Name", "Last price") |
| | 231 | fmt.Println(" --------------------------------------------------") |
| | 232 | empty := true |
| | 233 | for rows.Next() { |
| | 234 | var sym, name string |
| | 235 | var price float64 |
| | 236 | if err := rows.Scan(&sym, &name, &price); err != nil { |
| | 237 | fmt.Println("scan error:", err) |
| | 238 | return |
| | 239 | } |
| | 240 | fmt.Printf(" %-8s %-20s %15.6f\n", sym, name, price) |
| | 241 | empty = false |
| | 242 | } |
| | 243 | if empty { |
| | 244 | fmt.Println(" (watchlist is empty)") |
| | 245 | } |
| | 246 | } |
| | 247 | |
| | 248 | // addToWatchlist lists the cryptos that are not on the watchlist yet, |
| | 249 | // numbered, and adds the one the user picks. |
| | 250 | func addToWatchlist(wlID string) { |
| | 251 | rows, err := db.DB.Query(` |
| | 252 | SELECT c.id, c.symbol, c.name |
| | 253 | FROM crypto c |
| | 254 | WHERE NOT EXISTS (SELECT 1 FROM watchlist_items wi |
| | 255 | WHERE wi.watchlist_id = $1 AND wi.crypto_id = c.id) |
| | 256 | ORDER BY c.symbol`, wlID) |
| | 257 | if err != nil { |
| | 258 | fmt.Println("Error:", err) |
| | 259 | return |
| | 260 | } |
| | 261 | type option struct{ id, symbol, name string } |
| | 262 | var list []option |
| | 263 | for rows.Next() { |
| | 264 | var o option |
| | 265 | if err := rows.Scan(&o.id, &o.symbol, &o.name); err != nil { |
| | 266 | rows.Close() |
| | 267 | fmt.Println("scan error:", err) |
| | 268 | return |
| | 269 | } |
| | 270 | list = append(list, o) |
| | 271 | } |
| | 272 | rows.Close() |
| | 273 | if len(list) == 0 { |
| | 274 | fmt.Println("Every crypto is already on your watchlist.") |
| | 275 | return |
| | 276 | } |
| | 277 | fmt.Println() |
| | 278 | fmt.Printf(" %-4s %-8s %s\n", "#", "Symbol", "Name") |
| | 279 | fmt.Println(" ------------------------------") |
| | 280 | for i, o := range list { |
| | 281 | fmt.Printf(" %-4d %-8s %s\n", i+1, o.symbol, o.name) |
| | 282 | } |
| | 283 | k, err := pickNumber("Crypto # to add: ", len(list)) |
| | 284 | if err != nil { |
| | 285 | fmt.Println(err) |
| | 286 | return |
| | 287 | } |
| | 288 | _, err = db.DB.Exec( |
| | 289 | `INSERT INTO watchlist_items (watchlist_id, crypto_id) |
| | 290 | VALUES ($1, $2) |
| | 291 | ON CONFLICT (watchlist_id, crypto_id) DO NOTHING`, |
| | 292 | wlID, list[k].id, |
| | 293 | ) |
| | 294 | if err != nil { |
| | 295 | fmt.Println("Error:", err) |
| | 296 | return |
| | 297 | } |
| | 298 | fmt.Printf("Added %s.\n", list[k].symbol) |
| | 299 | } |
| | 300 | |
| | 301 | // removeFromWatchlist lists the watchlist's cryptos, numbered, and removes |
| | 302 | // the one the user picks. |
| | 303 | func removeFromWatchlist(wlID string) { |
| | 304 | rows, err := db.DB.Query(` |
| | 305 | SELECT c.id, c.symbol, c.name |
| | 306 | FROM watchlist_items wi |
| | 307 | JOIN crypto c ON c.id = wi.crypto_id |
| | 308 | WHERE wi.watchlist_id = $1 |
| | 309 | ORDER BY c.symbol`, wlID) |
| | 310 | if err != nil { |
| | 311 | fmt.Println("Error:", err) |
| | 312 | return |
| | 313 | } |
| | 314 | type option struct{ id, symbol, name string } |
| | 315 | var list []option |
| | 316 | for rows.Next() { |
| | 317 | var o option |
| | 318 | if err := rows.Scan(&o.id, &o.symbol, &o.name); err != nil { |
| | 319 | rows.Close() |
| | 320 | fmt.Println("scan error:", err) |
| | 321 | return |
| | 322 | } |
| | 323 | list = append(list, o) |
| | 324 | } |
| | 325 | rows.Close() |
| | 326 | if len(list) == 0 { |
| | 327 | fmt.Println("Your watchlist is empty.") |
| | 328 | return |
| | 329 | } |
| | 330 | fmt.Println() |
| | 331 | fmt.Printf(" %-4s %-8s %s\n", "#", "Symbol", "Name") |
| | 332 | fmt.Println(" ------------------------------") |
| | 333 | for i, o := range list { |
| | 334 | fmt.Printf(" %-4d %-8s %s\n", i+1, o.symbol, o.name) |
| | 335 | } |
| | 336 | k, err := pickNumber("Crypto # to remove: ", len(list)) |
| | 337 | if err != nil { |
| | 338 | fmt.Println(err) |
| | 339 | return |
| | 340 | } |
| | 341 | if _, err := db.DB.Exec( |
| | 342 | `DELETE FROM watchlist_items WHERE watchlist_id = $1 AND crypto_id = $2`, |
| | 343 | wlID, list[k].id, |
| | 344 | ); err != nil { |
| | 345 | fmt.Println("Error:", err) |
| | 346 | return |
| | 347 | } |
| | 348 | fmt.Printf("Removed %s.\n", list[k].symbol) |
| | 349 | } |
| | 350 | }}} |
| | 351 | |
| | 352 | `server/market.go` — `pickNumber`: |
| | 353 | |
| | 354 | {{{ |
| | 355 | // pickNumber reads a 1-based choice from a list of n items. |
| | 356 | func pickNumber(label string, n int) (int, error) { |
| | 357 | if n == 0 { |
| | 358 | return 0, fmt.Errorf("Nothing to choose from.") |
| | 359 | } |
| | 360 | k, err := strconv.Atoi(prompt(label)) |
| | 361 | if err != nil || k < 1 || k > n { |
| | 362 | return 0, fmt.Errorf("Invalid choice, enter a number from 1 to %d.", n) |
| | 363 | } |
| | 364 | return k - 1, nil |
| | 365 | } |
| | 366 | }}} |