diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-05-16 01:40:02 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-05-16 01:40:02 -0700 |
| commit | bbceb4c6798d43f4b32e73f38fc4907e00733244 (patch) | |
| tree | aa5ff1fed343e92fbd3d9f7e05859f621bb217f2 /components | |
| parent | b321614ef1aabf3ce001fea471e45bebc35ccb86 (diff) | |
Rewrite watchlist as custom HTML component with proper styling
Replaces st.button rows with a components.html iframe that renders sym·price·Δ%
in a 3-column grid with hairline dividers, monospace fonts, and colored change
percentages. Adds hidden text_input click receiver so row clicks update
session state. Fixes toggle button to show toast when watchlist cap is hit and
removes now-redundant psm-watch-toggle CSS.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'components')
| -rw-r--r-- | components/watchlist.py | 184 |
1 files changed, 87 insertions, 97 deletions
diff --git a/components/watchlist.py b/components/watchlist.py index 0b50d3b..64f57f8 100644 --- a/components/watchlist.py +++ b/components/watchlist.py @@ -1,66 +1,9 @@ """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 -_WATCHLIST_CSS_INJECTED = False - - -def _inject_css(): - global _WATCHLIST_CSS_INJECTED - if _WATCHLIST_CSS_INJECTED: - return - _WATCHLIST_CSS_INJECTED = True - st.markdown(""" -<style> -/* ── Watchlist section label ───────────────────────────────────────────── */ -.psm-watch-label { - font-family: var(--font-sans); - font-size: 10px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.14em; - color: var(--fg-4); - margin: 0.5rem 0 0.25rem; -} - -/* ── Watchlist row button overrides ────────────────────────────────────── */ -[data-testid="stSidebar"] .psm-watch-btn button { - background: transparent !important; - border: none !important; - border-bottom: 1px solid var(--line-1) !important; - border-radius: 0 !important; - padding: 0.3rem 0 !important; - width: 100% !important; - text-align: left !important; - cursor: pointer !important; - display: flex !important; - align-items: center !important; -} - -[data-testid="stSidebar"] .psm-watch-btn button:hover { - background: var(--ink-2) !important; -} - -[data-testid="stSidebar"] .psm-watch-btn button p { - font-family: var(--font-mono) !important; - font-size: 12px !important; - color: var(--fg-1) !important; - margin: 0 !important; - line-height: 1.3 !important; -} - -/* ── Watchlist empty state ──────────────────────────────────────────────── */ -.psm-watch-empty { - font-family: var(--font-sans); - font-size: 11px; - color: var(--fg-4); - padding: 0.25rem 0 0.5rem; - font-style: italic; -} -</style> -""", unsafe_allow_html=True) - def render_watchlist(): """Render the personal watchlist section in the sidebar. @@ -71,56 +14,103 @@ def render_watchlist(): Must be called from within a `with st.sidebar:` block. """ - _inject_css() - - watchlist: list[str] = st.session_state.get("watchlist", []) + watchlist = st.session_state.get("watchlist", []) - st.markdown('<div class="psm-watch-label">Watchlist</div>', unsafe_allow_html=True) + # 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() - if not watchlist: - st.markdown( - '<div class="psm-watch-empty">No saved tickers</div>', - unsafe_allow_html=True, - ) - return + # 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_color = "#4F8C5E" if chg_pct >= 0 else "#B5494B" - row_label = ( - escape_html(sym) - + " $" + f"{price:,.2f}" - + " " + sign + f"{chg_pct:.2f}%" - ) + 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_color = "#8E8676" - row_label = escape_html(sym) + " $" + f"{price:,.2f}" + chg_cls = "flat" + chg_str = "—" + px_str = "$" + f"{price:,.2f}" else: - chg_color = "#8E8676" - row_label = escape_html(sym) + " —" - - # Render a styled container div then the Streamlit button inside it - st.markdown('<div class="psm-watch-btn">', unsafe_allow_html=True) - if st.button(row_label, key="watch_row_" + sym, use_container_width=True): - st.session_state["ticker"] = sym - st.rerun() - st.markdown("</div>", unsafe_allow_html=True) - + 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: - # Never let one broken ticker blow up the whole watchlist - sym_safe = escape_html(sym) - st.markdown( - '<div class="psm-watch-btn">', - unsafe_allow_html=True, + 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 st.button(sym_safe + " (error)", key="watch_row_" + sym, use_container_width=True): - st.session_state["ticker"] = sym - st.rerun() - st.markdown("</div>", unsafe_allow_html=True) + + 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) |
