Changes between Initial Version and Version 1 of UseCase0007Implementation


Ignore:
Timestamp:
09/24/26 14:03:41 (4 days ago)
Author:
231285
Comment:

--

Legend:

Unmodified
Added
Removed
Modified
  • UseCase0007Implementation

    v1 v1  
     1= Use-case 0007 Implementation - Manage watchlist =
     2
     3'''Initiating actor:''' Trader
     4
     5'''Other actors:''' —
     6
     7A logged-in Trader keeps a list of crypto assets they want to monitor, with the last
     8price of each. The first time the watchlist is opened the system creates a default
     9watchlist named "Favorites" for the Trader. From a sub-menu the Trader can list the
     10watchlist, add a crypto or remove one. The Trader never types a symbol: for adding, the
     11system lists, numbered, only the cryptos that are not on the watchlist yet, and for
     12removing, only the cryptos that are on it; the Trader picks one by its number. Adding
     13a crypto that is already on the list is a no-op (idempotent), and a number that is not
     14in the list is refused without touching the database.
     15
     16Original use-case description (P3): [wiki:UseCase0007].
     17Implementation: `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
     22All 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
     24Go code; only the Go source indentation is removed.
     25
     26The run shown is user `alice` on the seed data, whose watchlist contains BTC, ETH and
     27SOL. She lists it, adds ADA, tries to remove a number that is not in the list, removes
     28SOL 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{{{
     37SELECT id FROM watchlists WHERE user_id = $1 ORDER BY created_at LIMIT 1
     38}}}
     39
     40Only if this returns no row, it creates the default watchlist and uses its id:
     41
     42{{{
     43INSERT 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
     47watchlist 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{{{
     61SELECT 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
     70For 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
     72shows 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{{{
     83SELECT 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
     90For 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
     92already on the watchlist, it prints `Every crypto is already on your watchlist.`
     93instead.)
     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{{{
     104INSERT INTO watchlist_items (watchlist_id, crypto_id)
     105 VALUES ($1, $2)
     106 ON CONFLICT (watchlist_id, crypto_id) DO NOTHING
     107}}}
     108
     109It 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{{{
     120SELECT 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
     127For 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
     129memory. (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{{{
     138DELETE FROM watchlist_items WHERE watchlist_id = $1 AND crypto_id = $2
     139}}}
     140
     141It 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
     147Before removing SOL, alice first chose `[3] Remove crypto` and, at step 12, entered `5`
     148while 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
     150is shown again; she then chose `[3]` once more, which returned the scenario to step 11.
     151The 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
     157Choosing `[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`:
     159ADA 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.
     171func 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
     198func 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
     214func 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.
     250func 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.
     303func 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.
     356func 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}}}