From 90bb93be17439951d96d8ef3908306ac1e8c511a Mon Sep 17 00:00:00 2001 From: Hanzo_dev <2002samudiojohan@gmail.com> Date: Wed, 4 Mar 2026 00:51:32 -0500 Subject: [PATCH] fix: robust mobile suspend and auth recovery --- frontend/src/App.vue | 75 +++++++++++++++++++++++++++- frontend/src/stores/auth.ts | 36 +++++++++---- frontend/src/stores/coupon.ts | 8 +-- frontend/src/stores/favorites.ts | 8 +++ frontend/src/stores/route.ts | 44 +++++++++++++--- frontend/src/stores/schedule.ts | 31 +++++++++--- frontend/src/stores/shuttle.ts | 25 ++++++++-- frontend/src/stores/taxi.ts | 25 ++++++++-- frontend/src/views/MapView.vue | 29 +++++++---- frontend/src/views/RoutesView.vue | 15 +++++- frontend/src/views/SchedulesView.vue | 8 +-- 11 files changed, 257 insertions(+), 47 deletions(-) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ba384eb..e38bbf6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,6 +7,14 @@ import { useThemeStore } from './stores/theme' import { useAuthStore } from './stores/auth' import { useFavoritesStore } from './stores/favorites' import { analyticsService } from '@/services/analyticsService' +// 🔧 FIX: Importar stores para resetear isLoading al volver del background +import { useScheduleStore } from './stores/schedule' +import { useRouteStore } from './stores/route' +import { useTaxiStore } from './stores/taxi' +import { useShuttleStore } from './stores/shuttle' +import { useCouponStore } from './stores/coupon' +import { supabase } from '@/supabase' +// useFavoritesStore ya importado arriba (línea 8) // Initialize theme store const route = useRoute() @@ -23,14 +31,68 @@ const isAuthScreen = computed(() => { let lastHiddenAt: number | null = null let refocusDebounceTimer: ReturnType | null = null -function dispatchRefocus(reason: string) { +async function dispatchRefocus(reason: string) { // Debounce: no disparar dos eventos seguidos en menos de 1 segundo if (refocusDebounceTimer) return refocusDebounceTimer = setTimeout(() => { refocusDebounceTimer = null }, 1000) - console.log(`SIBU | App refocus — motivo: ${reason}`) + console.log(`SIBU | App.vue dispatching auth check and refocus — motivo: ${reason}`) + + // 🛡 HIGIENE TRANSACCIONAL: Solo verificamos sesión si el usuario estaba logueado. + // Si Guest, omitimos el Refresh para no fallar + if (authStore.isAuthenticated) { + console.log(`SIBU | Verificando sanidad de sesión Supabase...`) + const timeoutMs = 5000 + + const refreshWithTimeout = Promise.race([ + supabase.auth.refreshSession(), + new Promise((_, reject) => + setTimeout(() => reject(new Error("Auth refresh timeout")), timeoutMs) + ) + ]) + + try { + await refreshWithTimeout + } catch (err) { + console.warn("SIBU | Auth zombie detectado o red muerta. Reseteando sesión y app.", err) + // Fall fast & hard: Romper cualquier loop infinito o socket huérfano + document.body.style.opacity = '0' + await supabase.auth.signOut() + window.location.reload() + return // No despachar `app-refocus`, la app está muriendo + } + } + + // Una vez que sabemos que la autenticación está sana (o no era relevante), enviamos señal a la UI window.dispatchEvent(new CustomEvent('app-refocus')) } +/** + * 🔧 FIX CRÍTICO: Resetear todos los isLoading antes de refocus. + * + * Cuando se apaga/bloquea la pantalla, el OS puede congelar el JS thread. + * En ese caso, los fetch en vuelo son abortados por el navegador y el bloque + * `finally` puede no ejecutarse, dejando isLoading = true para siempre. + * + * Este reset garantiza que la UI no quede bloqueada con un spinner infinito + * al volver de background, mostrando los datos que ya estaban cargados. + */ +function forceResetAllLoadingStates() { + try { + useScheduleStore().$patch({ isLoading: false }) + useRouteStore().$patch({ isLoadingRoutes: false, isLoadingStops: false }) + useTaxiStore().$patch({ isLoading: false }) + useShuttleStore().$patch({ isLoading: false }) + useCouponStore().$patch({ isLoading: false }) + // Favorites: solo existe cuando hay usuario autenticado — el bug específico + if (authStore.isAuthenticated) { + useFavoritesStore().$patch({ isLoading: false }) + } + console.log('SIBU | ✅ Loading states reseteados tras regreso del background') + } catch (e) { + console.warn('SIBU | No se pudo resetear loading states:', e) + } +} + function handleVisibilityChange() { if (document.visibilityState === 'hidden') { lastHiddenAt = Date.now() @@ -40,6 +102,11 @@ function handleVisibilityChange() { const secondsAway = (Date.now() - lastHiddenAt) / 1000 // Umbral bajo (1 s) para capturar retornos rápidos desde Google Maps / links externos if (secondsAway > 1) { + // Reset de loading states: solo si estuvo más de 3s (suficiente para que + // el OS congele el JS thread y los fetch queden colgados) + if (secondsAway > 3) { + forceResetAllLoadingStates() + } dispatchRefocus(`visibilitychange tras ${secondsAway.toFixed(1)}s`) } lastHiddenAt = null @@ -51,6 +118,7 @@ function handleVisibilityChange() { function handlePageShow(event: PageTransitionEvent) { if (event.persisted) { // La página fue restaurada desde bfcache — siempre necesita refocus + forceResetAllLoadingStates() dispatchRefocus('pageshow persisted (bfcache)') } } @@ -63,6 +131,9 @@ function handleWindowFocus() { if (lastHiddenAt !== null) { const secondsAway = (Date.now() - lastHiddenAt) / 1000 if (secondsAway > 1) { + if (secondsAway > 3) { + forceResetAllLoadingStates() + } dispatchRefocus(`window focus tras ${secondsAway.toFixed(1)}s`) } lastHiddenAt = null diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 3165f6b..b95b800 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -18,21 +18,39 @@ export const useAuthStore = defineStore('auth', () => { const isPassenger = computed(() => !role.value || role.value?.toUpperCase() === 'PASSENGER') /** Listens for Supabase Auth state changes */ - supabase.auth.onAuthStateChange(async (_event, session) => { + supabase.auth.onAuthStateChange(async (event, session) => { userSession.value = session - if (session?.user) { - // Immediately fetch their role from standard Public table if present - const { data } = await supabase - .from('users') - .select('*') - .eq('id', session.user.id) - .single() + // Cargar perfil solo en inicio de sesión real, NO en cada token refresh. + // Cuando la pantalla se apaga y vuelve, Supabase re-emite TOKEN_REFRESHED. + // Si se re-fetchea el perfil en ese momento, la red puede estar inestable + // y el fetch queda colgado, dejando userProfile = null y re-disparando + // todos los watchers de auto_location y autenticación. + const profileLoadEvents = ['SIGNED_IN', 'INITIAL_SESSION'] + if (session?.user && profileLoadEvents.includes(event)) { + // Timeout de seguridad: si la red está inestable al despertar, + // no bloquear indefinidamente + const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 10000)) + const profileFetch = (async () => { + try { + const { data } = await supabase + .from('users') + .select('*') + .eq('id', session.user!.id) + .single() + return data ?? null + } catch { + return null + } + })() + + const data = await Promise.race([profileFetch, timeout]) if (data) userProfile.value = data - } else { + } else if (!session?.user) { userProfile.value = null } + // TOKEN_REFRESHED: no se re-fetchea el perfil, userSession ya se actualizó arriba }) async function login(email: string, pass: string, keepSession: boolean = false) { diff --git a/frontend/src/stores/coupon.ts b/frontend/src/stores/coupon.ts index 3dd4b96..9a633d4 100644 --- a/frontend/src/stores/coupon.ts +++ b/frontend/src/stores/coupon.ts @@ -11,9 +11,9 @@ export const useCouponStore = defineStore('coupon', () => { const myCoupons = ref([]) const filters = ref({}) - async function loadCoupons(newFilters?: CouponFilters) { - if (isLoading.value) return; - isLoading.value = true + async function loadCoupons(newFilters?: CouponFilters, isBackground = false) { + if (isLoading.value && !isBackground) return; + if (!isBackground) isLoading.value = true error.value = null if (newFilters) { filters.value = newFilters @@ -24,7 +24,7 @@ export const useCouponStore = defineStore('coupon', () => { error.value = e instanceof Error ? e.message : 'Failed to load coupons' console.error('Error loading coupons:', e) } finally { - isLoading.value = false + if (!isBackground) isLoading.value = false } } diff --git a/frontend/src/stores/favorites.ts b/frontend/src/stores/favorites.ts index 41bbbc8..a163a1c 100644 --- a/frontend/src/stores/favorites.ts +++ b/frontend/src/stores/favorites.ts @@ -24,6 +24,13 @@ export const useFavoritesStore = defineStore('favorites', () => { async function loadFavorites() { isLoading.value = true + // Safety: si la red está inestable al awakening del background, no quedar cargando + const safetyTimer = setTimeout(() => { + if (isLoading.value) { + console.warn('SIBU | favoritesStore: safety timeout — reseteando isLoading') + isLoading.value = false + } + }, 12000) try { const { data: userData } = await supabase.auth.getUser() if (!userData?.user) { favorites.value = []; return } @@ -37,6 +44,7 @@ export const useFavoritesStore = defineStore('favorites', () => { } catch (error) { console.error('Error loading favorites:', error) } finally { + clearTimeout(safetyTimer) isLoading.value = false } } diff --git a/frontend/src/stores/route.ts b/frontend/src/stores/route.ts index fe90f6a..f65b24b 100644 --- a/frontend/src/stores/route.ts +++ b/frontend/src/stores/route.ts @@ -18,19 +18,46 @@ export const useRouteStore = defineStore('route', () => { const hasSelectedRoute = computed(() => selectedRouteId.value !== null && selectedRouteName.value !== null) - async function loadRoutes(filters?: { originCity?: string, destinationCity?: string }, force = false) { + // Safety: nunca más de 12s cargando (protección contra thread congelado por OS al apagar pantalla) + let _routesSafetyTimer: ReturnType | null = null + let _stopsSafetyTimer: ReturnType | null = null + function _startRoutesSafety() { + if (_routesSafetyTimer) clearTimeout(_routesSafetyTimer) + _routesSafetyTimer = setTimeout(() => { + if (isLoadingRoutes.value) { + console.warn('SIBU | routeStore: routes safety timeout — reseteando isLoadingRoutes') + isLoadingRoutes.value = false + } + }, 12000) + } + function _startStopsSafety() { + if (_stopsSafetyTimer) clearTimeout(_stopsSafetyTimer) + _stopsSafetyTimer = setTimeout(() => { + if (isLoadingStops.value) { + console.warn('SIBU | routeStore: stops safety timeout — reseteando isLoadingStops') + isLoadingStops.value = false + } + }, 12000) + } + + async function loadRoutes(filters?: { originCity?: string, destinationCity?: string }, force = false, isBackground = false) { const CACHE_TIME = 1000 * 60 * 15; // 15 minutos const now = Date.now(); - // Guard: Si ya se están cargando rutas, no iniciar otra petición - if (isLoadingRoutes.value) return; + // Guard: Si ya se están cargando rutas y NO estamos en background, omitir + // Si estamos en background, podemos sobreescribir la carga sin mostrar spinner + if (isLoadingRoutes.value && !isBackground) return; // Si no forzamos, no hay filtros raros, ya tenemos rutas y aún no expira el caché, omitir llamada - if (!force && !filters && allRoutes.value.length > 0 && (now - lastFetched.value < CACHE_TIME)) { + // Excepción: isBackground de refocus obliga a actualizar para asegurar frescura después de mucho rato apagado. + if (!force && !isBackground && !filters && allRoutes.value.length > 0 && (now - lastFetched.value < CACHE_TIME)) { return } - isLoadingRoutes.value = true + if (!isBackground) { + isLoadingRoutes.value = true + _startRoutesSafety() + } error.value = null try { allRoutes.value = await routesService.getAllRoutes(filters) @@ -39,7 +66,10 @@ export const useRouteStore = defineStore('route', () => { error.value = e instanceof Error ? e.message : 'Failed to load routes' console.error('Error loading routes:', e) } finally { - isLoadingRoutes.value = false + if (!isBackground) { + if (_routesSafetyTimer) { clearTimeout(_routesSafetyTimer); _routesSafetyTimer = null } + isLoadingRoutes.value = false + } } } @@ -58,6 +88,7 @@ export const useRouteStore = defineStore('route', () => { if (isLoadingStops.value) return []; isLoadingStops.value = true error.value = null + _startStopsSafety() try { const stops = await routesService.getRouteStops(routeId) selectedRouteStops.value = stops @@ -69,6 +100,7 @@ export const useRouteStore = defineStore('route', () => { selectedRouteStops.value = [] return [] } finally { + if (_stopsSafetyTimer) { clearTimeout(_stopsSafetyTimer); _stopsSafetyTimer = null } isLoadingStops.value = false } } diff --git a/frontend/src/stores/schedule.ts b/frontend/src/stores/schedule.ts index b4f1705..4101a72 100644 --- a/frontend/src/stores/schedule.ts +++ b/frontend/src/stores/schedule.ts @@ -9,29 +9,48 @@ export const useScheduleStore = defineStore('schedule', () => { const isLoading = ref(false) const error = ref(null) - async function loadRouteSchedules(routeId: string) { - isLoading.value = true + // Safety: nunca más de 12s cargando (protección contra thread congelado por OS al apagar pantalla) + let _safetyTimer: ReturnType | null = null + function _startSafetyTimer() { + _clearSafetyTimer() + _safetyTimer = setTimeout(() => { + if (isLoading.value) { + console.warn('SIBU | scheduleStore: safety timeout — reseteando isLoading') + isLoading.value = false + } + }, 12000) + } + function _clearSafetyTimer() { + if (_safetyTimer) { clearTimeout(_safetyTimer); _safetyTimer = null } + } + + async function loadRouteSchedules(routeId: string, isBackground = false) { + if (!isBackground) isLoading.value = true error.value = null + if (!isBackground) _startSafetyTimer() try { schedules.value = await schedulesService.getRouteSchedules(routeId) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to load schedules' console.error('Error loading schedules:', e) } finally { - isLoading.value = false + if (!isBackground) _clearSafetyTimer() + if (!isBackground) isLoading.value = false } } - async function loadStopSchedules(stopId: string) { - isLoading.value = true + async function loadStopSchedules(stopId: string, isBackground = false) { + if (!isBackground) isLoading.value = true error.value = null + if (!isBackground) _startSafetyTimer() try { schedules.value = await schedulesService.getStopSchedules(stopId) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to load schedules' console.error('Error loading schedules:', e) } finally { - isLoading.value = false + if (!isBackground) _clearSafetyTimer() + if (!isBackground) isLoading.value = false } } diff --git a/frontend/src/stores/shuttle.ts b/frontend/src/stores/shuttle.ts index 68707d5..deff471 100644 --- a/frontend/src/stores/shuttle.ts +++ b/frontend/src/stores/shuttle.ts @@ -10,19 +10,38 @@ export const useShuttleStore = defineStore('shuttle', () => { const error = ref(null) const filters = ref({}) - async function loadShuttles(newFilters?: ShuttleFilters) { - isLoading.value = true + // Safety: nunca más de 12s cargando (protección contra thread congelado por OS al apagar pantalla) + let _safetyTimer: ReturnType | null = null + function _startSafetyTimer() { + _clearSafetyTimer() + _safetyTimer = setTimeout(() => { + if (isLoading.value) { + console.warn('SIBU | shuttleStore: safety timeout — reseteando isLoading') + isLoading.value = false + } + }, 12000) + } + function _clearSafetyTimer() { + if (_safetyTimer) { clearTimeout(_safetyTimer); _safetyTimer = null } + } + + async function loadShuttles(newFilters?: ShuttleFilters, isBackground = false) { + if (!isBackground) isLoading.value = true error.value = null if (newFilters) { filters.value = newFilters } + if (!isBackground) _startSafetyTimer() try { shuttles.value = await shuttlesService.getAllShuttles(filters.value) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to load shuttles' console.error('Error loading shuttles:', e) } finally { - isLoading.value = false + if (!isBackground) { + _clearSafetyTimer() + isLoading.value = false + } } } diff --git a/frontend/src/stores/taxi.ts b/frontend/src/stores/taxi.ts index 07b823f..a32a873 100644 --- a/frontend/src/stores/taxi.ts +++ b/frontend/src/stores/taxi.ts @@ -10,19 +10,38 @@ export const useTaxiStore = defineStore('taxi', () => { const error = ref(null) const filters = ref({}) - async function loadTaxis(newFilters?: TaxiFilters) { - isLoading.value = true + // Safety: nunca más de 12s cargando (protección contra thread congelado por OS al apagar pantalla) + let _safetyTimer: ReturnType | null = null + function _startSafetyTimer() { + _clearSafetyTimer() + _safetyTimer = setTimeout(() => { + if (isLoading.value) { + console.warn('SIBU | taxiStore: safety timeout — reseteando isLoading') + isLoading.value = false + } + }, 12000) + } + function _clearSafetyTimer() { + if (_safetyTimer) { clearTimeout(_safetyTimer); _safetyTimer = null } + } + + async function loadTaxis(newFilters?: TaxiFilters, isBackground = false) { + if (!isBackground) isLoading.value = true error.value = null if (newFilters) { filters.value = newFilters } + if (!isBackground) _startSafetyTimer() try { taxis.value = await taxisService.getAllTaxis(filters.value) } catch (e) { error.value = e instanceof Error ? e.message : 'Failed to load taxis' console.error('Error loading taxis:', e) } finally { - isLoading.value = false + if (!isBackground) { + _clearSafetyTimer() + isLoading.value = false + } } } diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue index 1aa4b70..f0f8d5e 100644 --- a/frontend/src/views/MapView.vue +++ b/frontend/src/views/MapView.vue @@ -135,17 +135,17 @@ function closePromoModal() { selectedPromo.value = null; } -async function fetchData() { +async function fetchData(isBackground = false) { await Promise.all([ - routeStore.loadRoutes(), - couponStore.loadCoupons({ active_only: true }) + routeStore.loadRoutes(undefined, false, isBackground), + couponStore.loadCoupons({ active_only: true }, isBackground) ]); updateActiveUnits(); } async function handleRefocus() { - // Refrescar datos en fondo - fetchData(); + // Refrescar datos en fondo de manera silenciosa (isBackground = true) + fetchData(true); await nextTick(); @@ -419,7 +419,9 @@ function locateUser(): Promise { } resolve(); }, - { enableHighAccuracy: true, timeout: 8000, maximumAge: 30000 } + // maximumAge: 5 min — al volver del background, usa posición cacheada inmediatamente + // en lugar de esperar que el GPS se reactive (evita timeout al desbloquear pantalla) + { enableHighAccuracy: false, timeout: 6000, maximumAge: 300000 } ); }); } @@ -515,10 +517,19 @@ function handleImageError(event: Event) { } // Watch for user profile to trigger location if preference is enabled OR on auth changes -watch([() => authStore.userProfile?.auto_location, isLoaded], ([canLocate, loaded]) => { +// Nota: solo se activa si NO tenemos coordenadas aún Y el mapa está cargado. +// Se evita re-disparar por TOKEN_REFRESHED porque onAuthStateChange ya no recarga +// el perfil en ese evento, así que userProfile no cambia al despertar la pantalla. +watch([() => authStore.userProfile?.auto_location, isLoaded], ([canLocate, loaded], [prevCanLocate]) => { + // Solo localizar si: el mapa cargó, auto_location está activo, + // y NO tenemos coords aún (o el mapa se reinicializó sin coords) if (canLocate && loaded && !userCoords.value) { - console.log('SIBU | Iniciando geolocalización automática...'); - locateUser(); + // Extra guard: no re-disparar si auto_location no cambió (solo isLoaded cambió) + // Esto previene relocalización innecesaria al volver del background + if (prevCanLocate !== undefined || !userCoords.value) { + console.log('SIBU | Iniciando geolocalización automática...'); + locateUser(); + } } }, { immediate: true }); diff --git a/frontend/src/views/RoutesView.vue b/frontend/src/views/RoutesView.vue index fefd2a0..e85c3ac 100644 --- a/frontend/src/views/RoutesView.vue +++ b/frontend/src/views/RoutesView.vue @@ -20,12 +20,25 @@ const englishOnly = ref(false) onMounted(async () => { analyticsService.logEvent({ event_name: 'screen_view', screen_name: 'Transport' }) + window.addEventListener('app-refocus', handleRefocus) await Promise.all([ routeStore.loadRoutes(), taxiStore.loadTaxis() ]) }) +function handleRefocus() { + Promise.all([ + routeStore.loadRoutes(undefined, false, true), + taxiStore.loadTaxis(undefined, true) + ]) +} + +import { onUnmounted } from 'vue' +onUnmounted(() => { + window.removeEventListener('app-refocus', handleRefocus) +}) + const handleBusSearch = async () => { await routeStore.loadRoutes({ originCity: originSearch.value, @@ -237,7 +250,7 @@ const correlimientos = computed(() => {
-

{{ taxi.shift }}

+

{{ taxi.shifts?.[0] || 'Día' }}

{