// IngestionControl component - Ingestion Control Tab with Gap Filling // ─── Constants ────────────────────────────────────────────────────────────── const CHART_W = 490, CHART_H = 160; const CHART_PAD = { top: 10, right: 72, bottom: 28, left: 40 }; const J_MILES_AXIS_SCALE = 10000; const Y_MILES_AXIS_SCALE = 5000; const DEFAULT_MAX_J_MILES = 100000; const DEFAULT_MAX_Y_MILES = 50000; const TOOLTIP_WIDTH_PX = 514; const TOOLTIP_Y_OFFSET = 250; const TOOLTIP_HOVER_DELAY_MS = 300; const WINDOW_EDGE_PAD = 8; const COVERAGE_SUCCESS_PCT = 98; const COVERAGE_WARNING_PCT = 90; const COVERAGE_GOOD_PCT = 90; const COVERAGE_FAIR_PCT = 50; const DEFAULT_GAP_FILL_BUDGET = 50; const Z_INGESTION_TOOLTIP = 9999; const RELEASE_WINDOW_THRESHOLD_DAYS = 15; // --- Distribution Chart with independent cabin pricing scales (pure SVG) --- // releaseDays: optional number — draws a vertical release-date line // releaseDate: optional string — actual calendar date for the release line label // todayDate: optional string — today's date for a "Today" line function DistributionChart({ data, stats, theme, releaseDays, releaseDate, todayDate }) { const W = CHART_W, H = CHART_H, PAD = CHART_PAD; const chartW = W - PAD.left - PAD.right; const chartH = H - PAD.top - PAD.bottom; const dist = data || []; const maxVal = (stats && stats.max_flights_per_day) || 1; const hasJ = dist.some(d => d.avg_j > 0); const hasY = dist.some(d => d.avg_y > 0); let maxJ = 0, maxY = 0; dist.forEach(d => { if (d.avg_j > 0 && d.avg_j > maxJ) maxJ = d.avg_j; if (d.avg_y > 0 && d.avg_y > maxY) maxY = d.avg_y; }); if (maxJ > 0) maxJ = Math.ceil(maxJ * 1.1 / J_MILES_AXIS_SCALE) * J_MILES_AXIS_SCALE; if (maxY > 0) maxY = Math.ceil(maxY * 1.1 / Y_MILES_AXIS_SCALE) * Y_MILES_AXIS_SCALE; if (maxJ === 0) maxJ = DEFAULT_MAX_J_MILES; if (maxY === 0) maxY = DEFAULT_MAX_Y_MILES; const months = []; let lastMonth = -1; dist.forEach((d, i) => { const m = new Date(d.date + "T00:00:00").getMonth(); if (m !== lastMonth) { months.push({ idx: i, label: new Date(d.date + "T00:00:00").toLocaleString("en-US", { month: "short" }) }); lastMonth = m; } }); const barW = dist.length > 0 ? chartW / dist.length : 1; const buildLine = (key, max) => { const points = []; dist.forEach((d, i) => { if (d[key] > 0) { const x = PAD.left + i * barW + barW / 2; const y = PAD.top + chartH - (d[key] / max) * chartH; points.push(`${x},${y}`); } }); return points.join(" "); }; const jLine = hasJ ? buildLine("avg_j", maxJ) : ""; const yLine = hasY ? buildLine("avg_y", maxY) : ""; const fmtMiles = (v) => v >= 1000 ? (v / 1000).toFixed(0) + "k" : v; const children = [ React.createElement("rect", { key: "bg", x: 0, y: 0, width: W, height: H, rx: 6, fill: "#0d0d0d" }), React.createElement("rect", { key: "cbg", x: PAD.left, y: PAD.top, width: chartW, height: chartH, fill: "#111" }) ]; [0, 0.25, 0.5, 0.75, 1].forEach(frac => { const y = PAD.top + chartH - frac * chartH; const val = Math.round(frac * maxVal); children.push( React.createElement("g", { key: "grid-" + frac }, React.createElement("line", { x1: PAD.left, y1: y, x2: PAD.left + chartW, y2: y, stroke: "#222", strokeWidth: 0.5 }), React.createElement("text", { x: PAD.left - 4, y: y + 3, textAnchor: "end", fill: "#555", fontSize: 8, fontFamily: "Arial" }, val >= 1000 ? (val / 1000).toFixed(1) + "k" : val) ) ); }); const rAxisX1 = PAD.left + chartW + 4; const rAxisX2 = PAD.left + chartW + 36; if (hasJ) { [0, 0.5, 1].forEach(frac => { const y = PAD.top + chartH - frac * chartH; children.push( React.createElement("text", { key: "rj-" + frac, x: rAxisX1, y: y + 3, textAnchor: "start", fill: "#fbbf24", fontSize: 7, fontFamily: "Arial", opacity: 0.8 }, fmtMiles(Math.round(frac * maxJ))) ); }); } if (hasY) { [0, 0.5, 1].forEach(frac => { const y = PAD.top + chartH - frac * chartH; children.push( React.createElement("text", { key: "ry-" + frac, x: rAxisX2, y: y + 3, textAnchor: "start", fill: "#10b981", fontSize: 7, fontFamily: "Arial", opacity: 0.8 }, fmtMiles(Math.round(frac * maxY))) ); }); } dist.forEach((d, i) => { if (d.flights > 0) { const barH = Math.max(1, (d.flights / maxVal) * chartH); const x = PAD.left + i * barW; const y = PAD.top + chartH - barH; children.push( React.createElement("rect", { key: "b" + i, x: x, y: y, width: Math.max(0.8, barW - 0.3), height: barH, fill: theme.accent, opacity: 0.6 }) ); } }); if (jLine) { children.push(React.createElement("polyline", { key: "jline", points: jLine, fill: "none", stroke: "#fbbf24", strokeWidth: 1.5, strokeLinejoin: "round", opacity: 0.9 })); } if (yLine) { children.push(React.createElement("polyline", { key: "yline", points: yLine, fill: "none", stroke: "#10b981", strokeWidth: 1.5, strokeLinejoin: "round", opacity: 0.9 })); } months.forEach(m => { children.push( React.createElement("g", { key: "m-" + m.idx }, React.createElement("line", { x1: PAD.left + m.idx * barW, y1: PAD.top, x2: PAD.left + m.idx * barW, y2: PAD.top + chartH, stroke: "#333", strokeWidth: 0.5, strokeDasharray: "2,2" }), React.createElement("text", { x: PAD.left + m.idx * barW + 2, y: H - 6, fill: "#666", fontSize: 9, fontFamily: "Arial" }, m.label) ) ); }); children.push( React.createElement("text", { key: "ylab", x: 4, y: PAD.top + chartH / 2, fill: "#555", fontSize: 7, fontFamily: "Arial", transform: `rotate(-90 4 ${PAD.top + chartH / 2})`, textAnchor: "middle" }, "flights/day") ); // --- Vertical "Today" line (index 0 = today) --- if (dist.length > 0) { const todayX = PAD.left; children.push( React.createElement("line", { key: "today-line", x1: todayX, y1: PAD.top, x2: todayX, y2: PAD.top + chartH, stroke: "#60a5fa", strokeWidth: 1.5, strokeDasharray: "4,3", opacity: 0.85 }), React.createElement("text", { key: "today-label", x: todayX + 3, y: PAD.top + chartH - 4, fill: "#60a5fa", fontSize: 7, fontFamily: "Arial", fontWeight: 600 }, "Today") ); } // --- Vertical release-date line (dynamic based on release window) --- if (releaseDays && releaseDays > 0 && releaseDays < dist.length) { const rlX = PAD.left + releaseDays * barW; const rlLabel = releaseDate ? new Date(releaseDate + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "2-digit" }) : releaseDays + "d"; children.push( React.createElement("line", { key: "release-line", x1: rlX, y1: PAD.top, x2: rlX, y2: PAD.top + chartH, stroke: "#ef4444", strokeWidth: 1.5, strokeDasharray: "4,3", opacity: 0.9 }), React.createElement("text", { key: "release-label", x: rlX + 3, y: PAD.top + 10, fill: "#ef4444", fontSize: 7, fontFamily: "Arial", fontWeight: 600 }, rlLabel + " (" + releaseDays + "d)") ); } if (hasJ || hasY) { const legendItems = []; let lx = 0; legendItems.push(React.createElement("rect", { key: "lb", x: lx, y: 0, width: 8, height: 8, fill: theme.accent, opacity: 0.7, rx: 1 })); legendItems.push(React.createElement("text", { key: "lt", x: lx + 11, y: 7, fill: "#888", fontSize: 7, fontFamily: "Arial" }, "Flights")); lx += 42; if (hasJ) { legendItems.push(React.createElement("line", { key: "ljl", x1: lx, y1: 4, x2: lx + 10, y2: 4, stroke: "#fbbf24", strokeWidth: 1.5 })); legendItems.push(React.createElement("text", { key: "ljt", x: lx + 13, y: 7, fill: "#fbbf24", fontSize: 7, fontFamily: "Arial" }, "Biz Avg")); lx += 42; } if (hasY) { legendItems.push(React.createElement("line", { key: "lyl", x1: lx, y1: 4, x2: lx + 10, y2: 4, stroke: "#10b981", strokeWidth: 1.5 })); legendItems.push(React.createElement("text", { key: "lyt", x: lx + 13, y: 7, fill: "#10b981", fontSize: 7, fontFamily: "Arial" }, "Econ Avg")); lx += 48; } const legendX = PAD.left + chartW - lx - 4; const legendY = PAD.top + chartH - 14; children.push( React.createElement("rect", { key: "legend-bg", x: legendX - 4, y: legendY - 3, width: lx + 8, height: 14, rx: 3, fill: "#0d0d0d", opacity: 0.85 }) ); children.push( React.createElement("g", { key: "legend", transform: `translate(${legendX}, ${legendY})` }, ...legendItems) ); } return React.createElement("svg", { width: W, height: H, style: { display: "block" } }, ...children); } // --- GapAnalysis ---- function GapAnalysis() { const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(true); const [expanded, setExpanded] = React.useState({}); const theme = window.activeTheme || window.theme || { bg: "#0a0a0a", card: "#141414", border: "#252525", text: "#e0e0e0", textDim: "#888", accent: "#3b82f6", success: "#10b981", warning: "#f59e0b", danger: "#ef4444", }; const [, _themeRender] = React.useState(0); React.useEffect(() => { const h = () => _themeRender(n => n + 1); window.addEventListener("theme-change", h); return () => window.removeEventListener("theme-change", h); }, []); React.useEffect(() => { (window.authFetch || fetch)(`${API}/api/admin/gaps`) .then(r => { if (!r.ok) throw new Error("Not authorized"); return r.json(); }) .then(d => { setData(d); setLoading(false); }) .catch(() => setLoading(false)); }, []); if (loading) return React.createElement("div", { style: { padding: 20, textAlign: "center", color: theme.textDim } }, "Scanning for gaps..."); if (!data || !data.programs || Object.keys(data.programs).length === 0) { return React.createElement("div", { style: { padding: 16, background: theme.success + "15", border: `1px solid ${theme.success}40`, borderRadius: 10, marginBottom: 20, color: theme.success, fontSize: 13, fontWeight: 600 } }, "\u2713 No date gaps detected \u2014 all programs have continuous coverage" ); } const summary = data.summary; const programs = data.programs; const sorted = Object.entries(programs).sort((a, b) => b[1].total_gap_days - a[1].total_gap_days); const covColor = (pct) => pct >= COVERAGE_SUCCESS_PCT ? theme.success : pct >= COVERAGE_WARNING_PCT ? theme.warning : theme.danger; const GapTimeline = ({ info }) => { const rangeStart = new Date(info.range_start + "T00:00:00"); const rangeEnd = new Date(info.range_end + "T00:00:00"); const rangeDays = info.range_days; if (rangeDays <= 0) return null; const dayToX = (dateStr) => { const d = new Date(dateStr + "T00:00:00"); return ((d - rangeStart) / (rangeEnd - rangeStart)) * 100; }; return React.createElement("div", { style: { position: "relative", height: 10, background: theme.success + "30", borderRadius: 4, overflow: "hidden", marginTop: 6 } }, ...info.gaps.map((g, i) => { const left = dayToX(g.start); const right = dayToX(g.end); const width = Math.max(0.5, right - left); return React.createElement("div", { key: i, title: `${g.start} \u2192 ${g.end} (${g.days}d)`, style: { position: "absolute", left: left + "%", width: width + "%", top: 0, bottom: 0, background: theme.danger, opacity: 0.8, borderRadius: 2 } }); }) ); }; return React.createElement("div", { style: { marginBottom: 24 } }, React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 } }, React.createElement("h3", { style: { fontSize: 16, fontWeight: 700, margin: 0, color: theme.text } }, "\uD83D\uDD0D Gap Analysis"), React.createElement("div", { style: { display: "flex", gap: 16, fontSize: 12 } }, React.createElement("span", { style: { color: theme.danger } }, summary.total_gaps + " gaps"), React.createElement("span", { style: { color: theme.warning } }, summary.total_gap_days + " missing days"), React.createElement("span", { style: { color: theme.textDim } }, "~" + summary.estimated_api_calls + " calls to fix") ) ), React.createElement("div", { style: { display: "flex", flexDirection: "column", gap: 6 } }, ...sorted.map(([source, info]) => { const isExpanded = expanded[source]; const progName = (PROGRAMS[source] || {}).name || source; return React.createElement("div", { key: source, style: { background: theme.card, border: `1px solid ${theme.border}`, borderRadius: 8, overflow: "hidden" } }, React.createElement("div", { onClick: () => setExpanded(prev => ({ ...prev, [source]: !prev[source] })), style: { display: "flex", alignItems: "center", gap: 12, padding: "10px 14px", cursor: "pointer" } }, React.createElement("span", { style: { fontSize: 10, color: theme.textDim, transition: "transform 0.15s", transform: isExpanded ? "rotate(90deg)" : "none" } }, "\u25B6"), React.createElement("span", { style: { fontSize: 13, fontWeight: 600, color: theme.text, width: 160 } }, progName), React.createElement("span", { style: { fontSize: 12, color: theme.danger, fontWeight: 600, width: 80 } }, info.gaps.length + " gap" + (info.gaps.length > 1 ? "s" : "")), React.createElement("span", { style: { fontSize: 12, color: theme.warning, width: 80 } }, info.total_gap_days + " days"), React.createElement("span", { style: { fontSize: 12, color: covColor(info.coverage_pct), width: 70, textAlign: "right" } }, info.coverage_pct + "%"), React.createElement("div", { style: { flex: 1, minWidth: 100 } }, React.createElement(GapTimeline, { info })) ), isExpanded && React.createElement("div", { style: { padding: "0 14px 12px 36px", borderTop: `1px solid ${theme.border}` } }, React.createElement("div", { style: { fontSize: 11, color: theme.textDim, marginTop: 8, marginBottom: 6 } }, `Range: ${info.range_start} \u2192 ${info.range_end} (${info.range_days} days, ${info.covered_days} covered)`), React.createElement("table", { style: { width: "100%", fontSize: 12, borderCollapse: "collapse" } }, React.createElement("thead", null, React.createElement("tr", { style: { color: theme.textDim, textAlign: "left" } }, React.createElement("th", { style: { padding: "4px 8px", fontWeight: 500 } }, "Start"), React.createElement("th", { style: { padding: "4px 8px", fontWeight: 500 } }, "End"), React.createElement("th", { style: { padding: "4px 8px", fontWeight: 500, textAlign: "right" } }, "Days") ) ), React.createElement("tbody", null, ...info.gaps.map((g, i) => React.createElement("tr", { key: i, style: { borderTop: `1px solid ${theme.border}10` } }, React.createElement("td", { style: { padding: "4px 8px", color: theme.text } }, g.start), React.createElement("td", { style: { padding: "4px 8px", color: theme.text } }, g.end), React.createElement("td", { style: { padding: "4px 8px", color: theme.danger, fontWeight: 600, textAlign: "right" } }, g.days) )) ) ) ) ); }) ) ); } // --- IngestionControl ---- function IngestionControl() { // === Tab state (NEW) === const [activeTab, setActiveTab] = React.useState("ingestion"); // === Ingestion state (from OLD) === const [coverage, setCoverage] = React.useState([]); const [loading, setLoading] = React.useState(true); const [selected, setSelected] = React.useState({}); const [maxPages, setMaxPages] = React.useState(0); const [triggerStatus, setTriggerStatus] = React.useState(null); const [triggering, setTriggering] = React.useState(false); // === Chart hover state (from OLD) === const [hoveredSource, setHoveredSource] = React.useState(null); const [chartCache, setChartCache] = React.useState({}); const [chartLoading, setChartLoading] = React.useState(false); const [tooltipPos, setTooltipPos] = React.useState({ x: 0, y: 0 }); const hoverTimerRef = React.useRef(null); // === Gap state (NEW) === const [gaps, setGaps] = React.useState({}); const [gapSummary, setGapSummary] = React.useState({}); const [gapsLoading, setGapsLoading] = React.useState(true); const [selectedGaps, setSelectedGaps] = React.useState({}); const [maxBudget, setMaxBudget] = React.useState(DEFAULT_GAP_FILL_BUDGET); const [gapTriggerStatus, setGapTriggerStatus] = React.useState(null); const [gapTriggering, setGapTriggering] = React.useState(false); // === Theme (from OLD) === const theme = window.activeTheme || window.theme || { bg: "#0a0a0a", card: "#141414", border: "#252525", text: "#e0e0e0", textDim: "#888", accent: "#3b82f6", success: "#10b981", warning: "#f59e0b", danger: "#ef4444", purple: "#8b5cf6" }; // Theme change listener (from OLD) const [, _themeRender2] = React.useState(0); React.useEffect(() => { const h = () => _themeRender2(n => n + 1); window.addEventListener("theme-change", h); return () => window.removeEventListener("theme-change", h); }, []); // === Load coverage data (from OLD - uses authFetch) === React.useEffect(() => { (window.authFetch || fetch)(`${API}/api/admin/ingestion-coverage`) .then(r => { if (!r.ok) throw new Error("Not authorized"); return r.json(); }) .then(d => { setCoverage(d.coverage || []); const sel = {}; (d.coverage || []).forEach(a => { sel[a.source] = a.days_ahead < (a.release_days || 365) - RELEASE_WINDOW_THRESHOLD_DAYS; }); setSelected(sel); setLoading(false); }) .catch(() => setLoading(false)); }, []); // === Load gap data (NEW - uses authFetch) === React.useEffect(() => { (window.authFetch || fetch)(`${API}/api/admin/gaps`) .then(r => r.json()) .then(d => { setGaps(d.programs || {}); setGapSummary(d.summary || {}); const sel = {}; Object.keys(d.programs || {}).forEach(source => { sel[source] = true; }); setSelectedGaps(sel); setGapsLoading(false); }) .catch(() => setGapsLoading(false)); }, []); // === Chart hover functions (from OLD) === const fetchDistribution = React.useCallback(async (source) => { if (chartCache[source]) return; setChartLoading(true); try { const res = await (window.authFetch || fetch)(`${API}/api/admin/ingestion-distribution/${source}`); const data = await res.json(); setChartCache(prev => ({ ...prev, [source]: data })); } catch (e) { console.error("Distribution fetch failed:", e); } finally { setChartLoading(false); } }, [chartCache]); const handleCardMouseEnter = React.useCallback((source, event) => { clearTimeout(hoverTimerRef.current); const rect = event.currentTarget.getBoundingClientRect(); hoverTimerRef.current = setTimeout(() => { const tooltipW = TOOLTIP_WIDTH_PX; let x = rect.left + rect.width / 2 - tooltipW / 2; let y = rect.top - TOOLTIP_Y_OFFSET; if (x < WINDOW_EDGE_PAD) x = WINDOW_EDGE_PAD; if (x + tooltipW > window.innerWidth - WINDOW_EDGE_PAD) x = window.innerWidth - tooltipW - WINDOW_EDGE_PAD; if (y < WINDOW_EDGE_PAD) y = rect.bottom + WINDOW_EDGE_PAD; setTooltipPos({ x, y }); setHoveredSource(source); fetchDistribution(source); }, TOOLTIP_HOVER_DELAY_MS); }, [fetchDistribution]); const handleCardMouseLeave = React.useCallback(() => { clearTimeout(hoverTimerRef.current); setHoveredSource(null); }, []); // === Ingestion helpers (from OLD) === const toggleAirline = (source) => { setSelected(prev => ({ ...prev, [source]: !prev[source] })); }; const selectAll = () => { const sel = {}; coverage.forEach(a => { sel[a.source] = true; }); setSelected(sel); }; const selectNone = () => { const sel = {}; coverage.forEach(a => { sel[a.source] = false; }); setSelected(sel); }; const selectedSources = Object.entries(selected).filter(([,v]) => v).map(([k]) => k); const triggerIngestion = async () => { if (selectedSources.length === 0) { setTriggerStatus({ type: "error", msg: "No airlines selected" }); return; } setTriggering(true); setTriggerStatus(null); try { const res = await (window.authFetch || fetch)(`${API}/api/admin/trigger-ingestion`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sources: selectedSources.join(","), max_pages: maxPages || 0 }) }); const data = await res.json(); if (res.ok) { setTriggerStatus({ type: "success", msg: `Ingestion triggered! ${data.message || ""}` }); } else { setTriggerStatus({ type: "error", msg: data.detail || data.error || "Failed to trigger" }); } } catch (e) { setTriggerStatus({ type: "error", msg: e.message }); } setTriggering(false); }; // === Gap helpers (NEW) === const toggleGapProgram = (source) => { setSelectedGaps(prev => ({ ...prev, [source]: !prev[source] })); }; const selectAllGaps = () => { const sel = {}; Object.keys(gaps).forEach(source => { sel[source] = true; }); setSelectedGaps(sel); }; const selectNoGaps = () => { const sel = {}; Object.keys(gaps).forEach(source => { sel[source] = false; }); setSelectedGaps(sel); }; const selectedGapSources = Object.entries(selectedGaps).filter(([,v]) => v).map(([k]) => k); const selectedGapDays = selectedGapSources.reduce((sum, s) => sum + (gaps[s]?.total_gap_days || 0), 0); const selectedGapCount = selectedGapSources.reduce((sum, s) => sum + (gaps[s]?.gaps?.length || 0), 0); const triggerGapFilling = async () => { if (selectedGapSources.length === 0) { setGapTriggerStatus({ type: "error", msg: "No programs selected" }); return; } setGapTriggering(true); setGapTriggerStatus(null); try { const res = await (window.authFetch || fetch)(`${API}/api/admin/trigger-gaps`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sources: selectedGapSources.join(","), max_budget: maxBudget || 50 }) }); const data = await res.json(); if (res.ok) { setGapTriggerStatus({ type: "success", msg: data.message || "Gap filling triggered!" }); } else { setGapTriggerStatus({ type: "error", msg: data.detail || data.error || "Failed to trigger" }); } } catch (e) { setGapTriggerStatus({ type: "error", msg: e.message }); } setGapTriggering(false); }; // === Shared helpers === // Color/bar now relative to per-program release window const getDaysColor = (days, relDays) => { const pct = relDays > 0 ? (days / relDays) : 0; if (pct >= 0.95) return theme.success; if (pct >= 0.5) return theme.warning; if (pct >= 0.1) return "#f97316"; return theme.danger; }; const getBarWidth = (days, relDays) => { const target = relDays || 365; return Math.min(100, Math.max(2, (days / target) * 100)); }; const getCoverageColor = (pct) => { if (pct >= 98) return theme.success; if (pct >= 90) return theme.warning; return theme.danger; }; const formatGapDate = (iso) => { const d = new Date(iso + "T00:00:00"); return (d.getMonth() + 1) + "/" + d.getDate(); }; if (loading) { return React.createElement("div", { style: { padding: 40, textAlign: "center", color: theme.textDim } }, "Loading coverage data..."); } const gapCount = Object.keys(gaps).length; // === Tab button helper (NEW) === const tabBtn = (id, label) => React.createElement("button", { onClick: () => setActiveTab(id), style: { padding: "10px 0", fontSize: 14, fontWeight: activeTab === id ? 600 : 400, background: "none", border: "none", cursor: "pointer", color: activeTab === id ? theme.accent : theme.textDim, borderBottom: activeTab === id ? `2px solid ${theme.accent}` : "2px solid transparent", marginRight: 24 } }, label); // === Chart tooltip portal (from OLD) === const chartTooltip = hoveredSource && React.createElement("div", { style: { position: "fixed", left: tooltipPos.x, top: tooltipPos.y, zIndex: Z_INGESTION_TOOLTIP, background: "#141414", border: "1px solid #333", borderRadius: 10, padding: 12, boxShadow: "0 12px 40px rgba(0,0,0,0.6)", pointerEvents: "none", width: TOOLTIP_WIDTH_PX, transition: "opacity 0.15s", opacity: 1 } }, React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 } }, React.createElement("span", { style: { fontSize: 13, fontWeight: 700, color: theme.text } }, (PROGRAMS[hoveredSource] || {}).name || hoveredSource), React.createElement("span", { style: { fontSize: 11, color: theme.textDim } }, (chartCache[hoveredSource]?.release_days || 365) + "-day release window") ), chartLoading && !chartCache[hoveredSource] ? React.createElement("div", { style: { height: 140, display: "flex", alignItems: "center", justifyContent: "center", color: theme.textDim, fontSize: 12 } }, React.createElement("span", { style: { animation: "pulse 1s infinite" } }, "Loading...")) : chartCache[hoveredSource] ? React.createElement(DistributionChart, { data: chartCache[hoveredSource].distribution, stats: chartCache[hoveredSource].stats, theme: theme, releaseDays: chartCache[hoveredSource].release_days, releaseDate: chartCache[hoveredSource].release_date, todayDate: chartCache[hoveredSource].today }) : null, chartCache[hoveredSource] && chartCache[hoveredSource].stats && React.createElement("div", { style: { display: "flex", gap: 12, marginTop: 8, paddingTop: 8, borderTop: "1px solid #252525" } }, ...(() => { const st = chartCache[hoveredSource].stats; const relD = st.release_days || 365; const items = [ { label: "Coverage", value: `${st.coverage_pct || 0}%`, color: (st.coverage_pct || 0) >= 90 ? theme.success : (st.coverage_pct || 0) >= 50 ? theme.warning : theme.danger }, { label: "Days", value: `${st.days_in_release_window || st.days_with_data || 0}/${relD}`, color: theme.text }, { label: "Gaps", value: `${(st.days_with_data || 0) - (st.days_in_release_window || 0)}d`, color: theme.warning }, { label: "Flights/day", value: (st.avg_flights_per_day || 0).toLocaleString(), color: theme.accent }, ]; if (st.avg_j_miles) items.push({ label: "Avg Biz", value: (st.avg_j_miles).toLocaleString(), color: "#fbbf24" }); if (st.avg_y_miles) items.push({ label: "Avg Econ", value: (st.avg_y_miles).toLocaleString(), color: "#10b981" }); return items; })().map(s => React.createElement("div", { key: s.label, style: { flex: 1, textAlign: "center" } }, React.createElement("div", { style: { fontSize: 13, fontWeight: 700, color: s.color } }, s.value), React.createElement("div", { style: { fontSize: 9, color: theme.textDim, marginTop: 2 } }, s.label) )) ) ); // ================================================================ // RENDER // ================================================================ return React.createElement("div", { style: { maxWidth: 1100, margin: "0 auto" } }, // DB Progress Widget at top (from OLD) typeof DBProgressWidget === "function" && React.createElement(ErrorBoundary, null, React.createElement(DBProgressWidget)), // Chart tooltip portal (from OLD) chartTooltip, // --- Tab switcher (NEW) --- React.createElement("div", { style: { display: "flex", borderBottom: `1px solid ${theme.border}`, marginBottom: 20 } }, tabBtn("ingestion", "\uD83D\uDE80 Rolling Ingestion"), tabBtn("gaps", `\uD83D\uDD0D Fill Gaps${gapCount > 0 ? ` (${gapCount})` : ""}`) ), // ================================================================ // TAB: ROLLING INGESTION // ================================================================ activeTab === "ingestion" && React.createElement(React.Fragment, null, React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 20 } }, React.createElement("h2", { style: { fontSize: 20, fontWeight: 700, margin: 0, color: theme.text } }, "\uD83D\uDE80 Ingestion Control"), React.createElement("span", { style: { fontSize: 12, color: theme.textDim } }, coverage.length + " programs \u2022 " + selectedSources.length + " selected") ), // Gap Analysis panel (from OLD) React.createElement(GapAnalysis), // Control bar React.createElement("div", { style: { display: "flex", gap: 16, alignItems: "center", marginBottom: 20, padding: 16, background: theme.card, borderRadius: 10, border: `1px solid ${theme.border}` } }, React.createElement("div", { style: { display: "flex", gap: 8 } }, React.createElement("button", { onClick: selectAll, style: { padding: "6px 12px", fontSize: 12, borderRadius: 6, cursor: "pointer", background: "transparent", border: `1px solid ${theme.border}`, color: theme.text } }, "Select All"), React.createElement("button", { onClick: selectNone, style: { padding: "6px 12px", fontSize: 12, borderRadius: 6, cursor: "pointer", background: "transparent", border: `1px solid ${theme.border}`, color: theme.text } }, "Select None") ), React.createElement("div", { style: { width: 1, height: 30, background: theme.border } }), React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, React.createElement("label", { style: { fontSize: 12, color: theme.textDim, whiteSpace: "nowrap" } }, "Max Pages:"), React.createElement("input", { type: "number", min: 0, value: maxPages, onChange: (e) => setMaxPages(parseInt(e.target.value) || 0), placeholder: "0 = unlimited", style: { width: 90, padding: "6px 10px", fontSize: 12, borderRadius: 6, background: theme.bg, border: `1px solid ${theme.border}`, color: theme.text } }), React.createElement("span", { style: { fontSize: 11, color: theme.textDim } }, "(0 = unlimited)") ), React.createElement("div", { style: { flex: 1 } }), React.createElement("span", { style: { fontSize: 12, color: maxPages > 0 ? theme.text : theme.textDim } }, maxPages > 0 ? `~${selectedSources.length * maxPages} API calls` : `${selectedSources.length} airlines, unlimited pages`), React.createElement("button", { onClick: triggerIngestion, disabled: triggering || selectedSources.length === 0, style: { padding: "8px 20px", fontSize: 13, fontWeight: 600, borderRadius: 8, cursor: triggering ? "wait" : "pointer", border: "none", background: selectedSources.length === 0 ? theme.border : theme.accent, color: selectedSources.length === 0 ? theme.textDim : "#fff" } }, triggering ? "Triggering..." : "\u25B6 Run Ingestion") ), // Trigger status triggerStatus && React.createElement("div", { style: { padding: "10px 16px", marginBottom: 16, borderRadius: 8, fontSize: 13, background: triggerStatus.type === "success" ? "#10b98120" : "#ef444420", color: triggerStatus.type === "success" ? theme.success : theme.danger, border: `1px solid ${triggerStatus.type === "success" ? theme.success + "40" : theme.danger + "40"}` } }, triggerStatus.msg), // Airline cards grid (with hover from OLD) React.createElement("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))", gap: 10 } }, coverage.map(airline => React.createElement("div", { key: airline.source, onClick: () => toggleAirline(airline.source), onMouseEnter: (e) => handleCardMouseEnter(airline.source, e), onMouseLeave: handleCardMouseLeave, style: { display: "flex", alignItems: "center", gap: 12, padding: "12px 16px", borderRadius: 10, cursor: "pointer", background: selected[airline.source] ? theme.accent + "15" : theme.card, border: `1px solid ${selected[airline.source] ? theme.accent + "50" : theme.border}`, transition: "all 0.15s" } }, React.createElement("div", { style: { width: 20, height: 20, borderRadius: 4, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", background: selected[airline.source] ? theme.accent : "transparent", border: `2px solid ${selected[airline.source] ? theme.accent : theme.border}` } }, selected[airline.source] && React.createElement("span", { style: { color: "#fff", fontSize: 12, fontWeight: 700 } }, "\u2713") ), React.createElement("div", { style: { flex: 1, minWidth: 0 } }, React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 4 } }, React.createElement("span", { style: { fontSize: 13, fontWeight: 600, color: theme.text } }, (PROGRAMS[airline.source] || {}).name || airline.source), React.createElement("span", { style: { fontSize: 11, color: getDaysColor(airline.days_ahead, airline.release_days), fontWeight: 600 } }, airline.days_ahead >= 0 ? airline.days_ahead + "d / " + (airline.release_days || 365) + "d" : Math.abs(airline.days_ahead) + "d expired") ), React.createElement("div", { style: { height: 4, borderRadius: 2, background: theme.border, marginBottom: 4 } }, React.createElement("div", { style: { height: "100%", borderRadius: 2, width: getBarWidth(airline.days_ahead, airline.release_days) + "%", background: getDaysColor(airline.days_ahead, airline.release_days) } }) ), React.createElement("div", { style: { display: "flex", gap: 8, fontSize: 11, color: theme.textDim, flexWrap: "wrap" } }, React.createElement("span", null, (airline.date_count || 0) + "d cov"), airline.gap_days > 0 && React.createElement("span", { style: { color: theme.warning } }, airline.gap_days + " gaps"), React.createElement("span", null, airline.rows.toLocaleString() + " rows"), airline.complete && React.createElement("span", { style: { color: theme.success } }, "\u2713 Complete") ) ) )) ), // Footer React.createElement("div", { style: { marginTop: 20, padding: 16, background: theme.card, borderRadius: 10, border: `1px solid ${theme.border}`, display: "flex", justifyContent: "space-between", fontSize: 12, color: theme.textDim } }, React.createElement("span", null, "Total: " + coverage.reduce((s, a) => s + a.rows, 0).toLocaleString() + " rows across " + coverage.length + " programs"), React.createElement("span", null, "Full release window: " + coverage.filter(a => a.complete).length + " / " + coverage.length + " programs"), React.createElement("span", null, "Total gaps: " + coverage.reduce((s, a) => s + (a.gap_days || 0), 0) + " days") ) ), // ================================================================ // TAB: FILL GAPS (NEW) // ================================================================ activeTab === "gaps" && React.createElement(React.Fragment, null, gapsLoading ? React.createElement("div", { style: { padding: 40, textAlign: "center", color: theme.textDim } }, "Loading gap analysis...") : gapCount === 0 ? React.createElement("div", { style: { padding: 40, textAlign: "center", color: theme.success, background: theme.card, borderRadius: 10, border: `1px solid ${theme.border}` } }, React.createElement("div", { style: { fontSize: 32, marginBottom: 8 } }, "\u2705"), React.createElement("div", { style: { fontSize: 16, fontWeight: 600 } }, "No gaps detected!"), React.createElement("div", { style: { fontSize: 13, color: theme.textDim, marginTop: 4 } }, "All programs have continuous date coverage.") ) : React.createElement(React.Fragment, null, // Summary bar React.createElement("div", { style: { display: "flex", gap: 24, padding: 16, marginBottom: 20, background: theme.card, borderRadius: 10, border: `1px solid ${theme.border}` } }, React.createElement("div", null, React.createElement("div", { style: { fontSize: 11, color: theme.textDim, marginBottom: 2 } }, "Programs"), React.createElement("div", { style: { fontSize: 18, fontWeight: 700, color: theme.text } }, gapSummary.programs_with_gaps || 0) ), React.createElement("div", null, React.createElement("div", { style: { fontSize: 11, color: theme.textDim, marginBottom: 2 } }, "Total Gaps"), React.createElement("div", { style: { fontSize: 18, fontWeight: 700, color: theme.warning } }, gapSummary.total_gaps || 0) ), React.createElement("div", null, React.createElement("div", { style: { fontSize: 11, color: theme.textDim, marginBottom: 2 } }, "Gap Days"), React.createElement("div", { style: { fontSize: 18, fontWeight: 700, color: theme.danger } }, gapSummary.total_gap_days || 0) ), React.createElement("div", null, React.createElement("div", { style: { fontSize: 11, color: theme.textDim, marginBottom: 2 } }, "Est. API Calls"), React.createElement("div", { style: { fontSize: 18, fontWeight: 700, color: theme.purple } }, "~" + (gapSummary.estimated_api_calls || 0)) ) ), // Control bar React.createElement("div", { style: { display: "flex", gap: 16, alignItems: "center", marginBottom: 20, padding: 16, background: theme.card, borderRadius: 10, border: `1px solid ${theme.border}` } }, React.createElement("div", { style: { display: "flex", gap: 8 } }, React.createElement("button", { onClick: selectAllGaps, style: { padding: "6px 12px", fontSize: 12, borderRadius: 6, cursor: "pointer", background: "transparent", border: `1px solid ${theme.border}`, color: theme.text } }, "Select All"), React.createElement("button", { onClick: selectNoGaps, style: { padding: "6px 12px", fontSize: 12, borderRadius: 6, cursor: "pointer", background: "transparent", border: `1px solid ${theme.border}`, color: theme.text } }, "Select None") ), React.createElement("div", { style: { width: 1, height: 30, background: theme.border } }), React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 8 } }, React.createElement("label", { style: { fontSize: 12, color: theme.textDim, whiteSpace: "nowrap" } }, "Max Budget:"), React.createElement("input", { type: "number", min: 10, max: 900, value: maxBudget, onChange: (e) => setMaxBudget(parseInt(e.target.value) || 50), style: { width: 80, padding: "6px 10px", fontSize: 12, borderRadius: 6, background: theme.bg, border: `1px solid ${theme.border}`, color: theme.text } }), React.createElement("span", { style: { fontSize: 11, color: theme.textDim } }, "API calls") ), React.createElement("div", { style: { flex: 1 } }), React.createElement("span", { style: { fontSize: 12, color: theme.text } }, `${selectedGapSources.length} programs, ${selectedGapCount} gaps, ${selectedGapDays} days`), React.createElement("button", { onClick: triggerGapFilling, disabled: gapTriggering || selectedGapSources.length === 0, style: { padding: "8px 20px", fontSize: 13, fontWeight: 600, borderRadius: 8, cursor: gapTriggering ? "wait" : "pointer", border: "none", background: selectedGapSources.length === 0 ? theme.border : theme.purple, color: selectedGapSources.length === 0 ? theme.textDim : "#fff" } }, gapTriggering ? "Triggering..." : "\uD83D\uDD0D Fill Gaps") ), // Gap trigger status gapTriggerStatus && React.createElement("div", { style: { padding: "10px 16px", marginBottom: 16, borderRadius: 8, fontSize: 13, background: gapTriggerStatus.type === "success" ? "#10b98120" : "#ef444420", color: gapTriggerStatus.type === "success" ? theme.success : theme.danger, border: `1px solid ${gapTriggerStatus.type === "success" ? theme.success + "40" : theme.danger + "40"}` } }, gapTriggerStatus.msg), // Gap program cards grid React.createElement("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(340px, 1fr))", gap: 10 } }, Object.entries(gaps).sort((a, b) => b[1].total_gap_days - a[1].total_gap_days).map(([source, info]) => React.createElement("div", { key: source, onClick: () => toggleGapProgram(source), style: { display: "flex", alignItems: "flex-start", gap: 12, padding: "12px 16px", borderRadius: 10, cursor: "pointer", background: selectedGaps[source] ? theme.purple + "15" : theme.card, border: `1px solid ${selectedGaps[source] ? theme.purple + "50" : theme.border}`, transition: "all 0.15s" } }, React.createElement("div", { style: { width: 20, height: 20, borderRadius: 4, flexShrink: 0, marginTop: 2, display: "flex", alignItems: "center", justifyContent: "center", background: selectedGaps[source] ? theme.purple : "transparent", border: `2px solid ${selectedGaps[source] ? theme.purple : theme.border}` } }, selectedGaps[source] && React.createElement("span", { style: { color: "#fff", fontSize: 12, fontWeight: 700 } }, "\u2713") ), React.createElement("div", { style: { flex: 1, minWidth: 0 } }, React.createElement("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 4 } }, React.createElement("span", { style: { fontSize: 13, fontWeight: 600, color: theme.text } }, (PROGRAMS[source] || {}).name || source), React.createElement("span", { style: { fontSize: 11, color: getCoverageColor(info.coverage_pct), fontWeight: 600 } }, info.coverage_pct + "% covered") ), React.createElement("div", { style: { height: 4, borderRadius: 2, background: theme.border, marginBottom: 6 } }, React.createElement("div", { style: { height: "100%", borderRadius: 2, width: info.coverage_pct + "%", background: getCoverageColor(info.coverage_pct) } }) ), React.createElement("div", { style: { display: "flex", gap: 12, fontSize: 11, color: theme.textDim, marginBottom: 4 } }, React.createElement("span", { style: { color: theme.warning } }, info.gaps.length + (info.gaps.length === 1 ? " gap" : " gaps")), React.createElement("span", { style: { color: theme.danger } }, info.total_gap_days + " days missing"), React.createElement("span", null, info.range_start + " \u2192 " + info.range_end) ), React.createElement("div", { style: { fontSize: 11, color: theme.textDim } }, info.gaps.slice(0, 3).map((g, i) => React.createElement("span", { key: i, style: { display: "inline-block", marginRight: 8, marginTop: 2, padding: "2px 6px", borderRadius: 4, background: theme.bg, border: `1px solid ${theme.border}` } }, formatGapDate(g.start) + "\u2013" + formatGapDate(g.end) + " (" + g.days + "d)")), info.gaps.length > 3 && React.createElement("span", { style: { fontSize: 10, color: theme.textDim } }, " +" + (info.gaps.length - 3) + " more") ) ) )) ), // Gap footer React.createElement("div", { style: { marginTop: 20, padding: 16, background: theme.card, borderRadius: 10, border: `1px solid ${theme.border}`, display: "flex", justifyContent: "space-between", fontSize: 12, color: theme.textDim } }, React.createElement("span", null, selectedGapSources.length + " of " + gapCount + " programs selected"), React.createElement("span", null, "Selected: " + selectedGapCount + " gaps, " + selectedGapDays + " gap days") ) ) ) ); }