/* watchlist.jsx — watchlist personale per-utente (stile Investing). 3 schermate: gruppi → lista ticker (prezzo + Δ% live) → dettaglio grafico. Prezzi live via /api/quotes (WebSocket Finnhub lato server). Avatar = monogramma del ticker (scelta utente 12/6: niente loghi → niente chiamate profile2 dietro il throttle, zero rallentamenti). Drag&drop dei gruppi. FUORI dal forward-test freeze: solo visualizzazione, nessun segnale. */ (function () { const { useState, useEffect, useRef } = React; // ── helper API ────────────────────────────────────────────────────────── const _api = (method, path, body) => fetch(path, { method, headers: body ? { "Content-Type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined, }).then(r => r.json()); // conversione leva3 (l'utente opera a leva3: la % reale triplica) const _lev3 = (dp) => (dp == null ? "" : (dp * 3 >= 0 ? "+" : "") + (dp * 3).toFixed(1) + "%"); // ── hook: prezzi live (poll /api/quotes mentre la schermata è attiva) ───── function useLiveQuotes(symbols, active) { const [quotes, setQuotes] = useState({}); const [ws, setWs] = useState("off"); const [mktOpen, setMktOpen] = useState(false); const key = symbols.join(","); const ref = useRef(symbols); ref.current = symbols; useEffect(() => { if (!active || !symbols.length) return; let stop = false; const poll = () => { if (document.visibilityState !== "visible") return; const s = ref.current; if (!s.length) return; fetch(`/api/quotes?symbols=${encodeURIComponent(s.join(","))}`) .then(r => r.json()) .then(d => { if (stop) return; setQuotes(d.quotes || {}); setWs(d.ws || "off"); setMktOpen(!!d.market_open); }) .catch(() => {}); }; poll(); const t = setInterval(poll, 2500); const onVis = () => { if (document.visibilityState === "visible") poll(); }; document.addEventListener("visibilitychange", onVis); return () => { stop = true; clearInterval(t); document.removeEventListener("visibilitychange", onVis); }; }, [active, key]); return { quotes, ws, mktOpen }; } // ── hook: nomi società (risoluzione async lato server, con retry) ───────── function useMeta(symbols) { const [meta, setMeta] = useState({}); const key = symbols.join(","); useEffect(() => { if (!symbols.length) return; let stop = false, tries = 0; const load = () => { fetch(`/api/symbol-meta?symbols=${encodeURIComponent(symbols.join(","))}`) .then(r => r.json()) .then(d => { if (stop) return; setMeta(d.meta || {}); const pending = symbols.some(s => !d.meta?.[s]?.name || d.meta[s].name === s); tries++; if (pending && tries < 3) setTimeout(load, 3000); }) .catch(() => {}); }; load(); return () => { stop = true; }; }, [key]); return meta; } // ── avatar: monogramma del ticker (ZERO rete, niente loghi) ─────────────── function MonoAvatar({ symbol, size = 34 }) { const hue = [...symbol].reduce((a, c) => a + c.charCodeAt(0), 0) % 360; return (
{symbol.slice(0, 2)}
); } // ── modale di conferma in stile sito (sostituisce window.confirm) ───────── function WlConfirm({ title, text, confirmLabel = "Elimina", onCancel, onConfirm }) { return (
e.stopPropagation()}>
{title}
{text &&
{text}
}
); } // ── pillola Δ% (verde/rosso) ────────────────────────────────────────────── function DeltaPill({ dp }) { if (dp == null) return ; const cls = dp >= 0 ? "pos" : "neg"; return {(dp >= 0 ? "▲ " : "▼ ") + Math.abs(dp).toFixed(2) + "%"}; } // ── riga ticker (prezzo live + pulse) ───────────────────────────────────── function WlTickerRow({ symbol, name, q, onOpen, onRemove }) { const price = q?.price; const dp = q?.dp; const live = q?.live; const prev = useRef(price); const [flash, setFlash] = useState(""); useEffect(() => { if (live && price != null && prev.current != null && price !== prev.current) { setFlash(price > prev.current ? "flash-up" : "flash-down"); const t = setTimeout(() => setFlash(""), 650); prev.current = price; return () => clearTimeout(t); } prev.current = price; }, [price, live]); return (
{name || symbol} {symbol}{live && }
{price ? price.toFixed(2) : "—"}
); } // ── lista ticker: ordinata per Δ% giornaliera (dal più alto al più basso), // come "Relativa giornaliera" di Investing. Senza Δ% (quote non ancora // arrivata) → in coda, nell'ordine di inserimento. function WlTickerList({ tickers, quotes, meta, onOpen, onRemove }) { const sorted = [...tickers].sort((a, b) => { const da = quotes[a] ? quotes[a].dp : null; const db = quotes[b] ? quotes[b].dp : null; if (da == null && db == null) return 0; if (da == null) return 1; if (db == null) return -1; return db - da; }); return (
{sorted.map(sym => ( onOpen(sym)} onRemove={(e) => { e.stopPropagation(); onRemove(sym); }} /> ))}
); } // ── pannello "aggiungi ticker" (ricerca con debounce) ───────────────────── function WlAddPanel({ gid, existing, full, onAdded, onClose }) { const [q, setQ] = useState(""); const [results, setResults] = useState([]); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { if (!q.trim()) { setResults([]); return; } const t = setTimeout(() => { fetch(`/api/symbol-search?q=${encodeURIComponent(q.trim())}`) .then(r => r.json()).then(d => setResults(d.results || [])).catch(() => setResults([])); }, 300); return () => clearTimeout(t); }, [q]); const add = (sym) => { setBusy(true); setErr(""); _api("POST", `/api/watchlist/groups/${gid}/tickers`, { symbol: sym }) .then(d => { if (d.groups) { onAdded(d.groups); setQ(""); } else if (d.error) setErr(d.error); }) .finally(() => setBusy(false)); }; return (
Aggiungi ticker
{full ? (
Gruppo pieno (50). Rimuovi un ticker per aggiungerne un altro.
) : ( <> setQ(e.target.value)} /> {err &&
{err}
}
{results.map(r => { const have = existing.includes(r.symbol); return ( ); })} {q.trim() && results.length === 0 &&
Nessun risultato per «{q.trim()}».
}
)}
); } // ── schermata: dettaglio ticker (grafico) ───────────────────────────────── const _TFS = ["1G", "1S", "1M", "1A", "Max"]; function WlChart({ symbol, tf, livePrice, live }) { const containerRef = useRef(null); const chartRef = useRef(null); const seriesRef = useRef(null); const cacheRef = useRef({}); const lastTimeRef = useRef(null); // time dell'ultimo punto (per il live update) const firstValRef = useRef(null); // primo valore (per ricolorare verde/rosso live) const [loading, setLoading] = useState(false); const [empty, setEmpty] = useState(false); useEffect(() => { if (!containerRef.current || !window.LightweightCharts) return; const container = containerRef.current; let chart; try { chart = LightweightCharts.createChart(container, { width: Math.max(100, container.clientWidth), height: Math.max(100, container.clientHeight), layout: { background: { color: "transparent" }, textColor: "#8892a4", fontFamily: "JetBrains Mono, monospace" }, grid: { vertLines: { color: "rgba(255,255,255,0.02)" }, horzLines: { color: "rgba(255,255,255,0.05)" } }, crosshair: { mode: LightweightCharts.CrosshairMode.Normal, vertLine: { color: "rgba(150,164,196,0.35)", width: 1, style: LightweightCharts.LineStyle.Dashed, labelBackgroundColor: "#2b3442" }, horzLine: { color: "rgba(150,164,196,0.35)", width: 1, style: LightweightCharts.LineStyle.Dashed, labelBackgroundColor: "#2b3442" }, }, rightPriceScale: { borderColor: "rgba(255,255,255,0.07)" }, timeScale: { borderColor: "rgba(255,255,255,0.07)", timeVisible: true, secondsVisible: false }, }); } catch (e) { console.warn("WlChart init failed", e); return; } chart.priceScale("right").applyOptions({ scaleMargins: { top: 0.12, bottom: 0.12 } }); const series = chart.addAreaSeries({ lineColor: "#3b82f6", topColor: "rgba(59,130,246,0.22)", bottomColor: "rgba(59,130,246,0)", lineWidth: 2, priceLineVisible: false, lastValueVisible: true, }); chartRef.current = chart; seriesRef.current = series; const ro = new ResizeObserver(() => chart.applyOptions({ width: container.clientWidth, height: container.clientHeight })); ro.observe(container); return () => { ro.disconnect(); chart.remove(); chartRef.current = null; }; }, []); const _recolor = (up) => { seriesRef.current.applyOptions({ lineColor: up ? "#22c55e" : "#ef4444", topColor: up ? "rgba(34,197,94,0.20)" : "rgba(239,68,68,0.20)", bottomColor: up ? "rgba(34,197,94,0)" : "rgba(239,68,68,0)", }); }; useEffect(() => { if (!chartRef.current) return; let cancelled = false; const apply = (candles) => { if (!candles || !candles.length) { setEmpty(true); seriesRef.current.setData([]); return; } setEmpty(false); const data = candles.map(c => ({ time: c.time, value: c.close })); lastTimeRef.current = data[data.length - 1].time; firstValRef.current = data[0].value; _recolor(data[data.length - 1].value >= data[0].value); seriesRef.current.setData(data); chartRef.current.timeScale().fitContent(); }; const ck = `${symbol}|${tf}`; const load = (force) => { if (!force && cacheRef.current[ck]) { apply(cacheRef.current[ck]); return; } if (!force) setLoading(true); fetch(`/api/wl-candles/${encodeURIComponent(symbol)}?tf=${tf}`) .then(r => r.json()) .then(d => { const c = d.candles || []; if (force && !c.length) return; // hiccup yfinance: non svuotare il grafico cacheRef.current[ck] = c; if (!cancelled) apply(c); }) .catch(e => { console.warn("wl-candles", e); if (!force) setEmpty(true); }) .finally(() => { if (!force) setLoading(false); }); }; load(false); // refetch live: su 1G/1S nuove candele 5m/30m ogni minuto (TTL backend 60s) let t = null; if (tf === "1G" || tf === "1S") t = setInterval(() => load(true), 60000); return () => { cancelled = true; if (t) clearInterval(t); }; }, [symbol, tf]); // live: l'ultimo punto della linea segue il prezzo del WebSocket (come Investing) useEffect(() => { if (!seriesRef.current || !live || livePrice == null || !lastTimeRef.current) return; try { seriesRef.current.update({ time: lastTimeRef.current, value: livePrice }); if (firstValRef.current != null) _recolor(livePrice >= firstValRef.current); } catch (e) { console.warn("wl-chart live update:", e); } }, [livePrice, live]); return (
{loading &&
Caricamento…
} {empty && !loading &&
Nessun dato disponibile per {symbol}.
}
); } function WlTickerScreen({ symbol, onBack }) { const [tf, setTf] = useState("1G"); const { quotes } = useLiveQuotes([symbol], true); const meta = useMeta([symbol]); const name = meta[symbol]?.name; const q = quotes[symbol] || {}; const dp = q.dp; return (
{name || symbol}
{symbol}
{q.price ? q.price.toFixed(2) : "—"}
{dp != null && (
Con la tua leva3 oggi: = 0 ? "pos" : "neg"}>{_lev3(dp)} (={Math.abs(dp).toFixed(2)}% × 3)
)}
{_TFS.map(t => ( ))}
Prezzi USA in tempo reale durante l'orario di mercato (Finnhub). Grafico via yfinance.
); } // ── schermata: gruppo (lista ticker) ────────────────────────────────────── function WlGroupScreen({ group, onBack, onOpenTicker, setGroups }) { const [showAdd, setShowAdd] = useState(false); const [editing, setEditing] = useState(false); const [nm, setNm] = useState(group.name); const tickers = group.tickers || []; const { quotes, ws, mktOpen } = useLiveQuotes(tickers, true); const meta = useMeta(tickers); const removeTicker = (sym) => _api("DELETE", `/api/watchlist/groups/${group.id}/tickers/${encodeURIComponent(sym)}`) .then(d => d.groups && setGroups(d.groups)); const rename = () => { const v = nm.trim(); if (!v || v === group.name) { setEditing(false); return; } _api("PATCH", `/api/watchlist/groups/${group.id}`, { name: v }) .then(d => { if (d.groups) setGroups(d.groups); setEditing(false); }); }; const wsLabel = !tickers.length ? "" : (ws === "connected" && mktOpen) ? "● live" : mktOpen ? "in connessione…" : "mercato chiuso · ultimo prezzo"; return (
{editing ? ( setNm(e.target.value)} onKeyDown={e => e.key === "Enter" && rename()} onBlur={rename} /> ) : (

{ setNm(group.name); setEditing(true); }} title="Clicca per rinominare"> {group.name} ({tickers.length})

)}
{wsLabel}
{showAdd && ( = 50} onAdded={(g) => setGroups(g)} onClose={() => setShowAdd(false)} /> )} {tickers.length === 0 ? (
Nessun ticker in questa lista. Premi + Ticker per cercarne e aggiungerne.
) : ( <>
Ordinati per variazione giornaliera (dal più alto al più basso).
)}
); } // ── schermata: elenco gruppi (riordinabili con drag&drop dalla maniglia) ── function WlGroupsScreen({ groups, onOpen, setGroups }) { const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false); const [confirmDel, setConfirmDel] = useState(null); // {id, name} | null const [cards, setCards] = useState(groups); const [dragIdx, setDragIdx] = useState(-1); const gridRef = useRef(null); useEffect(() => { if (dragIdx < 0) setCards(groups); }, [groups]); // ri-fetch di sicurezza: se la risposta di una mutazione si perde // (es. proxy che mangia il body della DELETE), lo stato si riallinea comunque const refresh = () => _api("GET", "/api/watchlist/groups").then(d => d.groups && setGroups(d.groups)).catch(() => {}); const create = () => { const v = newName.trim(); if (!v || creating) return; setCreating(true); _api("POST", "/api/watchlist/groups", { name: v }) .then(d => { if (d.groups) { setGroups(d.groups); setNewName(""); } }) .finally(() => setCreating(false)); }; const doDelete = () => { const gid = confirmDel?.id; setConfirmDel(null); if (!gid) return; setCards(prev => prev.filter(g => g.id !== gid)); // ottimistico: sparisce subito _api("DELETE", `/api/watchlist/groups/${gid}`) .then(d => { if (d.groups) setGroups(d.groups); else refresh(); }) .catch(refresh); }; // drag&drop card gruppo: hit-test sui rect della grid (funziona anche multi-colonna) const down = (e, idx) => { e.preventDefault(); e.stopPropagation(); setDragIdx(idx); try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {} }; const move = (e) => { if (dragIdx < 0) return; const els = gridRef.current ? [...gridRef.current.querySelectorAll(".wl-grp-card")] : []; let ti = -1; els.forEach((el, i) => { const r = el.getBoundingClientRect(); if (e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom) ti = i; }); if (ti >= 0 && ti !== dragIdx) { setCards(prev => { const a = prev.slice(); const [it] = a.splice(dragIdx, 1); a.splice(ti, 0, it); return a; }); setDragIdx(ti); } }; const up = (e) => { if (dragIdx < 0) return; try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {} setDragIdx(-1); const ids = cards.map(g => g.id); if (ids.join(",") !== groups.map(g => g.id).join(",")) _api("POST", "/api/watchlist/groups/reorder", { order: ids }) .then(d => d.groups && setGroups(d.groups)); }; return (
Le tue liste {cards.length} {cards.length === 1 ? "lista" : "liste"}
{cards.map((g, idx) => (
dragIdx < 0 && onOpen(g.id)}>
e.stopPropagation()} onPointerDown={e => down(e, idx)} onPointerMove={move} onPointerUp={up} onPointerCancel={up}> {g.name}
{(g.tickers || []).length} {(g.tickers || []).length === 1 ? "titolo" : "titoli"}
{(g.tickers || []).slice(0, 6).map(s => {s})} {(g.tickers || []).length > 6 && +{g.tickers.length - 6}}
))}
setNewName(e.target.value)} onKeyDown={e => e.key === "Enter" && create()} />
Watchlist personale: ogni utente ha le sue liste. Prezzi USA in tempo reale durante l'orario di mercato. Max 50 ticker per lista.
{confirmDel && ( setConfirmDel(null)} onConfirm={doDelete} /> )}
); } // ── vista principale ────────────────────────────────────────────────────── window.WatchlistView = function WatchlistView() { const [groups, setGroups] = useState(null); // null = caricamento const [screen, setScreen] = useState("groups"); const [openGid, setOpenGid] = useState(null); const [openSym, setOpenSym] = useState(null); const [err, setErr] = useState(null); useEffect(() => { _api("GET", "/api/watchlist/groups") .then(d => { if (d.error) setErr(d.error); else setGroups(d.groups || []); }) .catch(e => setErr(String(e))); }, []); if (err) return
Errore: {err}
; if (groups === null) return
Caricamento…
; const openGroup = groups.find(g => g.id === openGid); if (screen === "ticker" && openSym) { return setScreen(openGid ? "group" : "groups")} />; } if (screen === "group" && openGroup) { return { setScreen("groups"); setOpenGid(null); }} onOpenTicker={(sym) => { setOpenSym(sym); setScreen("ticker"); }} setGroups={setGroups} />; } return { setOpenGid(gid); setScreen("group"); }} />; }; })();