From 33868c9b7f98c1ed1b46492ed23e8bd8aae1e2f7 Mon Sep 17 00:00:00 2001 From: FlightWork Dev Date: Mon, 6 Apr 2026 15:49:38 +0200 Subject: [PATCH] feat: integrate StaffMonitor for real-time stand, check-in, and gate data - Add src/utils/staffMonitor.ts: fetches and parses Pisa airport StaffMonitor (departures + arrivals in parallel, 8s timeout, silent fail on error) - Update FlightScreen.tsx: display stand, CI desk, and gate badges per flight using normalised flight number as key Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/screens/FlightScreen.tsx | 56 +++++++++++++++ src/utils/staffMonitor.ts | 134 +++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 src/utils/staffMonitor.ts diff --git a/src/screens/FlightScreen.tsx b/src/screens/FlightScreen.tsx index 155fb94..77baee5 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -12,6 +12,7 @@ import { useAppTheme } from '../context/ThemeContext'; import { useAirport } from '../context/AirportContext'; import { getAirlineOps, getAirlineColor } from '../utils/airlineOps'; import { fetchAirportScheduleRaw } from '../utils/fr24api'; +import { fetchStaffMonitorData, type StaffMonitorInfo } from '../utils/staffMonitor'; import { formatAirportHeader } from '../utils/airportSettings'; import { requestWidgetUpdate } from 'react-native-android-widget'; import { WIDGET_CACHE_KEY } from '../widgets/widgetTaskHandler'; @@ -245,6 +246,7 @@ export default function FlightScreen() { const [scheduledCount, setScheduledCount] = useState(0); const [pinnedFlightId, setPinnedFlightId] = useState(null); const [inboundArrivals, setInboundArrivals] = useState>({}); + const [staffData, setStaffData] = useState>(new Map()); // Carica preferenza notifiche salvata useEffect(() => { @@ -273,6 +275,9 @@ export default function FlightScreen() { } setInboundArrivals(inboundMap); + // Fetch dati operativi reali da staffMonitor (stand, banco CI, gate) — fail silenzioso + fetchStaffMonitorData().then(sm => setStaffData(sm)); + setArrivals(fetchedArrivals); setDepartures(fetchedDepartures); @@ -544,6 +549,10 @@ export default function FlightScreen() { const flightId = item.flight?.identification?.number?.default || null; const isPinned = flightId !== null && flightId === pinnedFlightId; + // Dati operativi reali dallo StaffMonitor (stand fisico, banco CI, gate) + const smKey = (flightId || '').replace(/\s/g, ''); + const smInfo = staffData.get(smKey); + return ( + {/* StaffMonitor: Stand · Check-in banco · Gate */} + {!!smInfo && (!!smInfo.stand || !!smInfo.checkin || !!smInfo.gate) && ( + + {!!smInfo.stand && ( + + + Stand + {smInfo.stand} + + )} + {!!smInfo.checkin && ( + + + CI + {smInfo.checkin} + + )} + {!!smInfo.gate && ( + + + Gate + {smInfo.gate} + + )} + + )} ); }, [activeTab, userShift, s, pinnedFlightId, pinFlight, unpinFlight, inboundArrivals, colors]); @@ -766,5 +801,26 @@ function makeStyles(c: any) { opsTime: { fontSize: 13, fontWeight: '800', color: c.primaryDark }, pinBtn: { width: 34, height: 34, borderRadius: 17, backgroundColor: 'rgba(255,255,255,0.15)', justifyContent: 'center', alignItems: 'center' }, pinBtnActive: { backgroundColor: 'rgba(245,158,11,0.25)' }, + staffRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + paddingHorizontal: 14, + paddingVertical: 8, + borderTopWidth: 1, + borderTopColor: c.border, + backgroundColor: c.cardSecondary ?? c.bg, + }, + staffBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + backgroundColor: c.primaryLight, + borderRadius: 8, + paddingHorizontal: 8, + paddingVertical: 4, + }, + staffLabel: { fontSize: 9, fontWeight: '600' as const, color: c.textSub, letterSpacing: 0.5 }, + staffValue: { fontSize: 12, fontWeight: '800' as const, color: c.primaryDark }, }); } diff --git a/src/utils/staffMonitor.ts b/src/utils/staffMonitor.ts new file mode 100644 index 0000000..b5f1bcb --- /dev/null +++ b/src/utils/staffMonitor.ts @@ -0,0 +1,134 @@ +// src/utils/staffMonitor.ts + +export type StaffMonitorInfo = { + stand: string; // es. "27" + checkin: string; // es. "17" oppure "33 / 34" per più banchi + gate: string; // es. "11" +}; + +const BASE_URL = 'https://servizi.pisa-airport.com/staffMonitor/staffMonitor'; +const TIMEOUT_MS = 8_000; + +/** + * Rimuove gli zeri iniziali dalla parte numerica del numero volo. + * Il codice IATA è sempre i primi 2 caratteri (lettere o cifre). + * FR08973 → FR8973 + * U202490 → U22490 + * HV05424 → HV5424 + */ +function normalizeFlightNumber(raw: string): string { + const s = raw.trim().toUpperCase(); // normalizza a maiuscolo per match con dati FR24 + if (s.length < 3) return s; + const iata = s.slice(0, 2); + const num = parseInt(s.slice(2), 10); + if (isNaN(num)) return s; + return `${iata}${num}`; +} + +/** Rimuove tutti i tag HTML e decodifica le entity comuni. */ +function extractText(html: string): string { + return html + .replace(//gi, '\n') // converti
in newline prima di strippare i tag + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))) + .trim(); +} + +/** + * Estrae il contenuto di ogni TD da un blocco TR. + * Normalizza i TD self-closing () prima del parsing. + */ +function extractCells(rowHtml: string): string[] { + const normalised = rowHtml.replace(/]*)?\s*\/>/gi, ''); + const cells: string[] = []; + const tdRe = /]*)?>([\s\S]*?)<\/TD>/gi; + let m: RegExpExecArray | null; + while ((m = tdRe.exec(normalised)) !== null) { + cells.push(m[1]); + } + return cells; +} + +/** + * Parsa una riga della tabella (array di celle) estraendo: + * - colonna 1: numero volo (class="clsFlight") + * - colonna 12: stand + * - colonna 13: banco/i check-in + * - colonna 14: gate + */ +function parseRow(cells: string[]): { flightNumber: string; info: StaffMonitorInfo } | null { + if (cells.length < 15) return null; + + const rawFlight = extractText(cells[1]); + if (!rawFlight || rawFlight.length < 3) return null; + // Solo righe con codice IATA valido (2 alfanumerici + cifre) + if (!/^[A-Z0-9]{2}\d+$/i.test(rawFlight)) return null; + + const flightNumber = normalizeFlightNumber(rawFlight); + + const stand = extractText(cells[12]).replace(/°/g, '').trim(); + + // Il banco CI può avere più valori su righe separate, es. "33°\n34°" + const checkin = extractText(cells[13]) + .split(/[\n\r]+/) + .map(d => d.replace(/°/g, '').trim()) + .filter(d => d.length > 0) + .join(' / '); + + const gate = extractText(cells[14]).replace(/°/g, '').trim(); + + return { flightNumber, info: { stand, checkin, gate } }; +} + +/** Parsa una pagina HTML del monitor in una Map flightNumber → StaffMonitorInfo. */ +function parseHtml(html: string): Map { + const result = new Map(); + const trRe = /]*)?>([\s\S]*?)<\/TR>/gi; + let trMatch: RegExpExecArray | null; + while ((trMatch = trRe.exec(html)) !== null) { + const parsed = parseRow(extractCells(trMatch[1])); + if (parsed) result.set(parsed.flightNumber, parsed.info); + } + return result; +} + +async function fetchPage(nature: 'D' | 'A', signal: AbortSignal): Promise { + const res = await fetch(`${BASE_URL}?trans=true&nature=${nature}`, { signal }); + if (!res.ok) throw new Error(`staffMonitor ${nature}: HTTP ${res.status}`); + return res.text(); +} + +/** + * Fetcha e parsa le pagine partenze (D) e arrivi (A) dello StaffMonitor di Pisa in parallelo. + * + * Restituisce una Map keyed per numero volo normalizzato (es. "FR8973") con + * i dati operativi reali: stand, banco check-in, gate. + * + * Non rigetta mai — restituisce Map vuota in caso di qualsiasi errore. + */ +export async function fetchStaffMonitorData(): Promise> { + const controller = new AbortController(); + const tid = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const [depHtml, arrHtml] = await Promise.all([ + fetchPage('D', controller.signal), + fetchPage('A', controller.signal), + ]); + // Arrival data ha precedenza sulle departure per voli in transito/turnaround (intenzionale) + return new Map([ + ...parseHtml(depHtml), + ...parseHtml(arrHtml), + ]); + } catch (err) { + controller.abort(); // cancella l'eventuale fetch gemella ancora in volo + if (__DEV__) console.warn('[staffMonitor] fetch failed:', err); + return new Map(); + } finally { + clearTimeout(tid); + } +}