diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index cf3ba20..0171426 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -134,6 +134,28 @@ jobs: ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }} GRADLE_OPTS: "-Xmx4g -XX:MaxMetaspaceSize=512m" + - name: Validate APK release metadata + run: | + APK=$(find android/app/build/outputs/apk/release -name "*.apk" -type f | head -1) + BADGING=$("$ANDROID_HOME/build-tools/37.0.0/aapt" dump badging "$APK") + APK_VERSION_NAME=$(echo "$BADGING" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p") + APK_VERSION_CODE=$(echo "$BADGING" | sed -n "s/.*versionCode='\([^']*\)'.*/\1/p") + EXPECTED_TAG="v${APK_VERSION_NAME}" + + echo "APK versionName=$APK_VERSION_NAME" + echo "APK versionCode=$APK_VERSION_CODE" + echo "Release tag=${{ steps.meta.outputs.tag }}" + + if [[ "${{ steps.meta.outputs.version }}" != "$APK_VERSION_NAME" ]]; then + echo "app.json version (${{ steps.meta.outputs.version }}) does not match APK versionName ($APK_VERSION_NAME)" + exit 1 + fi + + if [[ "${{ steps.meta.outputs.tag }}" != "$EXPECTED_TAG" ]]; then + echo "Release tag (${{ steps.meta.outputs.tag }}) does not match APK versionName ($APK_VERSION_NAME)" + exit 1 + fi + - name: Rename APK run: | APK=$(find android/app/build/outputs/apk/release -name "*.apk" -type f | head -1) diff --git a/README.md b/README.md index a49eea5..e09194a 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ npm run typecheck APK files are published in [GitHub Releases](https://github.com/TargetMisser/FlightWorkApp/releases). -Latest stable: **v2.6.29** +Latest stable: **v2.6.36** To install: diff --git a/android/app/build.gradle b/android/app/build.gradle index 6d4c392..7195e61 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -105,8 +105,8 @@ android { applicationId 'com.aerostaffpro.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 38 - versionName "2.6.29" + versionCode 42 + versionName "2.6.36" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } diff --git a/app.json b/app.json index 7f62699..19f3fb9 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "AeroStaff Pro", "slug": "AeroStaffPro", - "version": "2.6.29", + "version": "2.6.36", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", diff --git a/package-lock.json b/package-lock.json index 933b3eb..972e4fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aerostaff-pro", - "version": "2.6.29", + "version": "2.6.36", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aerostaff-pro", - "version": "2.6.29", + "version": "2.6.36", "dependencies": { "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.3", diff --git a/package.json b/package.json index ff73029..6338501 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aerostaff-pro", - "version": "2.6.29", + "version": "2.6.36", "main": "index.ts", "scripts": { "start": "expo start", diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 26c05cb..798eaa1 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -7,7 +7,7 @@ const it = { overlayNotepad: 'Blocco Note', overlayPhonebook: 'Rubrica', overlayPasswords: 'Password', overlayManuals: 'Manuali DCS', overlaySettings: 'Impostazioni', // Common - cancel: 'Annulla', save: 'Salva', delete: 'Elimina', error: 'Errore', + cancel: 'Annulla', save: 'Salva', delete: 'Elimina', remove: 'Rimuovi', error: 'Errore', confirm: 'Conferma', ok: 'OK', add: 'Aggiungi', profileTitle: 'Profili aeroporto', profileSubtitle: 'Salva aeroporto e compagnie diverse, poi cambia profilo con un tap.', @@ -42,6 +42,17 @@ const it = { sectionAirport: 'AEROPORTO', airportBase: 'Aeroporto base', airportLoading: 'Caricamento aeroporto...', airportAirlines: 'Compagnie monitorate', airportAirlinesSub: 'Wizz, easyJet, Ryanair…', + sectionFlightData: 'DATI VOLI', + airLabsKey: 'AirLabs sperimentale', + airLabsKeyNotConfigured: 'Non configurato · usa StaffMonitor come fallback', + airLabsKeyDevice: 'Configurato sul dispositivo', + airLabsKeyBuild: 'Configurato nella build', + airLabsModalTitle: 'Chiave API AirLabs', + airLabsModalCopy: 'Incolla una chiave AirLabs per provarlo come fonte voli. Resta nel SecureStore del telefono; se non va, StaffMonitor resta il fallback.', + airLabsModalLabel: 'API key', + airLabsKeySavedTitle: 'AirLabs configurato', + airLabsKeySavedMsg: 'Aggiorna la tab Voli per provare AirLabs come fonte dati.', + airLabsKeyErrorMsg: 'Non sono riuscito a salvare la chiave AirLabs.', sectionNotifications: 'NOTIFICHE', notifFlights: 'Notifiche voli', notifFlightsSub: 'Gestito nella tab Voli', notifReminder: 'Promemoria turno', notifReminderSub: 'Prossimamente', @@ -118,6 +129,8 @@ const it = { flightTitle: 'Voli in tempo reale', flightArrivals: 'Arrivi', flightDepartures: 'Partenze', flightToday: 'Oggi', flightTomorrow: 'Domani', flightNoFlights: 'Nessun volo per questo giorno.', + flightDataSource: 'Fonte voli', + flightProviderDiagnosticsTitle: 'Diagnostica voli', flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Nastro', flightDeparted: 'Partito', flightLanded: 'Atterrato', flightEstimated: 'Stimato', flightOnTime: 'In orario', flightPinned: 'PINNATO', flightPinnedLabel: 'Pinnato', @@ -206,7 +219,7 @@ const en: typeof it = { overlayNotepad: 'Notepad', overlayPhonebook: 'Phonebook', overlayPasswords: 'Password', overlayManuals: 'DCS Manuals', overlaySettings: 'Settings', // Common - cancel: 'Cancel', save: 'Save', delete: 'Delete', error: 'Error', + cancel: 'Cancel', save: 'Save', delete: 'Delete', remove: 'Remove', error: 'Error', confirm: 'Confirm', ok: 'OK', add: 'Add', profileTitle: 'Airport profiles', profileSubtitle: 'Save different airports and airline sets, then switch profiles with one tap.', @@ -241,6 +254,17 @@ const en: typeof it = { sectionAirport: 'AIRPORT', airportBase: 'Home airport', airportLoading: 'Loading airport...', airportAirlines: 'Monitored airlines', airportAirlinesSub: 'Wizz, easyJet, Ryanair…', + sectionFlightData: 'FLIGHT DATA', + airLabsKey: 'Experimental AirLabs', + airLabsKeyNotConfigured: 'Not configured · using StaffMonitor fallback', + airLabsKeyDevice: 'Configured on device', + airLabsKeyBuild: 'Configured in build', + airLabsModalTitle: 'AirLabs API key', + airLabsModalCopy: 'Paste an AirLabs key to test it as a flight source. It stays in the phone SecureStore; if it fails, StaffMonitor remains the fallback.', + airLabsModalLabel: 'API key', + airLabsKeySavedTitle: 'AirLabs configured', + airLabsKeySavedMsg: 'Refresh the Flights tab to test AirLabs as data source.', + airLabsKeyErrorMsg: 'Could not save the AirLabs key.', sectionNotifications: 'NOTIFICATIONS', notifFlights: 'Flight notifications', notifFlightsSub: 'Managed in Flights tab', notifReminder: 'Shift reminder', notifReminderSub: 'Coming soon', @@ -317,6 +341,8 @@ const en: typeof it = { flightTitle: 'Real-time Flights', flightArrivals: 'Arrivals', flightDepartures: 'Departures', flightToday: 'Today', flightTomorrow: 'Tomorrow', flightNoFlights: 'No flights for this day.', + flightDataSource: 'Flight source', + flightProviderDiagnosticsTitle: 'Flight diagnostics', flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Belt', flightDeparted: 'Departed', flightLanded: 'Landed', flightEstimated: 'Estimated', flightOnTime: 'On time', flightPinned: 'PINNED', flightPinnedLabel: 'Pinned', diff --git a/src/screens/FlightScreen.tsx b/src/screens/FlightScreen.tsx index 09dbe5d..1704098 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -12,7 +12,7 @@ import { MaterialIcons } from '@expo/vector-icons'; import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; import { useAirport } from '../context/AirportContext'; import { getAirlineOps, getAirlineColor, AIRLINE_COLORS, AIRLINE_DISPLAY_NAMES } from '../utils/airlineOps'; -import { fetchAirportScheduleRaw } from '../utils/fr24api'; +import { fetchAirportScheduleRaw, type FlightScheduleProviderStatus } from '../utils/fr24api'; import { fetchStaffMonitorData, normalizeFlightNumber, type StaffMonitorFlight } from '../utils/staffMonitor'; import { formatAirportHeader, getAirportAirlines, getStoredAirportAirlines } from '../utils/airportSettings'; import { requestWidgetUpdate } from 'react-native-android-widget'; @@ -36,6 +36,11 @@ const FLIGHTS_RETENTION_SECONDS = 60 * 60; const MIN_NOTIF_MINUTES = 1; const MAX_NOTIF_MINUTES = 90; type FlightAlertTone = 'success' | 'warning' | 'info'; +type FlightDataSourceState = { + sourceLabel: string; + fetchedAt: number; + diagnostics: FlightScheduleProviderStatus[]; +}; type FlightNotificationSettings = { onlyTrackedAirlines: boolean; @@ -57,6 +62,39 @@ const DEFAULT_NOTIFICATION_SETTINGS: FlightNotificationSettings = { departureLeadMinutes: 10, }; +function formatFlightProviderDiagnostics( + source: FlightDataSourceState | null, + locale: string, +): string { + if (!source) return ''; + + const fetchedLabel = new Date(source.fetchedAt).toLocaleTimeString(locale, { + hour: '2-digit', + minute: '2-digit', + }); + const isItalian = locale.startsWith('it'); + const lines = [ + `${isItalian ? 'Fonte attiva' : 'Active source'}: ${source.sourceLabel}`, + `${isItalian ? 'Aggiornato' : 'Updated'}: ${fetchedLabel}`, + ]; + + for (const item of source.diagnostics) { + const status = item.status === 'success' + ? 'OK' + : item.status === 'skipped' + ? (isItalian ? 'saltato' : 'skipped') + : (isItalian ? 'errore' : 'error'); + const counts = item.status === 'success' + ? ` · A:${item.arrivals ?? 0} D:${item.departures ?? 0}` + : ''; + const timing = typeof item.durationMs === 'number' ? ` · ${item.durationMs}ms` : ''; + const message = item.message ? ` · ${item.message}` : ''; + lines.push(`${item.label}: ${status}${counts}${timing}${message}`); + } + + return lines.join('\n'); +} + function normalizeAirlineKey(value: unknown): string { return typeof value === 'string' ? value.trim().toLowerCase().replace(/\s+/g, ' ') @@ -126,7 +164,9 @@ function mergeFlights(cached: any[], fresh: any[], tsField: string): any[] { function pruneExpiredFlights(items: any[], tsField: string, nowSeconds = Date.now() / 1000): any[] { const cutoff = nowSeconds - FLIGHTS_RETENTION_SECONDS; return items.filter(item => { - const ts = item.flight?.time?.scheduled?.[tsField]; + const ts = item.flight?.time?.real?.[tsField] + || item.flight?.time?.estimated?.[tsField] + || item.flight?.time?.scheduled?.[tsField]; if (!ts) return true; return ts >= cutoff; }); @@ -430,6 +470,15 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, const smFlight = smPool.find(sm => sm.flightNumber === normFn) ?? smPool.find(sm => normalizeForMatching(sm.flightNumber) === normFnStripped); + const operational = item.flight?._operational ?? {}; + const terminalGate = (terminal?: string, gate?: string) => { + if (terminal && gate) return `${terminal}/${gate}`; + return gate ?? terminal ?? '—'; + }; + const standLabel = smFlight?.stand ?? operational.stand ?? '—'; + const checkinLabel = smFlight?.checkin ?? operational.checkin ?? '—'; + const gateLabel = smFlight?.gate ?? terminalGate(operational.departureTerminal, operational.departureGate); + const beltLabel = smFlight?.belt ?? operational.belt ?? '—'; const arrivalProgress = activeTab === 'arrivals' && ts ? (() => { const scheduledDep = item.flight?.time?.scheduled?.departure; @@ -661,23 +710,23 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, - Stand {smFlight?.stand ?? '—'} + Stand {standLabel} {activeTab === 'departures' ? ( <> - {t('flightCheckin')} {smFlight?.checkin ?? '—'} + {t('flightCheckin')} {checkinLabel} - {t('flightGate')} {smFlight?.gate ?? '—'} + {t('flightGate')} {gateLabel} ) : ( - {t('flightBelt')} {smFlight?.belt ?? '—'} + {t('flightBelt')} {beltLabel} )} @@ -913,6 +962,7 @@ export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScre const [staffMonitorDeps, setStaffMonitorDeps] = useState([]); const [staffMonitorArrs, setStaffMonitorArrs] = useState([]); const [notifSettings, setNotifSettings] = useState(DEFAULT_NOTIFICATION_SETTINGS); + const [flightDataSource, setFlightDataSource] = useState(null); const lastOpenNotifSettingsSignalRef = useRef(openNotifSettingsSignal); const applySelectedAirlines = useCallback((next: string[]) => { setSelectedAirlines(next); @@ -961,6 +1011,13 @@ export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScre if (cache.date === today) { setAllArrivalsFull(cache.arrivals ?? []); setAllDeparturesFull(cache.departures ?? []); + if (cache.sourceLabel && cache.fetchedAt) { + setFlightDataSource({ + sourceLabel: cache.sourceLabel, + fetchedAt: cache.fetchedAt, + diagnostics: Array.isArray(cache.providerDiagnostics) ? cache.providerDiagnostics : [], + }); + } } } catch {} }); @@ -1016,6 +1073,9 @@ export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScre allDepartures, departures: fetchedDepartures, arrivals: fetchedArrivals, + sourceLabel, + providerDiagnostics, + fetchedAt, } = await fetchAirportScheduleRaw(airportCode); const nextAirportAirlines = getAirportAirlines(airportCode); setAirportAirlines(nextAirportAirlines); @@ -1047,9 +1107,23 @@ export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScre } catch {} const mergedArrs = pruneExpiredFlights(mergeFlights(cachedArrs, allArrivals, 'arrival'), 'arrival'); const mergedDeps = pruneExpiredFlights(mergeFlights(cachedDeps, allDepartures, 'departure'), 'departure'); + const sourceState: FlightDataSourceState = { + sourceLabel: sourceLabel ?? 'Sconosciuta', + fetchedAt: fetchedAt ?? Date.now(), + diagnostics: providerDiagnostics ?? [], + }; setAllArrivalsFull(mergedArrs); setAllDeparturesFull(mergedDeps); - AsyncStorage.setItem(FLIGHTS_CACHE_KEY, JSON.stringify({ arrivals: mergedArrs, departures: mergedDeps })).catch(() => {}); + setFlightDataSource(sourceState); + AsyncStorage.setItem(FLIGHTS_CACHE_KEY, JSON.stringify({ + date: new Date().toISOString().split('T')[0], + airportCode, + arrivals: mergedArrs, + departures: mergedDeps, + sourceLabel: sourceState.sourceLabel, + fetchedAt: sourceState.fetchedAt, + providerDiagnostics: sourceState.diagnostics, + })).catch(() => {}); // Build inbound arrival map: registration → best known arrival timestamp const inboundMap: Record = {}; @@ -1540,6 +1614,24 @@ export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScre + {flightDataSource && ( + setNotifDialog({ + title: t('flightProviderDiagnosticsTitle'), + message: formatFlightProviderDiagnostics(flightDataSource, locale), + tone: 'info', + })} + > + + + {t('flightDataSource')}: {flightDataSource.sourceLabel} + + + + )} + {loading ? ( @@ -1840,6 +1932,8 @@ function makeStyles(c: ThemeColors) { pageTitle: { fontSize: 22, fontWeight: 'bold', color: c.primaryDark }, pageSub: { fontSize: 13, color: c.textSub, marginTop: 2 }, controlsRow: { flexDirection: 'row', gap: 8, padding: 12, backgroundColor: c.card, borderBottomWidth: 1, borderBottomColor: c.border }, + sourceBadge: { flexDirection: 'row', alignItems: 'center', gap: 6, alignSelf: 'flex-start', marginTop: 10, marginHorizontal: 16, paddingHorizontal: 10, paddingVertical: 7, borderRadius: 999, backgroundColor: c.primaryLight, borderWidth: 1, borderColor: c.glassBorder }, + sourceBadgeText: { fontSize: 11, fontWeight: '800', color: c.primaryDark }, segment: { flex: 1, flexDirection: 'row', backgroundColor: c.bg, borderRadius: 8, padding: 3 }, segBtn: { flex: 1, paddingVertical: 7, alignItems: 'center', borderRadius: 6 }, segBtnActive: { backgroundColor: c.card, borderWidth: 1, borderColor: c.primaryLight }, diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index b4c995c..b91a10a 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -23,6 +23,12 @@ import { } from '../utils/updateChecker'; import UpdateModal from '../components/UpdateModal'; import { exportBackup, importBackup } from '../utils/backupManager'; +import { + clearAirLabsApiKey, + getAirLabsApiKey, + getAirLabsKeyState, + saveAirLabsApiKey, +} from '../utils/flightProviderSettings'; // ─── Tema picker ────────────────────────────────────────────────────────────── type ThemeOption = { @@ -192,6 +198,7 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc const { airport, airportCode, setAirportCode, isLoading: airportLoading } = useAirport(); const { t, lang, setLang, languages } = useLanguage(); const [airportModalOpen, setAirportModalOpen] = useState(false); + const [airLabsModalOpen, setAirLabsModalOpen] = useState(false); const [dialogState, setDialogState] = useState(null); const translatedOptions = THEME_OPTIONS.map(opt => ({ @@ -200,6 +207,9 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc sublabel: opt.id === 'light' ? t('themeLightSub') : opt.id === 'dark' ? t('themeDarkSub') : t('themeWeatherSub'), })); const [airportInput, setAirportInput] = useState(airportCode); + const [airLabsInput, setAirLabsInput] = useState(''); + const [airLabsStatus, setAirLabsStatus] = useState(t('airLabsKeyNotConfigured')); + const [savingAirLabs, setSavingAirLabs] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); const [checkingUpdate, setCheckingUpdate] = useState(false); const [showUpdateModal, setShowUpdateModal] = useState(false); @@ -209,6 +219,21 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc getCachedUpdateInfo().then(setUpdateInfo); }, []); + const refreshAirLabsStatus = useCallback(async () => { + const state = await getAirLabsKeyState(); + if (!state.configured) { + setAirLabsStatus(t('airLabsKeyNotConfigured')); + return; + } + + const sourceLabel = state.source === 'device' ? t('airLabsKeyDevice') : t('airLabsKeyBuild'); + setAirLabsStatus(`${sourceLabel}${state.masked ? ` · ${state.masked}` : ''}`); + }, [t]); + + useEffect(() => { + refreshAirLabsStatus().catch(() => {}); + }, [refreshAirLabsStatus]); + const showDialog = useCallback((dialog: DialogState) => { setDialogState(dialog); }, []); @@ -314,6 +339,55 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc setAirportInput(airportCode); }; + const openAirLabsModal = async () => { + setAirLabsInput(await getAirLabsApiKey() ?? ''); + setAirLabsModalOpen(true); + }; + + const closeAirLabsModal = () => { + setAirLabsModalOpen(false); + setAirLabsInput(''); + }; + + const saveAirLabsKey = async () => { + setSavingAirLabs(true); + try { + await saveAirLabsApiKey(airLabsInput); + await refreshAirLabsStatus(); + closeAirLabsModal(); + showDialog({ + title: t('airLabsKeySavedTitle'), + message: t('airLabsKeySavedMsg'), + tone: 'success', + }); + } catch { + showDialog({ + title: t('error'), + message: t('airLabsKeyErrorMsg'), + tone: 'error', + }); + } finally { + setSavingAirLabs(false); + } + }; + + const removeAirLabsKey = async () => { + setSavingAirLabs(true); + try { + await clearAirLabsApiKey(); + await refreshAirLabsStatus(); + closeAirLabsModal(); + } catch { + showDialog({ + title: t('error'), + message: t('airLabsKeyErrorMsg'), + tone: 'error', + }); + } finally { + setSavingAirLabs(false); + } + }; + const saveAirport = async () => { const normalized = normalizeAirportCode(airportInput); if (!isValidAirportCode(normalized)) { @@ -419,6 +493,18 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc + {/* ── Sezione Fonti voli ── */} + {t('sectionFlightData')} + + { openAirLabsModal().catch(() => {}); }} + /> + + {/* ── Sezione Notifiche ── */} {t('sectionNotifications')} @@ -736,6 +822,59 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc + + + + + + {t('airLabsModalTitle')} + + {t('airLabsModalCopy')} + + + {t('airLabsModalLabel')} + + + + + {t('remove')} + + + {savingAirLabs + ? + : {t('save')}} + + + + + ); } diff --git a/src/utils/flightProviderSettings.ts b/src/utils/flightProviderSettings.ts new file mode 100644 index 0000000..8733336 --- /dev/null +++ b/src/utils/flightProviderSettings.ts @@ -0,0 +1,79 @@ +import * as SecureStore from 'expo-secure-store'; + +const AIRLABS_API_KEY_SECURE_KEY = 'aerostaff_airlabs_api_key_v1'; + +declare const process: + | { + env?: Record; + } + | undefined; + +export type AirLabsKeyState = { + configured: boolean; + source: 'device' | 'build' | null; + masked: string | null; +}; + +function sanitizeApiKey(value: string | null | undefined): string { + return (value ?? '').trim(); +} + +function getBuildAirLabsApiKey(): string { + const value = typeof process !== 'undefined' + ? process.env?.EXPO_PUBLIC_AIRLABS_API_KEY + : undefined; + return sanitizeApiKey(value); +} + +export function maskAirLabsApiKey(value: string | null | undefined): string | null { + const key = sanitizeApiKey(value); + if (!key) return null; + if (key.length <= 8) return `${key.slice(0, 2)}••••`; + return `${key.slice(0, 4)}••••${key.slice(-4)}`; +} + +export async function getAirLabsApiKey(): Promise { + try { + const stored = sanitizeApiKey(await SecureStore.getItemAsync(AIRLABS_API_KEY_SECURE_KEY)); + if (stored) return stored; + } catch {} + + const buildKey = getBuildAirLabsApiKey(); + return buildKey || null; +} + +export async function getAirLabsKeyState(): Promise { + try { + const stored = sanitizeApiKey(await SecureStore.getItemAsync(AIRLABS_API_KEY_SECURE_KEY)); + if (stored) { + return { + configured: true, + source: 'device', + masked: maskAirLabsApiKey(stored), + }; + } + } catch {} + + const buildKey = getBuildAirLabsApiKey(); + return { + configured: Boolean(buildKey), + source: buildKey ? 'build' : null, + masked: maskAirLabsApiKey(buildKey), + }; +} + +export async function saveAirLabsApiKey(value: string): Promise { + const key = sanitizeApiKey(value); + if (!key) { + await clearAirLabsApiKey(); + return; + } + + await SecureStore.setItemAsync(AIRLABS_API_KEY_SECURE_KEY, key); +} + +export async function clearAirLabsApiKey(): Promise { + try { + await SecureStore.deleteItemAsync(AIRLABS_API_KEY_SECURE_KEY); + } catch {} +} diff --git a/src/utils/flightProviders/airLabsProvider.ts b/src/utils/flightProviders/airLabsProvider.ts new file mode 100644 index 0000000..22d4246 --- /dev/null +++ b/src/utils/flightProviders/airLabsProvider.ts @@ -0,0 +1,251 @@ +import { AIRLINE_DISPLAY_NAMES } from '../airlineOps'; +import type { FlightScheduleProvider } from './types'; + +const AIRLABS_API_BASE = 'https://airlabs.co/api/v9/schedules'; +const AIRLABS_LIMIT = 50; + +type AirLabsScheduleItem = Record; + +const AIRLINE_NAME_BY_IATA: Record = { + '3O': 'Air Arabia Maroc', + '9H': 'Wizz Air Malta', + AZ: 'ITA Airways', + BA: AIRLINE_DISPLAY_NAMES['british airways'], + EI: AIRLINE_DISPLAY_NAMES['aer lingus'], + EN: 'Air Dolomiti', + EW: 'Eurowings', + FR: AIRLINE_DISPLAY_NAMES.ryanair, + FZ: AIRLINE_DISPLAY_NAMES.flydubai, + G9: 'Air Arabia', + HV: AIRLINE_DISPLAY_NAMES.transavia, + LH: 'Lufthansa', + QY: 'DHL', + RR: 'Buzz', + SK: AIRLINE_DISPLAY_NAMES.sas, + TO: AIRLINE_DISPLAY_NAMES.transavia, + U2: AIRLINE_DISPLAY_NAMES.easyjet, + V7: AIRLINE_DISPLAY_NAMES.volotea, + VY: AIRLINE_DISPLAY_NAMES.vueling, + W4: 'Wizz Air Malta', + W6: AIRLINE_DISPLAY_NAMES.wizz, + W9: 'Wizz Air UK', + XZ: 'Aeroitalia', +}; + +function buildAirLabsUrl(paramName: 'dep_iata' | 'arr_iata', airportCode: string, apiKey: string): string { + const params = [ + ['api_key', apiKey], + [paramName, airportCode], + ['limit', String(AIRLABS_LIMIT)], + ]; + return `${AIRLABS_API_BASE}?${params + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&')}`; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return String(error ?? 'unknown_error'); +} + +function parseUnixTimestamp(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.floor(value); + } + if (typeof value === 'string' && /^\d+$/.test(value.trim())) { + return parseUnixTimestamp(Number(value)); + } + return undefined; +} + +function parseAirLabsDateTime(value: unknown, utc = false): number | undefined { + if (typeof value !== 'string') return undefined; + const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2})/); + if (!match) return undefined; + + const [, y, mo, d, h, mi] = match; + const year = Number(y); + const month = Number(mo) - 1; + const day = Number(d); + const hour = Number(h); + const minute = Number(mi); + const ms = utc + ? Date.UTC(year, month, day, hour, minute, 0, 0) + : new Date(year, month, day, hour, minute, 0, 0).getTime(); + return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined; +} + +function readTime(item: AirLabsScheduleItem, localField: string): number | undefined { + return parseUnixTimestamp(item[`${localField}_ts`]) + ?? parseAirLabsDateTime(item[`${localField}_utc`], true) + ?? parseAirLabsDateTime(item[localField], false); +} + +function airportEndpoint(code: unknown, icao: unknown) { + const iataCode = typeof code === 'string' ? code.toUpperCase() : undefined; + const icaoCode = typeof icao === 'string' ? icao.toUpperCase() : undefined; + const name = iataCode || icaoCode || 'N/A'; + return { + name, + code: { + ...(iataCode ? { iata: iataCode } : {}), + ...(icaoCode ? { icao: icaoCode } : {}), + }, + }; +} + +function normalizeFlightNumber(item: AirLabsScheduleItem): string { + const explicit = typeof item.flight_iata === 'string' ? item.flight_iata.trim() : ''; + if (explicit) return explicit.replace(/\s+/g, ''); + + const airline = typeof item.airline_iata === 'string' ? item.airline_iata.trim().toUpperCase() : ''; + const number = typeof item.flight_number === 'string' || typeof item.flight_number === 'number' + ? String(item.flight_number).trim() + : ''; + return `${airline}${number}`.replace(/\s+/g, '') || 'N/A'; +} + +function statusColor(statusText: unknown, scheduledTs: number | undefined, bestTs: number | undefined): string { + const status = typeof statusText === 'string' ? statusText.toLowerCase() : ''; + if (/cancel/.test(status)) return 'red'; + if (/delay/.test(status)) return 'yellow'; + if (/active|landed|boarding|departed/.test(status)) return 'green'; + if (scheduledTs && bestTs && bestTs - scheduledTs > 5 * 60) return 'yellow'; + return 'gray'; +} + +function cleanString(value: unknown): string | undefined { + if (typeof value !== 'string' && typeof value !== 'number') return undefined; + const cleaned = String(value).trim(); + return cleaned || undefined; +} + +function itemToScheduleItem( + item: AirLabsScheduleItem, + direction: 'arrivals' | 'departures', +): any | null { + const timePrefix = direction === 'arrivals' ? 'arr' : 'dep'; + const timeField = direction === 'arrivals' ? 'arrival' : 'departure'; + const scheduledTs = readTime(item, `${timePrefix}_time`); + if (!scheduledTs) return null; + + const estimatedTs = readTime(item, `${timePrefix}_estimated`); + const actualTs = readTime(item, `${timePrefix}_actual`); + const bestTs = actualTs ?? estimatedTs ?? scheduledTs; + const flightNumber = normalizeFlightNumber(item); + const airlineIata = cleanString(item.airline_iata)?.toUpperCase(); + const airlineIcao = cleanString(item.airline_icao)?.toUpperCase(); + const airlineName = airlineIata ? AIRLINE_NAME_BY_IATA[airlineIata] : undefined; + const depAirport = airportEndpoint(item.dep_iata, item.dep_icao); + const arrAirport = airportEndpoint(item.arr_iata, item.arr_icao); + const status = cleanString(item.status) ?? (estimatedTs && estimatedTs !== scheduledTs ? 'delayed' : 'scheduled'); + + return { + flight: { + identification: { + id: `airlabs_${direction}_${flightNumber}_${scheduledTs}`, + number: { default: flightNumber }, + }, + airline: { + name: airlineName ?? (airlineIata ? `Compagnia ${airlineIata}` : 'Sconosciuta'), + code: { + ...(airlineIata ? { iata: airlineIata } : {}), + ...(airlineIcao ? { icao: airlineIcao } : {}), + }, + }, + aircraft: { + model: { code: cleanString(item.aircraft_icao) }, + }, + airport: { + origin: depAirport, + destination: arrAirport, + }, + time: { + scheduled: { [timeField]: scheduledTs }, + estimated: estimatedTs ? { [timeField]: estimatedTs } : {}, + real: actualTs ? { [timeField]: actualTs } : {}, + }, + status: { + text: status, + generic: { status: { color: statusColor(status, scheduledTs, bestTs) } }, + }, + _operational: { + departureGate: cleanString(item.dep_gate), + departureTerminal: cleanString(item.dep_terminal), + arrivalGate: cleanString(item.arr_gate), + arrivalTerminal: cleanString(item.arr_terminal), + belt: cleanString(item.arr_baggage), + }, + _source: 'airlabs', + }, + }; +} + +async function fetchAirLabsDirection( + airportCode: string, + direction: 'arrivals' | 'departures', + apiKey: string, + signal?: AbortSignal, +): Promise { + const paramName = direction === 'arrivals' ? 'arr_iata' : 'dep_iata'; + const res = await fetch(buildAirLabsUrl(paramName, airportCode, apiKey), { + headers: { Accept: 'application/json' }, + signal, + }); + const body = await res.text(); + + if (!res.ok) { + throw new Error(`AIRLABS_HTTP_${res.status}`); + } + + let json: any; + try { + json = JSON.parse(body); + } catch { + throw new Error('AIRLABS_INVALID_JSON_RESPONSE'); + } + + if (json?.error) { + const code = json.error.code ? `AIRLABS_${String(json.error.code).toUpperCase()}` : 'AIRLABS_ERROR'; + throw new Error(`${code}: ${json.error.message ?? 'Unknown AirLabs error'}`); + } + + const response = Array.isArray(json) ? json : json?.response; + if (!Array.isArray(response)) { + throw new Error('AIRLABS_UNEXPECTED_RESPONSE'); + } + + return response + .map(item => itemToScheduleItem(item, direction)) + .filter((item): item is any => item !== null); +} + +export const airLabsProvider: FlightScheduleProvider = { + id: 'airlabs', + label: 'AirLabs', + supports: ({ airLabsApiKey }) => Boolean(airLabsApiKey), + unavailableMessage: () => 'AirLabs API key non configurata', + fetch: async ({ airportCode, airLabsApiKey, signal }) => { + if (!airLabsApiKey) throw new Error('AIRLABS_API_KEY_MISSING'); + + const [departuresResult, arrivalsResult] = await Promise.allSettled([ + fetchAirLabsDirection(airportCode, 'departures', airLabsApiKey, signal), + fetchAirLabsDirection(airportCode, 'arrivals', airLabsApiKey, signal), + ]); + + const allDepartures = departuresResult.status === 'fulfilled' ? departuresResult.value : []; + const allArrivals = arrivalsResult.status === 'fulfilled' ? arrivalsResult.value : []; + + if (departuresResult.status === 'rejected' && arrivalsResult.status === 'rejected') { + throw new Error( + `AIRLABS_FAILED D:${errorMessage(departuresResult.reason)} A:${errorMessage(arrivalsResult.reason)}`, + ); + } + + if (allArrivals.length + allDepartures.length === 0) { + throw new Error('AIRLABS_EMPTY_SCHEDULE'); + } + + return { allArrivals, allDepartures }; + }, +}; diff --git a/src/utils/flightProviders/fr24Provider.ts b/src/utils/flightProviders/fr24Provider.ts new file mode 100644 index 0000000..7be4807 --- /dev/null +++ b/src/utils/flightProviders/fr24Provider.ts @@ -0,0 +1,37 @@ +import { buildFr24ScheduleUrl } from '../airportSettings'; +import type { FlightScheduleProvider } from './types'; + +export const fr24Provider: FlightScheduleProvider = { + id: 'fr24', + label: 'FlightRadar24', + supports: () => true, + fetch: async ({ airportCode, signal }) => { + const res = await fetch(buildFr24ScheduleUrl(airportCode), { + headers: { + 'User-Agent': 'Mozilla/5.0', + Accept: 'application/json,text/plain,*/*', + }, + signal, + }); + const body = await res.text(); + + if (!res.ok) { + throw new Error(`FR24_HTTP_${res.status}`); + } + if (/^\s* { + const diagnostics: FlightScheduleProviderStatus[] = []; + + for (const provider of providers) { + if (!provider.supports(context)) { + diagnostics.push({ + provider: provider.id, + label: provider.label, + status: 'skipped', + message: provider.unavailableMessage?.(context) ?? `Unsupported airport ${context.airportCode}`, + }); + continue; + } + + const startedAt = Date.now(); + try { + const result = await provider.fetch(context); + const durationMs = Date.now() - startedAt; + diagnostics.push({ + provider: provider.id, + label: provider.label, + status: 'success', + durationMs, + arrivals: result.allArrivals.length, + departures: result.allDepartures.length, + }); + + return { + ...result, + source: provider.id, + sourceLabel: provider.label, + fetchedAt: Date.now(), + diagnostics, + }; + } catch (error) { + diagnostics.push({ + provider: provider.id, + label: provider.label, + status: 'failed', + durationMs: Date.now() - startedAt, + message: errorMessage(error), + }); + if (__DEV__) console.warn(`[flightProviders] ${provider.id} failed:`, error); + } + } + + const summary = diagnostics.map(item => `${item.label}: ${item.message ?? item.status}`).join(' | '); + throw new Error(`NO_FLIGHT_PROVIDER_AVAILABLE ${summary}`); +} diff --git a/src/utils/flightProviders/staffMonitorProvider.ts b/src/utils/flightProviders/staffMonitorProvider.ts new file mode 100644 index 0000000..9866813 --- /dev/null +++ b/src/utils/flightProviders/staffMonitorProvider.ts @@ -0,0 +1,171 @@ +import { AIRLINE_DISPLAY_NAMES } from '../airlineOps'; +import { fetchStaffMonitorData, type StaffMonitorFlight } from '../staffMonitor'; +import type { FlightScheduleProvider } from './types'; + +const STAFF_MONITOR_AIRPORT = 'PSA'; + +type AirlineMeta = { + name: string; + iata: string; + prefixes: string[]; +}; + +const AIRLINE_BY_FLIGHT_PREFIX: AirlineMeta[] = [ + { name: AIRLINE_DISPLAY_NAMES.ryanair, iata: 'FR', prefixes: ['FR', 'RYR'] }, + { name: AIRLINE_DISPLAY_NAMES.easyjet, iata: 'U2', prefixes: ['U2', 'EJU', 'EZY'] }, + { name: AIRLINE_DISPLAY_NAMES.wizz, iata: 'W6', prefixes: ['W6', 'W4', 'W9'] }, + { name: AIRLINE_DISPLAY_NAMES.volotea, iata: 'V7', prefixes: ['V7'] }, + { name: AIRLINE_DISPLAY_NAMES.vueling, iata: 'VY', prefixes: ['VY'] }, + { name: AIRLINE_DISPLAY_NAMES.transavia, iata: 'HV', prefixes: ['HV', 'TO'] }, + { name: AIRLINE_DISPLAY_NAMES['aer lingus'], iata: 'EI', prefixes: ['EI'] }, + { name: AIRLINE_DISPLAY_NAMES['british airways'], iata: 'BA', prefixes: ['BA'] }, + { name: AIRLINE_DISPLAY_NAMES.sas, iata: 'SK', prefixes: ['SK', 'SAS'] }, + { name: AIRLINE_DISPLAY_NAMES.flydubai, iata: 'FZ', prefixes: ['FZ'] }, + { name: 'Aeroitalia', iata: 'XZ', prefixes: ['XZ'] }, + { name: 'Air Arabia Maroc', iata: '3O', prefixes: ['3O'] }, + { name: 'Air Arabia', iata: 'G9', prefixes: ['G9'] }, + { name: 'Air Dolomiti', iata: 'EN', prefixes: ['EN'] }, + { name: 'Buzz', iata: 'RR', prefixes: ['RR'] }, + { name: 'DHL', iata: 'QY', prefixes: ['QY'] }, + { name: 'Eurowings', iata: 'EW', prefixes: ['EW'] }, + { name: 'ITA Airways', iata: 'AZ', prefixes: ['AZ'] }, + { name: 'Lufthansa', iata: 'LH', prefixes: ['LH'] }, +]; + +function inferAirline(flightNumber: string): AirlineMeta { + const normalized = flightNumber.toUpperCase().replace(/\s+/g, ''); + for (const airline of AIRLINE_BY_FLIGHT_PREFIX) { + if (airline.prefixes.some(prefix => normalized.startsWith(prefix))) { + return airline; + } + } + + const fallbackCode = normalized.match(/^([A-Z0-9]{2,3}?)(?=\d)/)?.[1] ?? normalized.slice(0, 2); + return { + name: fallbackCode ? `Compagnia ${fallbackCode}` : 'Sconosciuta', + iata: fallbackCode, + prefixes: [fallbackCode], + }; +} + +function parseStaffMonitorClock(value?: string, baseDate = new Date()): number | undefined { + const match = value?.match(/\b(\d{1,2})[:.](\d{2})\b/); + if (!match) return undefined; + + const hours = Number(match[1]); + const minutes = Number(match[2]); + if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours > 23 || minutes > 59) { + return undefined; + } + + const date = new Date(baseDate); + date.setHours(hours, minutes, 0, 0); + return Math.floor(date.getTime() / 1000); +} + +function alignEstimatedTime(scheduledTs: number | undefined, estimatedTs: number | undefined): number | undefined { + if (!scheduledTs || !estimatedTs) return estimatedTs; + const halfDay = 12 * 60 * 60; + if (estimatedTs < scheduledTs - halfDay) return estimatedTs + 24 * 60 * 60; + if (estimatedTs > scheduledTs + halfDay) return estimatedTs - 24 * 60 * 60; + return estimatedTs; +} + +function statusColor(statusText: string | undefined, scheduledTs: number | undefined, bestTs: number | undefined): string { + const status = (statusText ?? '').toLowerCase(); + if (/cancel|annull/.test(status)) return 'red'; + if (/ritar|delay/.test(status)) return 'yellow'; + if (/imbarco|boarding|atterr|landed|decoll|partit|take/.test(status)) return 'green'; + if (scheduledTs && bestTs && bestTs - scheduledTs > 5 * 60) return 'yellow'; + return 'gray'; +} + +function hasRealEvent(statusText: string | undefined, direction: 'arrivals' | 'departures'): boolean { + const status = (statusText ?? '').toLowerCase(); + return direction === 'arrivals' + ? /atterr|landed/.test(status) + : /decoll|partit|take/.test(status); +} + +function airportEndpoint(name: string, iata?: string, icao?: string) { + return { + name, + code: { + ...(iata ? { iata } : {}), + ...(icao ? { icao } : {}), + }, + }; +} + +function staffMonitorFlightToScheduleItem( + item: StaffMonitorFlight, + direction: 'arrivals' | 'departures', + airportCode: string, + airportName: string, + airportIcao: string | undefined, + now: Date, +): any | null { + const scheduledTs = parseStaffMonitorClock(item.scheduledTime, now); + if (!scheduledTs) return null; + + const estimatedTs = alignEstimatedTime(scheduledTs, parseStaffMonitorClock(item.estimatedTime, now)); + const effectiveTs = estimatedTs ?? scheduledTs; + const timeField = direction === 'arrivals' ? 'arrival' : 'departure'; + const airline = inferAirline(item.flightNumber); + const routeName = item.route ?? 'N/A'; + const homeAirport = airportEndpoint(airportName, airportCode, airportIcao); + const remoteAirport = airportEndpoint(routeName); + const realTs = hasRealEvent(item.status, direction) ? effectiveTs : undefined; + const statusText = item.status ?? (estimatedTs && estimatedTs !== scheduledTs ? 'Stimato' : 'Scheduled'); + + return { + flight: { + identification: { + id: `staffmonitor_${direction}_${item.flightNumber}_${scheduledTs}`, + number: { default: item.flightNumber }, + }, + airline: { + name: airline.name, + code: { iata: airline.iata }, + }, + aircraft: { + registration: item.registration, + model: { code: item.aircraftType }, + }, + airport: direction === 'arrivals' + ? { origin: remoteAirport, destination: homeAirport } + : { origin: homeAirport, destination: remoteAirport }, + time: { + scheduled: { [timeField]: scheduledTs }, + estimated: estimatedTs ? { [timeField]: estimatedTs } : {}, + real: realTs ? { [timeField]: realTs } : {}, + }, + status: { + text: statusText, + generic: { status: { color: statusColor(item.status, scheduledTs, effectiveTs) } }, + }, + _source: 'staffMonitor', + }, + }; +} + +export const staffMonitorProvider: FlightScheduleProvider = { + id: 'staffMonitor', + label: 'StaffMonitor PSA', + supports: ({ airportCode }) => airportCode === STAFF_MONITOR_AIRPORT, + fetch: async ({ airportCode, airport, now = new Date() }) => { + const [departures, arrivals] = await Promise.all([ + fetchStaffMonitorData('D'), + fetchStaffMonitorData('A'), + ]); + + return { + allArrivals: arrivals + .map(item => staffMonitorFlightToScheduleItem(item, 'arrivals', airportCode, airport.name, airport.icao, now)) + .filter((item): item is any => item !== null), + allDepartures: departures + .map(item => staffMonitorFlightToScheduleItem(item, 'departures', airportCode, airport.name, airport.icao, now)) + .filter((item): item is any => item !== null), + }; + }, +}; diff --git a/src/utils/flightProviders/types.ts b/src/utils/flightProviders/types.ts new file mode 100644 index 0000000..3f2d244 --- /dev/null +++ b/src/utils/flightProviders/types.ts @@ -0,0 +1,41 @@ +import type { AirportInfo } from '../airportSettings'; + +export type FlightScheduleProviderId = 'airlabs' | 'fr24' | 'staffMonitor' | 'cache'; + +export type FlightScheduleProviderStatus = { + provider: FlightScheduleProviderId; + label: string; + status: 'success' | 'failed' | 'skipped'; + message?: string; + durationMs?: number; + arrivals?: number; + departures?: number; +}; + +export type FlightScheduleProviderContext = { + airportCode: string; + airport: AirportInfo; + airLabsApiKey?: string | null; + signal?: AbortSignal; + now?: Date; +}; + +export type FlightScheduleProviderResult = { + allArrivals: any[]; + allDepartures: any[]; +}; + +export type FlightSchedulePayload = FlightScheduleProviderResult & { + source: FlightScheduleProviderId; + sourceLabel: string; + fetchedAt: number; + diagnostics: FlightScheduleProviderStatus[]; +}; + +export type FlightScheduleProvider = { + id: FlightScheduleProviderId; + label: string; + supports: (context: FlightScheduleProviderContext) => boolean; + unavailableMessage?: (context: FlightScheduleProviderContext) => string; + fetch: (context: FlightScheduleProviderContext) => Promise; +}; diff --git a/src/utils/fr24api.ts b/src/utils/fr24api.ts index f58d647..918e04e 100644 --- a/src/utils/fr24api.ts +++ b/src/utils/fr24api.ts @@ -1,5 +1,5 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; import { - buildFr24ScheduleUrl, getAirportAirlines, getAirportInfo, getStoredAirportCode, @@ -8,23 +8,33 @@ import { storeDetectedAirportAirlines, type AirportInfo, } from './airportSettings'; +import { + fetchFlightScheduleFromProviders, + type FlightScheduleProviderId, + type FlightScheduleProviderStatus, +} from './flightProviders'; +import { getAirLabsApiKey } from './flightProviderSettings'; const FETCH_TIMEOUT = 10000; // 10 seconds +const SCHEDULE_CACHE_KEY = 'aerostaff_schedule_provider_cache_v1'; +const SCHEDULE_CACHE_TTL_MS = 30 * 60 * 1000; + +export type { FlightScheduleProviderId, FlightScheduleProviderStatus }; export type FR24Schedule = { arrivals: any[]; departures: any[]; airportCode: string; airport: AirportInfo; + source?: FlightScheduleProviderId; + sourceLabel?: string; + providerDiagnostics?: FlightScheduleProviderStatus[]; + fetchedAt?: number; }; -export type FR24ScheduleRaw = { +export type FR24ScheduleRaw = FR24Schedule & { allArrivals: any[]; allDepartures: any[]; - arrivals: any[]; - departures: any[]; - airportCode: string; - airport: AirportInfo; }; function filterAirlines(data: any[], allowedList: string[]) { @@ -42,70 +52,136 @@ async function resolveAirportCode(code?: string): Promise { return isValidAirportCode(normalized) ? normalized : getStoredAirportCode(); } -/** - * Fetch airport schedule from FlightRadar24, filtered by allowed airlines. - * Includes a 10s timeout to prevent UI blocking. - */ -export async function fetchAirportSchedule(code?: string): Promise { +type ScheduleCacheEntry = { + airportCode: string; + allArrivals: any[]; + allDepartures: any[]; + source?: FlightScheduleProviderId; + sourceLabel?: string; + providerDiagnostics?: FlightScheduleProviderStatus[]; + fetchedAt: number; + savedAt: number; +}; + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return String(error ?? 'unknown_error'); +} + +async function loadCachedSchedule(airportCode: string): Promise { + try { + const raw = await AsyncStorage.getItem(SCHEDULE_CACHE_KEY); + if (!raw) return null; + const cache = JSON.parse(raw); + const entry = cache?.[airportCode] as ScheduleCacheEntry | undefined; + if (!entry || Date.now() - entry.savedAt > SCHEDULE_CACHE_TTL_MS) return null; + if (!Array.isArray(entry.allArrivals) || !Array.isArray(entry.allDepartures)) return null; + return entry; + } catch { + return null; + } +} + +async function saveCachedSchedule(entry: ScheduleCacheEntry): Promise { + try { + const raw = await AsyncStorage.getItem(SCHEDULE_CACHE_KEY); + const cache = raw ? JSON.parse(raw) : {}; + cache[entry.airportCode] = entry; + await AsyncStorage.setItem(SCHEDULE_CACHE_KEY, JSON.stringify(cache)); + } catch {} +} + +async function fetchScheduleRawData(code?: string): Promise { + const airportCode = await resolveAirportCode(code); + const airport = getAirportInfo(airportCode); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + let payload: Awaited>; try { - const airportCode = await resolveAirportCode(code); - const res = await fetch(buildFr24ScheduleUrl(airportCode), { - headers: { 'User-Agent': 'Mozilla/5.0' }, + const airLabsApiKey = await getAirLabsApiKey(); + payload = await fetchFlightScheduleFromProviders({ + airportCode, + airport, + airLabsApiKey, signal: controller.signal, }); - const json = await res.json(); - - const allArrivals = json.result?.response?.airport?.pluginData?.schedule?.arrivals?.data || []; - const allDepartures = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; - - await storeDetectedAirportAirlines(airportCode, allArrivals, allDepartures); - const airlines = getAirportAirlines(airportCode); - return { - arrivals: filterAirlines(allArrivals, airlines), - departures: filterAirlines(allDepartures, airlines), + await saveCachedSchedule({ airportCode, - airport: getAirportInfo(airportCode), + allArrivals: payload.allArrivals, + allDepartures: payload.allDepartures, + source: payload.source, + sourceLabel: payload.sourceLabel, + providerDiagnostics: payload.diagnostics, + fetchedAt: payload.fetchedAt, + savedAt: Date.now(), + }); + } catch (error) { + const cached = await loadCachedSchedule(airportCode); + if (!cached) throw error; + + payload = { + allArrivals: cached.allArrivals, + allDepartures: cached.allDepartures, + source: cached.source ?? 'cache', + sourceLabel: `${cached.sourceLabel ?? 'Cache voli'} (cache)`, + fetchedAt: cached.fetchedAt, + diagnostics: [ + ...(cached.providerDiagnostics ?? []), + { + provider: 'cache', + label: 'Cache voli', + status: 'success', + message: `Fallback cache: ${errorMessage(error)}`, + }, + ], }; } finally { clearTimeout(timer); } + + const { allArrivals, allDepartures } = payload; + await storeDetectedAirportAirlines(airportCode, allArrivals, allDepartures); + const airlines = getAirportAirlines(airportCode); + return { + allArrivals, + allDepartures, + arrivals: filterAirlines(allArrivals, airlines), + departures: filterAirlines(allDepartures, airlines), + airportCode, + airport, + source: payload.source, + sourceLabel: payload.sourceLabel, + providerDiagnostics: payload.diagnostics, + fetchedAt: payload.fetchedAt, + }; +} + +/** + * Fetch airport schedule, filtered by allowed airlines. + * Uses the provider layer under the hood: configured external providers first, + * then airport-specific fallbacks and local cache. + */ +export async function fetchAirportSchedule(code?: string): Promise { + const raw = await fetchScheduleRawData(code); + return { + arrivals: raw.arrivals, + departures: raw.departures, + airportCode: raw.airportCode, + airport: raw.airport, + source: raw.source, + sourceLabel: raw.sourceLabel, + providerDiagnostics: raw.providerDiagnostics, + fetchedAt: raw.fetchedAt, + }; } /** - * Fetch raw (unfiltered) schedule — needed when callers also use non-allowed airline data + * Fetch raw (unfiltered) schedule - needed when callers also use non-allowed airline data * (e.g. inbound arrival map by registration). */ export async function fetchAirportScheduleRaw(code?: string): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT); - - try { - const airportCode = await resolveAirportCode(code); - const res = await fetch(buildFr24ScheduleUrl(airportCode), { - headers: { 'User-Agent': 'Mozilla/5.0' }, - signal: controller.signal, - }); - const json = await res.json(); - - const allArrivals = json.result?.response?.airport?.pluginData?.schedule?.arrivals?.data || []; - const allDepartures = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; - - await storeDetectedAirportAirlines(airportCode, allArrivals, allDepartures); - const airlines = getAirportAirlines(airportCode); - return { - allArrivals, - allDepartures, - arrivals: filterAirlines(allArrivals, airlines), - departures: filterAirlines(allDepartures, airlines), - airportCode, - airport: getAirportInfo(airportCode), - }; - } finally { - clearTimeout(timer); - } + return fetchScheduleRawData(code); } // Legacy aliases kept to avoid breaking older imports. diff --git a/src/utils/staffMonitor.ts b/src/utils/staffMonitor.ts index 4281f7a..948d4fe 100644 --- a/src/utils/staffMonitor.ts +++ b/src/utils/staffMonitor.ts @@ -2,6 +2,13 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; export type StaffMonitorFlight = { flightNumber: string; + aircraftType?: string; + trafficType?: string; + registration?: string; + route?: string; + scheduledTime?: string; + estimatedTime?: string; + status?: string; stand?: string; checkin?: string; gate?: string; @@ -56,6 +63,13 @@ function isPhoneOrJunk(val: string): boolean { type ColMap = { flight: number; + aircraftType?: number; + trafficType?: number; + registration?: number; + route?: number; + scheduled?: number; + estimated?: number; + status?: number; stand?: number; checkin?: number; gate?: number; @@ -85,6 +99,13 @@ function detectColumns(headerRow: RawCell[]): ColMap | null { // For "VOLO / FLIGHT" with colspan=2, logo is first sub-col, flight # is last. const flightCol = flightH.start + flightH.span - 1; + const aircraftTypeH = findPos(n => n.includes('ac type') || n.includes('a/c type') || n.includes('aircraft')); + const trafficTypeH = findPos(n => n.includes('tr.type') || n.includes('traffic')); + const registrationH = findPos(n => n === 'reg' || n.includes('registr')); + const routeH = findPos(n => n.includes('dest') || n.includes('from') || n.includes('to') || n === 'da'); + const scheduledH = findPos(n => n === 'sched' || n.includes('schedul') || n.includes('programm')); + const estimatedH = findPos(n => n === 'exp' || n.includes('estim') || n.includes('previst')); + const statusH = findPos(n => n === 'status' || n.includes('stato')); // Use word-boundary checks: 'stand' as a whole word to avoid matching "addetto stand" / "standby" const standH = findPos(n => n === 'stand' || /\bstand\b/.test(n) || n.includes('parch') || n.includes('posiz') || n.includes('piazzola') || n === 'park'); const checkinH = findPos(n => n.includes('check') || n === 'c/i' || n === 'ci' || n === 'banco' || n.includes('desk') || n.includes('bancone')); @@ -93,6 +114,13 @@ function detectColumns(headerRow: RawCell[]): ColMap | null { const beltH = findPos(n => n.includes('belt') || n.includes('nastro') || n.includes('tapis') || n.includes('baggage') || n.includes('reclam') || n.includes('bggl')); const map: ColMap = { flight: flightCol }; + if (aircraftTypeH) map.aircraftType = aircraftTypeH.start; + if (trafficTypeH) map.trafficType = trafficTypeH.start; + if (registrationH) map.registration = registrationH.start; + if (routeH) map.route = routeH.start; + if (scheduledH) map.scheduled = scheduledH.start; + if (estimatedH) map.estimated = estimatedH.start; + if (statusH) map.status = statusH.start; if (standH) map.stand = standH.start; if (checkinH) map.checkin = checkinH.start; if (gateH) map.gate = gateH.start; @@ -120,6 +148,25 @@ function cell(cells: string[], idx: number | undefined): string | undefined { return code; } +function textCell(cells: string[], idx: number | undefined): string | undefined { + if (idx === undefined) return undefined; + const raw = cells[idx]?.trim(); + if (!raw || raw === '/' || raw === '-' || raw === '--') return undefined; + return raw + .replace(/\s*\|\s*/g, ' / ') + .replace(/\s*\/\s*$/g, '') + .replace(/\s+/g, ' ') + .trim() || undefined; +} + +function timeCell(cells: string[], idx: number | undefined): string | undefined { + const raw = textCell(cells, idx); + const match = raw?.match(/\b(\d{1,2})[:.](\d{2})\b/); + if (!match) return undefined; + const hour = match[1].padStart(2, '0'); + return `${hour}:${match[2]}`; +} + /** Extract flight number from a cell that may contain "FR03747 B738" — take first token only. */ function extractFlightCode(raw: string): string | null { const token = raw.trim().split(/\s+/)[0]; @@ -153,6 +200,13 @@ function parseSection(sectionHTML: string): StaffMonitorFlight[] { results.push({ flightNumber, + aircraftType: textCell(cells, colMap.aircraftType), + trafficType: textCell(cells, colMap.trafficType), + registration: textCell(cells, colMap.registration), + route: textCell(cells, colMap.route), + scheduledTime: timeCell(cells, colMap.scheduled), + estimatedTime: timeCell(cells, colMap.estimated), + status: textCell(cells, colMap.status), stand: cell(cells, colMap.stand), checkin: cell(cells, colMap.checkin), gate: cell(cells, colMap.gate), @@ -373,7 +427,7 @@ export async function fetchStaffMonitorData(nature: 'D' | 'A'): Promise `${f.flightNumber} S=${f.stand ?? '-'} CI=${f.checkin ?? '-'} G=${f.gate ?? '-'} B=${f.belt ?? '-'}`).join('\n'); + : results.slice(0, 5).map(f => `${f.flightNumber} ${f.scheduledTime ?? '--:--'}>${f.estimatedTime ?? '--:--'} ${f.route ?? '-'} S=${f.stand ?? '-'} CI=${f.checkin ?? '-'} G=${f.gate ?? '-'} B=${f.belt ?? '-'}`).join('\n'); if (nature === 'D') _lastDebugFlightsD = summary; else _lastDebugFlightsA = summary;