/* 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}
}
Annulla
{confirmLabel}
);
}
// ── 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
✕ Chiudi
{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 (
add(r.symbol)}>
{r.symbol}
{r.description}
{have ? "già presente" : "+"}
);
})}
{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 (
‹ Indietro
{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 => (
setTf(t)}>{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 (
‹ Liste
{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}
setShowAdd(v => !v)}>{showAdd ? "✕" : "+ Ticker"}
{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}
{ e.stopPropagation(); setConfirmDel({ id: g.id, name: 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()} />
{creating ? "…" : "+ Crea lista"}
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 ;
if (groups === null) return ;
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"); }} />;
};
})();