fix: robust mobile suspend and auth recovery

This commit is contained in:
2026-03-04 00:51:32 -05:00
parent bdfcd55370
commit 90bb93be17
11 changed files with 257 additions and 47 deletions

View File

@ -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<typeof setTimeout> | 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

View File

@ -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<null>((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) {

View File

@ -11,9 +11,9 @@ export const useCouponStore = defineStore('coupon', () => {
const myCoupons = ref<any[]>([])
const filters = ref<CouponFilters>({})
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
}
}

View File

@ -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
}
}

View File

@ -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<typeof setTimeout> | null = null
let _stopsSafetyTimer: ReturnType<typeof setTimeout> | 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
}
}

View File

@ -9,29 +9,48 @@ export const useScheduleStore = defineStore('schedule', () => {
const isLoading = ref(false)
const error = ref<string | null>(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<typeof setTimeout> | 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
}
}

View File

@ -10,19 +10,38 @@ export const useShuttleStore = defineStore('shuttle', () => {
const error = ref<string | null>(null)
const filters = ref<ShuttleFilters>({})
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<typeof setTimeout> | 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
}
}
}

View File

@ -10,19 +10,38 @@ export const useTaxiStore = defineStore('taxi', () => {
const error = ref<string | null>(null)
const filters = ref<TaxiFilters>({})
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<typeof setTimeout> | 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
}
}
}

View File

@ -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<void> {
}
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 });
</script>

View File

@ -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(() => {
</div>
</div>
<div class="text-right flex flex-col items-end gap-2">
<p class="font-black text-xl text-primary">{{ taxi.shift }}</p>
<p class="font-black text-xl text-primary">{{ taxi.shifts?.[0] || 'Día' }}</p>
<FavoriteButton
item-type="taxi"
:item-id="taxi.id"

View File

@ -144,15 +144,15 @@ function handleOutsideClick(e: MouseEvent) {
if (!target.closest('.route-selector')) dropdownOpen.value = false
}
async function fetchData() {
await routeStore.loadRoutes()
async function fetchData(isBackground = false) {
await routeStore.loadRoutes(undefined, false, isBackground)
if (localSelectedRouteId.value) {
await scheduleStore.loadRouteSchedules(localSelectedRouteId.value)
await scheduleStore.loadRouteSchedules(localSelectedRouteId.value, isBackground)
}
}
function handleRefocus() {
fetchData()
fetchData(true)
}
onMounted(async () => {