// App shell: sidebar + topbar + content area
const NAV = [
{ id: "screener", label: "Home", icon: "home", group: "Analisi" },
{ id: "watchlist", label: "Watchlist", icon: "star", group: "Analisi" },
{ id: "dashboard", label: "Dashboard", icon: "candles", group: "Analisi" },
{ id: "calendar", label: "Calendario",icon: "calendar", group: "Analisi" },
{ id: "news", label: "News", icon: "bell", group: "Analisi" },
{ id: "sectors", label: "Settori", icon: "trending", group: "Analisi" },
{ id: "premarket", label: "Pre-market",icon: "clock", group: "Analisi" },
{ id: "picks", label: "Top Picks", icon: "spark", group: "Analisi" },
{ id: "chain", label: "Chain", icon: "chain", group: "Analisi" },
{ id: "report", label: "Report", icon: "fileText", group: "Strumenti" },
{ id: "accuracy", label: "Accuratezza", icon: "check", group: "Strumenti" },
{ id: "simulator", label: "Simulatore", icon: "chart", group: "Strumenti" },
{ id: "sleeves", label: "Sleeves", icon: "chart", group: "Strumenti" },
{ id: "evaluate", label: "Evaluate", icon: "beaker", group: "Strumenti" },
{ id: "config", label: "Configurazione", icon: "settings", group: "Sistema" },
{ id: "logs", label: "Log", icon: "terminal", group: "Sistema" },
{ id: "costs", label: "Costi", icon: "coins", group: "Sistema", adminOnly: true },
{ id: "admin", label: "Utenti", icon: "users", group: "Sistema", adminOnly: true },
];
const TAB_TITLES = {
dashboard: { t: "Dashboard", s: "Watchlist · candele 5m · forecast multi-tool" },
screener: { t: "Home", s: "Panoramica segnali su tutto il watchlist" },
watchlist: { t: "Watchlist", s: "Le tue liste personali · prezzo e variazione giornaliera in tempo reale" },
calendar: { t: "Calendario", s: "Earnings, eventi Fed, macro" },
news: { t: "News", s: "Agenda manuale dei catalizzatori · lanci, prodotti, IPO · digest Telegram del lunedì" },
sectors: { t: "Settori", s: "SPDR sector ETFs · momentum · alert Telegram" },
premarket: { t: "Pre-market", s: "Gap-and-Crap detector · futures · score 0-100" },
picks: { t: "Top Picks", s: "Stock Discovery · 5 factor quant + catalyst AI · crescita attesa prossimo mese (~30gg)" },
chain: { t: "Chain", s: "Lead-lag · quando A sale, i follower seguono 1-5gg · corr returns + Granger" },
report: { t: "Report", s: "Report di sintesi più recenti" },
accuracy: { t: "Accuratezza", s: "Previsioni verificate vs reale · dir-accuracy, bias, salute dati" },
simulator: { t: "Simulatore", s: "Paper-trading settimanale · denaro fittizio · vs buy&hold/SPY" },
sleeves: { t: "Sleeves", s: "Forward-test pre-registrato · 4 strategie meccaniche · 12 settimane vs €90/sett" },
evaluate: { t: "Evaluate", s: "Valuta i CSV esportati dei tre tool" },
config: { t: "Configurazione", s: "Tool attivi, visibilità, JSON editor" },
logs: { t: "Log", s: "Output del daemon · refresh ogni 30s" },
costs: { t: "Costi", s: "Costi API a pagamento (Anthropic + Perplexity) · per provider, mese, tool" },
admin: { t: "Utenti", s: "Gestione account e 2FA Telegram" },
};
// ── Risk Banner (Tool0) — striscia di stato rischio sempre visibile ───────────
const _RISK_REG = {
neutral: { cls: "neutral", label: "NEUTRALE" },
elevated: { cls: "elevated", label: "RISCHIO ELEVATO" },
risk_off: { cls: "risk-off", label: "RISK-OFF" },
};
function RiskBanner({ dataLoaded }) {
const [risk, setRisk] = useState(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!dataLoaded) return;
const load = () => fetch("/api/tool0-risk").then(r => r.json()).then(setRisk).catch(() => {});
load();
const t = setInterval(load, 5 * 60 * 1000);
return () => clearInterval(t);
}, [dataLoaded]);
if (!risk || !risk.regime) return null;
const r = _RISK_REG[risk.regime] || _RISK_REG.neutral;
const triggers = risk.triggers || [];
return (
setOpen(o => !o)} title="Dettaglio segnali di rischio">
Rischio mercato: {r.label}
{risk.severity}/100
{triggers.length > 0 && {triggers.length} segnali attivi }
{open ? "▾" : "▸"}
{open && (
{triggers.length === 0
?
Nessun segnale di rischio attivo.
: triggers.map((t, i) => (
{t.label}
{t.value != null && {String(t.value)}}
{t.note && {t.note} }
))}
{risk.portfolio && (
Portafoglio
{risk.portfolio.top_sector_pct}% {risk.portfolio.top_sector}
{risk.portfolio.mean_corr_1d != null && corr {risk.portfolio.mean_corr_1d}}
{(risk.portfolio.notes || []).join(" · ")}
)}
)}
);
}
function AppShell({ user, tab, setTab, onLogout, sidebarCollapsed, setSidebarCollapsed, dataVersions }) {
const [mobileOpen, setMobileOpen] = React.useState(false);
const [activeSym, setActiveSym] = useState(null);
const dataLoaded = (dataVersions?.dashboard || 0) > 0;
const filteredNav = NAV.filter(n => !n.adminOnly || user.is_admin);
const grouped = filteredNav.reduce((acc, n) => {
(acc[n.group] = acc[n.group] || []).push(n);
return acc;
}, {});
const title = TAB_TITLES[tab] || { t: "BVF", s: "" };
return (
{/* Mobile backdrop */}
{mobileOpen && (
setMobileOpen(false)}/>
)}
{/* SIDEBAR */}
{/* MAIN */}
setMobileOpen(true)} title="Menu">
{tab === "dashboard" &&
}
{tab === "screener" && { setActiveSym(sym); setTab("dashboard"); }}/>}
{tab === "watchlist" && }
{tab === "calendar" && }
{tab === "sectors" && }
{tab === "premarket" && }
{tab === "picks" && }
{tab === "chain" && }
{tab === "report" && }
{tab === "accuracy" && }
{tab === "simulator" && }
{tab === "sleeves" && }
{tab === "news" && }
{tab === "evaluate" && }
{tab === "config" && }
{tab === "logs" && }
{tab === "costs" && user.is_admin && }
{tab === "admin" && }
{/* Mobile bottom nav */}
{filteredNav.slice(0, 5).map(it => (
setTab(it.id)}>
{it.label}
))}
);
}
// ── Alert Bell ────────────────────────────────────────────────────────────────
const _SIG_COLOR = {
BUY: { color: "var(--green)", bg: "var(--green-soft)" },
STRONG_BUY: { color: "var(--green)", bg: "var(--green-soft)" },
SELL: { color: "var(--red)", bg: "var(--red-soft)" },
STRONG_SELL: { color: "var(--red)", bg: "var(--red-soft)" },
HOLD: { color: "var(--amber)", bg: "var(--amber-soft)" },
};
const _TOOL_LABEL = { tool2: "AI", tool3: "TA", tool4: "News" };
function AlertBell({ dataLoaded }) {
const [alerts, setAlerts] = useState([]);
const [unread, setUnread] = useState(0);
const [open, setOpen] = useState(false);
const ref = useRef(null);
const fetchAlerts = () => {
if (!dataLoaded) return;
fetch("/api/alerts")
.then(r => r.json())
.then(d => { setAlerts(d.alerts || []); setUnread(d.unread || 0); })
.catch(() => {});
};
useEffect(() => {
fetchAlerts();
const t = setInterval(fetchAlerts, 5 * 60 * 1000);
return () => clearInterval(t);
}, [dataLoaded]);
useEffect(() => {
const close = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, []);
const handleToggle = () => {
const next = !open;
setOpen(next);
if (next && unread > 0) {
fetch("/api/alerts/mark-read", { method: "POST" })
.then(() => setUnread(0))
.catch(() => {});
}
};
return (
{unread > 0 && }
{open && (
Segnali ultime 24h
{unread > 0 && {unread} nuovi }
setOpen(false)}>
{alerts.length === 0
?
Nessun segnale recente (esclusi HOLD).
: alerts.map((a, i) => {
const sc = _SIG_COLOR[a.signal] || { color:"var(--text-muted)", bg:"var(--surface)" };
return (
{a.symbol}
{(a.signal || "").replace(/_/g, " ")}
{_TOOL_LABEL[a.tool] || a.tool}
{(a.timestamp || "").slice(11, 16)}
);
})
}
)}
);
}
// ── Daemon Status ─────────────────────────────────────────────────────────────
function DaemonStatus({ collapsed }) {
const [running, setRunning] = useState(null); // null = caricamento
const [onLinux, setOnLinux] = useState(false);
const [busy, setBusy] = useState(false);
const fetchStatus = () => {
fetch("/api/status")
.then(r => r.json())
.then(d => { setRunning(d.running); setOnLinux(d.on_linux); })
.catch(() => {});
};
useEffect(() => {
fetchStatus();
const t = setInterval(fetchStatus, 30000);
return () => clearInterval(t);
}, []);
const doAction = async (action) => {
if (busy) return;
setBusy(true);
try {
await fetch(`/api/${action}`, { method: "POST" });
await new Promise(r => setTimeout(r, 1500));
fetchStatus();
} finally {
setBusy(false);
}
};
if (running === null) return null;
return (
{running ? "In esecuzione" : "Fermo"}
{onLinux && (
{running ? (
doAction("stop")} title="Ferma" disabled={busy}>
) : (
doAction("start")} title="Avvia" disabled={busy}>
)}
)}
);
}
function MarketPill() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const t = setInterval(()=>setNow(new Date()), 1000);
return () => clearInterval(t);
}, []);
// ET market hours (very rough)
const etHour = (now.getUTCHours() - 4 + 24) % 24;
const open = etHour >= 9 && etHour < 16 && now.getDay() >= 1 && now.getDay() <= 5;
return (
{open ? "Mercato aperto" : "Chiuso"}
{now.toLocaleTimeString("it-IT")}
);
}
// Badge freschezza dati per la tab attiva (legge window.HEALTH da /api/health).
// "Mostra sempre con badge età" — non nasconde mai i dati, segnala solo l'età.
const _TAB_TOOL = {
dashboard: "tool1", screener: "tool1", sectors: "tool7",
premarket: "tool8", picks: "tool9", chain: "tool10", calendar: "tool5",
};
function TabFreshness({ tab }) {
const key = _TAB_TOOL[tab];
const h = key && (window.HEALTH || {})[key];
if (!h) return null;
const age = h.age_min;
const label = age == null ? "n/d" : age < 90 ? `${age}min fa` : `${(age / 60).toFixed(1)}h fa`;
const hasAnom = h.anomalies && h.anomalies.length > 0;
const color = h.ok ? "var(--green)" : (h.fresh ? "var(--amber)" : "var(--red)");
const title = hasAnom ? h.anomalies.join("; ") : `Ultimo aggiornamento dati: ${label}`;
return (
dati {label}
);
}
window.AppShell = AppShell;
window.TAB_TITLES = TAB_TITLES;
// ────────── Shared filter components (available to all views) ──────────
// Custom dropdown — options as { value, label } array
function FilterSelect({ value, onChange, options, placeholder = "Tutti", width }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const close = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, []);
const selected = options.find(o => o.value === value);
const label = selected ? selected.label : placeholder;
const isAll = !selected || selected.label === placeholder || value === "all";
return (
setOpen(o => !o)}>
{label}
{open && (
{options.map(o => (
{ onChange(o.value); setOpen(false); }}
>
{o.label}
))}
)}
);
}
// Mini-calendar date picker — mostra un calendario mensile stilizzato
// availableDates: array di "YYYY-MM-DD" selezionabili (null = tutte le date disponibili)
// showClear: mostra il pulsante "Tutte le date" per resettare
function FilterCalendar({ value, onChange, availableDates, showClear = false, placeholder = "Tutte le date" }) {
const isAll = !value || value === "all";
const initMonth = () => {
const d = !isAll ? new Date(value + "T12:00:00") : (availableDates?.length ? new Date(availableDates[0] + "T12:00:00") : new Date());
return { y: d.getFullYear(), m: d.getMonth() };
};
const [open, setOpen] = useState(false);
const [view, setView] = useState(initMonth);
const ref = useRef(null);
const availSet = new Set(availableDates || []);
// Aggiorna il mese visualizzato quando cambia il valore selezionato
useEffect(() => {
if (!isAll) {
const d = new Date(value + "T12:00:00");
setView({ y: d.getFullYear(), m: d.getMonth() });
}
}, [value]);
useEffect(() => {
const close = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", close);
return () => document.removeEventListener("mousedown", close);
}, []);
const fmtBtn = d => {
if (!d || d === "all") return placeholder;
return new Date(d + "T12:00:00").toLocaleDateString("it-IT", { weekday:"short", day:"2-digit", month:"short" });
};
const firstDay = new Date(view.y, view.m, 1);
const lastDay = new Date(view.y, view.m + 1, 0);
const startWd = (firstDay.getDay() + 6) % 7; // Lun=0
const monthLabel = firstDay.toLocaleDateString("it-IT", { month: "long", year: "numeric" });
const cells = [];
for (let i = 0; i < startWd; i++) cells.push(null);
for (let d = 1; d <= lastDay.getDate(); d++) cells.push(d);
while (cells.length % 7 !== 0) cells.push(null);
const makeKey = d => `${view.y}-${String(view.m+1).padStart(2,"0")}-${String(d).padStart(2,"0")}`;
const today = new Date();
const todayKey = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,"0")}-${String(today.getDate()).padStart(2,"0")}`;
const prevMonth = () => { const d = new Date(view.y, view.m - 1, 1); setView({ y: d.getFullYear(), m: d.getMonth() }); };
const nextMonth = () => { const d = new Date(view.y, view.m + 1, 1); setView({ y: d.getFullYear(), m: d.getMonth() }); };
const pickDay = (d) => {
const key = makeKey(d);
if (availableDates && !availSet.has(key)) return;
onChange(key);
setOpen(false);
};
return (
setOpen(o => !o)}>
{fmtBtn(value)}
{!isAll && showClear && (
{ e.stopPropagation(); onChange("all"); setOpen(false); }}>×
)}
{open && (
{/* Intestazione mese */}
‹
{monthLabel}
›
{/* Pulsante "Tutte le date" */}
{showClear && (
{ onChange("all"); setOpen(false); }}
>{placeholder}
)}
{/* Griglia */}
{["L","M","M","G","V","S","D"].map((d, i) => (
{d}
))}
{cells.map((d, i) => {
if (!d) return ;
const key = makeKey(d);
const avail = !availableDates || availSet.has(key);
const selected = key === value;
const isToday = key === todayKey;
return (
pickDay(d)}
disabled={!avail}
>{d}
);
})}
)}
);
}
// Alias per retrocompatibilità (non più usato ma evita errori se rimane riferimento)
const FilterDateChips = FilterCalendar;
// Styled date input — opens native date picker from a custom button
function FilterDateInput({ value, onChange }) {
const ref = useRef(null);
return (
);
}