aboutsummaryrefslogtreecommitdiff
path: root/components/watchlist.py
diff options
context:
space:
mode:
Diffstat (limited to 'components/watchlist.py')
-rw-r--r--components/watchlist.py184
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)