aboutsummaryrefslogtreecommitdiff
path: root/components/watchlist.py
blob: 64f57f8fa1ebc7236164b6c768001f9defb7226d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
"""Watchlist sidebar component — session-scoped personal watchlist."""
import streamlit as st
import streamlit.components.v1 as components
from services.data_service import get_latest_price, get_company_info
from utils.security import escape_html


def render_watchlist():
    """Render the personal watchlist section in the sidebar.

    Displays a section label, then one clickable row per saved ticker showing
    symbol · price · Δ%. Clicking a row sets st.session_state["ticker"] and
    triggers a rerun. Shows an empty-state message when the list is empty.

    Must be called from within a `with st.sidebar:` block.
    """
    watchlist = st.session_state.get("watchlist", [])

    # Process any pending click from the JS click handler
    clicked = st.session_state.get("_wl_click", "")
    if clicked and clicked in watchlist:
        st.session_state["ticker"] = clicked
        st.session_state["_wl_click"] = ""
        st.rerun()

    # Hidden click receiver — JS will set this input's value on row click
    st.text_input("wl_click_receiver", key="_wl_click", label_visibility="collapsed")

    # Build rows HTML (string concatenation, NOT f-strings — JS {} would break)
    rows_html = ""
    for sym in watchlist:
        try:
            price = get_latest_price(sym)
            info = get_company_info(sym) or {}
            prev = info.get("regularMarketPreviousClose") or info.get("previousClose")
            sym_e = escape_html(sym)
            if price is not None and prev and prev > 0:
                chg_pct = (price - prev) / prev * 100
                sign = "+" if chg_pct >= 0 else "−"
                chg_cls = "pos" if chg_pct >= 0 else "neg"
                chg_str = sign + f"{abs(chg_pct):.2f}%"
                px_str = "$" + f"{price:,.2f}"
            elif price is not None:
                chg_cls = "flat"
                chg_str = "—"
                px_str = "$" + f"{price:,.2f}"
            else:
                chg_cls = "flat"
                chg_str = "—"
                px_str = "—"
            rows_html += (
                "<div class='wl-row' data-sym='" + sym_e + "' onclick='selectTicker(this.dataset.sym)'>"
                "<span class='wl-sym'>" + sym_e + "</span>"
                "<span class='wl-px'>" + px_str + "</span>"
                "<span class='wl-chg " + chg_cls + "'>" + chg_str + "</span>"
                "</div>"
            )
        except Exception:
            sym_e = escape_html(sym)
            rows_html += (
                "<div class='wl-row' data-sym='" + sym_e + "' onclick='selectTicker(this.dataset.sym)'>"
                "<span class='wl-sym'>" + sym_e + "</span>"
                "<span class='wl-px wl-err'>—</span>"
                "<span class='wl-chg flat'>—</span>"
                "</div>"
            )

    if not rows_html:
        content_html = "<div class='wl-empty'>No saved tickers</div>"
    else:
        content_html = rows_html

    height = 28 + max(len(watchlist), 1) * 34 + 16  # label + rows + buffer

    # Build the full HTML block (string concat, not f-string — JS {} would break)
    html = (
        "<style>"
        "* { margin: 0; padding: 0; box-sizing: border-box; }"
        "body { background: transparent; overflow: hidden; font-family: 'IBM Plex Mono', monospace; }"
        ".wl-label { font-family: 'IBM Plex Sans', sans-serif; font-size: 10px; font-weight: 600;"
        "  text-transform: uppercase; letter-spacing: 0.14em; color: #5E5849; padding: 0 0 6px; display: block; }"
        ".wl-row { display: grid; grid-template-columns: 1fr auto auto; gap: 8px;"
        "  align-items: center; padding: 6px 4px; border-bottom: 1px solid #232934;"
        "  cursor: pointer; transition: background 0.1s; }"
        ".wl-row:last-child { border-bottom: none; }"
        ".wl-row:hover { background: #11151C; }"
        ".wl-sym { font-size: 12px; color: #F2ECDC; font-weight: 500; }"
        ".wl-px { font-size: 12px; color: #C7C0AE; font-variant-numeric: tabular-nums; text-align: right; }"
        ".wl-chg { font-size: 11px; min-width: 54px; text-align: right; font-variant-numeric: tabular-nums; }"
        ".wl-chg.pos { color: #4F8C5E; }"
        ".wl-chg.neg { color: #B5494B; }"
        ".wl-chg.flat { color: #8E8676; }"
        ".wl-empty { font-family: 'IBM Plex Sans', sans-serif; font-size: 11px; color: #5E5849;"
        "  font-style: italic; padding: 4px 0 8px; }"
        "</style>"
        "<span class='wl-label'>Watchlist</span>"
        + content_html +
        "<script>"
        "function selectTicker(sym) {"
        "  try {"
        "    var inputs = window.parent.document.querySelectorAll('input[type=text]');"
        "    var target = null;"
        "    for (var i = 0; i < inputs.length; i++) {"
        "      var inp = inputs[i];"
        "      if (inp.getAttribute('aria-label') === 'wl_click_receiver') { target = inp; break; }"
        "    }"
        "    if (!target) return;"
        "    var setter = Object.getOwnPropertyDescriptor(window.parent.HTMLInputElement.prototype, 'value').set;"
        "    setter.call(target, sym);"
        "    target.dispatchEvent(new window.parent.Event('input', { bubbles: true }));"
        "  } catch(e) { console.warn('watchlist click failed', e); }"
        "}"
        "</script>"
    )

    components.html(html, height=height, scrolling=False)