/** * useNetworkStatus.ts — S57: Network Intelligence Layer (bandwidth estimation) * * Monitora connettività reale + latenza + bandwidth stimata. * Safari-safe: usa navigator.onLine + eventi window, nessun Worker. * * S57 rispetto a S55: * + navigator.connection "change" events → react istantaneamente al cambio rete * + downlink bandwidth estimation (navigator.connection.downlink, MB/s) * + ping interval adattivo: 10s quando poor/slow, 30s quando good * + campo bandwidthMbps esposto nell'API pubblica * + classify usa downlink come segnale primario quando RTT non disponibile * * Stati qualità: * offline — navigator.onLine=false o ping fallisce ×2 * poor — RTT >2500ms, downlink <0.5 Mbps, o effectiveType "2g"/"slow-2g" * slow — RTT 800-2500ms, downlink 0.5-1 Mbps, o effectiveType "3g" * good — RTT <800ms, downlink >2 Mbps (default se stima non disponibile) * * API: * const { online, quality, rttMs, bandwidthMbps, since, justReconnected } = useNetworkStatus(); */ import { useState, useEffect, useRef, useCallback } from "react"; export type NetworkQuality = "good" | "slow" | "poor" | "offline"; export interface NetworkState { online: boolean; quality: NetworkQuality; rttMs: number | null; bandwidthMbps: number | null; // S57: downlink stimato in Mbps (null se non disponibile) since: number; // timestamp inizio stato attuale justReconnected: boolean; // true per 4s dopo riconnessione } // ── Tipi Navigator Connection API (non standard, assente in @types/node) ────── interface NetworkInformation extends EventTarget { effectiveType?: "slow-2g" | "2g" | "3g" | "4g"; rtt?: number; downlink?: number; // Mbps stimato saveData?: boolean; onchange?: ((ev: Event) => void) | null; } type NavigatorWithConn = Navigator & { connection?: NetworkInformation }; const PING_URL = "https://agente-ai.pages.dev/favicon.ico"; const PING_TIMEOUT_MS = 4000; const PING_INTERVAL_GOOD_MS = 30_000; const PING_INTERVAL_POOR_MS = 10_000; // S57: più frequente quando rete è scarsa const RECONNECT_SHOW_MS = 4_000; const DEFAULT: NetworkState = { online: navigator.onLine, quality: navigator.onLine ? "good" : "offline", rttMs: null, bandwidthMbps: null, since: Date.now(), justReconnected: false, }; // ── Legge downlink da navigator.connection ───────────────────────────────── function readDownlink(): number | null { const conn = (navigator as NavigatorWithConn).connection; if (!conn || conn.downlink === undefined) return null; return conn.downlink > 0 ? conn.downlink : null; } // ── Legge effectiveType da navigator.connection ──────────────────────────── function readEffectiveType(): NetworkQuality | null { const conn = (navigator as NavigatorWithConn).connection; if (!conn) return null; if (conn.effectiveType === "slow-2g" || conn.effectiveType === "2g") return "poor"; if (conn.effectiveType === "3g") return "slow"; return null; } // ── Classifica la qualità usando tutti i segnali disponibili ────────────── function classify( rttMs: number | null, pingOk: boolean, downlinkMbps: number | null ): NetworkQuality { if (!pingOk) return "offline"; // effectiveType ha precedenza quando disponibile (dato dal browser) const fromType = readEffectiveType(); if (fromType) return fromType; // downlink come segnale primario S57 if (downlinkMbps !== null) { if (downlinkMbps < 0.5) return "poor"; if (downlinkMbps < 1.0) return "slow"; // Bug#2: was 2.0 — banner showed at 1.4 Mbps (sufficient for app) return "good"; } // Fallback RTT if (rttMs === null) return "good"; if (rttMs > 2500) return "poor"; if (rttMs > 800) return "slow"; return "good"; } async function measureRtt(): Promise<{ ok: boolean; rttMs: number | null }> { const t0 = performance.now(); try { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), PING_TIMEOUT_MS); await fetch(`${PING_URL}?_=${Date.now()}`, { method: "HEAD", cache: "no-store", signal: ctrl.signal, }); clearTimeout(timer); return { ok: true, rttMs: Math.round(performance.now() - t0) }; } catch { return { ok: false, rttMs: null }; } } export function useNetworkStatus(): NetworkState { const [state, setState] = useState(DEFAULT); const reconnectTimer = useRef | null>(null); const pingIntervalRef = useRef | null>(null); const pingCount = useRef(0); const currentQuality = useRef(DEFAULT.quality); // ── Ping + aggiornamento stato ─────────────────────────────────────────── const runPing = useCallback(async () => { if (!navigator.onLine) { setState(s => s.online === false ? s : { online: false, quality: "offline", rttMs: null, bandwidthMbps: null, since: Date.now(), justReconnected: false, }); currentQuality.current = "offline"; return; } const [{ ok, rttMs }] = await Promise.all([measureRtt()]); const downlinkMbps = readDownlink(); if (!ok) { pingCount.current++; // Offline confermato solo dopo 2 ping falliti consecutivi (evita flap) if (pingCount.current >= 2) { setState(s => s.quality === "offline" ? s : { online: false, quality: "offline", rttMs: null, bandwidthMbps: null, since: Date.now(), justReconnected: false, }); currentQuality.current = "offline"; } return; } pingCount.current = 0; const quality = classify(rttMs, true, downlinkMbps); setState(prev => { const wasOffline = !prev.online; const changed = prev.quality !== quality || !prev.online; if (!changed && prev.bandwidthMbps === downlinkMbps) return prev; if (wasOffline && reconnectTimer.current) clearTimeout(reconnectTimer.current); currentQuality.current = quality; return { online: true, quality, rttMs, bandwidthMbps: downlinkMbps, since: changed ? Date.now() : prev.since, justReconnected: wasOffline, }; }); // Auto-dismiss justReconnected dopo 4s if (reconnectTimer.current) clearTimeout(reconnectTimer.current); reconnectTimer.current = setTimeout(() => { setState(s => s.justReconnected ? { ...s, justReconnected: false } : s); }, RECONNECT_SHOW_MS); }, []); // ── Intervallo adattivo: rischedula quando qualità cambia ──────────────── const rescheduleInterval = useCallback(() => { if (pingIntervalRef.current) clearInterval(pingIntervalRef.current); const ms = currentQuality.current === "good" ? PING_INTERVAL_GOOD_MS : PING_INTERVAL_POOR_MS; pingIntervalRef.current = setInterval(() => { runPing().then(rescheduleInterval).catch(() => {}); }, ms); }, [runPing]); useEffect(() => { runPing().then(rescheduleInterval).catch(() => {}); const onOnline = () => { pingCount.current = 0; runPing().then(rescheduleInterval).catch(() => {}); }; const onOffline = () => { currentQuality.current = "offline"; setState({ online: false, quality: "offline", rttMs: null, bandwidthMbps: null, since: Date.now(), justReconnected: false }); }; window.addEventListener("online", onOnline); window.addEventListener("offline", onOffline); // S57: navigator.connection "change" event — react istantaneamente const conn = (navigator as NavigatorWithConn).connection; const onConnChange = () => { runPing().then(rescheduleInterval).catch(() => {}); }; conn?.addEventListener("change", onConnChange); return () => { if (pingIntervalRef.current) clearInterval(pingIntervalRef.current); window.removeEventListener("online", onOnline); window.removeEventListener("offline", onOffline); conn?.removeEventListener("change", onConnChange); if (reconnectTimer.current) clearTimeout(reconnectTimer.current); }; }, [runPing, rescheduleInterval]); return state; }