feat: optimización de ETA, limpieza automática de rutas y smart location

This commit is contained in:
2026-03-02 09:00:08 -05:00
parent fa8551b19d
commit 9c90a175cc
3 changed files with 153 additions and 102 deletions

View File

@ -31,12 +31,46 @@ export function useETA() {
return R * c; return R * c;
} }
const calcularETA = async (ruta_id: string, parada_cercana: BusStop) => { const calcularETA = async (ruta_id: string, parada_cercana: BusStop | null) => {
cargando.value = true; cargando.value = true;
busesActivos.value = []; busesActivos.value = [];
try { try {
// PASO 1: Obtener detalles de la ruta y todas sus paradas para calcular distancia real // OPTIMIZACIÓN: PASO 1 - Verificar horarios primero (más rápido que cálculos geo)
// Esto permite dar feedback instantáneo de "No hay buses" sin esperar a la ubicación
const diaActual = new Date().getDay();
const dias = ['domingo', 'lunes', 'martes', 'miercoles', 'jueves', 'viernes', 'sabado'];
const diaString = dias[diaActual];
const tipoDia = (diaActual === 0 || diaActual === 6) ? 'weekend' : 'weekday';
const { data: horarios, error: hError } = await supabase
.from('bus_schedules')
.select('id, departure_time, dias_operacion, schedule_type')
.eq('route_id', ruta_id)
.eq('is_active', true)
.eq('is_published', true);
if (hError) throw hError;
const horariosHoy = (horarios ?? []).filter(h => {
if (h.dias_operacion) {
return h.dias_operacion.includes('todos') || h.dias_operacion.includes(diaString);
}
return h.schedule_type === tipoDia || h.schedule_type === 'todos' || !h.schedule_type;
});
if (horariosHoy.length === 0) {
// No hay horarios hoy, no perdemos tiempo en geo
busesActivos.value = [];
cargando.value = false;
return;
}
// Si no tenemos parada aún, no podemos calcular ETA real, pero sabemos que SÍ hay buses.
// Mantenemos el estado 'cargando' hasta que llegue la parada.
if (!parada_cercana) return;
// PASO 2: Obtener detalles de la ruta y todas sus paradas para calcular distancia
const [routeRes, stopsRes] = await Promise.all([ const [routeRes, stopsRes] = await Promise.all([
supabase supabase
.from('routes') .from('routes')
@ -59,69 +93,27 @@ export function useETA() {
const targetStopIndex = routeStops.findIndex(s => s.stop_id === parada_cercana.id); const targetStopIndex = routeStops.findIndex(s => s.stop_id === parada_cercana.id);
if (targetStopIndex === -1) return; if (targetStopIndex === -1) return;
// CALCULAR DISTANCIA ACUMULADA (Terminal hasta Parada Destino) // CALCULAR DISTANCIA ACUMULADA
// Usamos Haversine entre cada parada consecutiva para mayor precisión que una línea recta total
let distanciaAcumuladaKm = 0; let distanciaAcumuladaKm = 0;
for (let i = 0; i < targetStopIndex; i++) { for (let i = 0; i < targetStopIndex; i++) {
const stopA = routeStops[i]; const stopA = routeStops[i];
const stopB = routeStops[i + 1]; const stopB = routeStops[i + 1];
const start = stopA ? (stopA.bus_stops as any) : null; const start = stopA ? (stopA.bus_stops as any) : null;
const end = stopB ? (stopB.bus_stops as any) : null; const end = stopB ? (stopB.bus_stops as any) : null;
if (start?.latitude != null && start?.longitude != null && if (start?.latitude != null && start?.longitude != null &&
end?.latitude != null && end?.longitude != null) { end?.latitude != null && end?.longitude != null) {
distanciaAcumuladaKm += getDistanceKm( distanciaAcumuladaKm += getDistanceKm(start.latitude, start.longitude, end.latitude, end.longitude);
start.latitude, start.longitude,
end.latitude, end.longitude
);
} }
} }
// Aplicar factor de corrección de ruta (las calles no son rectas, aprox +20%)
distanciaAcumuladaKm *= 1.2; distanciaAcumuladaKm *= 1.2;
// PASO 2: Aplicar Fórmula Mejorada (Requerida por el usuario)
// ETA = (Distancia / Velocidad) + (Nº Paradas * Tiempo Parada)
const velocidad = routeData.average_speed_kmh || VELOCIDAD_PROMEDIO_KMH; const velocidad = routeData.average_speed_kmh || VELOCIDAD_PROMEDIO_KMH;
// Tiempo de viaje (en minutos)
const tiempoViajeMinutos = (distanciaAcumuladaKm / velocidad) * 60; const tiempoViajeMinutos = (distanciaAcumuladaKm / velocidad) * 60;
// Tiempo total en paradas (N paradas previas × tiempo promedio)
// numeroParadas = stops antes de llegar a la parada destino
const numeroParadas = targetStopIndex; const numeroParadas = targetStopIndex;
const tiempoParadasMinutos = (numeroParadas * TIEMPO_PARADA_SEGUNDOS) / 60; const tiempoParadasMinutos = (numeroParadas * TIEMPO_PARADA_SEGUNDOS) / 60;
// Tiempo total hasta la parada del usuario
const minutosHastaParada = tiempoViajeMinutos + tiempoParadasMinutos; const minutosHastaParada = tiempoViajeMinutos + tiempoParadasMinutos;
console.log(`SIBU ETA | Ruta: ${ruta_id} | Dist: ${distanciaAcumuladaKm.toFixed(2)}km | Paradas: ${numeroParadas} | Total: ${minutosHastaParada.toFixed(1)}min`); // PASO 3: Calcular ETA para cada salida
// PASO 3: Obtener horarios activos para hoy
const diaActual = new Date().getDay();
const dias = ['domingo', 'lunes', 'martes', 'miercoles', 'jueves', 'viernes', 'sabado'];
const diaString = dias[diaActual];
const tipoDia = (diaActual === 0 || diaActual === 6) ? 'weekend' : 'weekday';
const { data: horarios, error: hError } = await supabase
.from('bus_schedules')
.select('id, departure_time, dias_operacion, schedule_type')
.eq('route_id', ruta_id)
.eq('is_active', true)
.eq('is_published', true);
if (hError) throw hError;
const horariosHoy = (horarios ?? []).filter(h => {
if (h.dias_operacion) {
return h.dias_operacion.includes('todos') || h.dias_operacion.includes(diaString);
}
return h.schedule_type === tipoDia || h.schedule_type === 'todos' || !h.schedule_type;
});
// PASO 4: Calcular ETA para cada salida desde la terminal
const ahora = new Date(); const ahora = new Date();
const minutosAhora = ahora.getHours() * 60 + ahora.getMinutes(); const minutosAhora = ahora.getHours() * 60 + ahora.getMinutes();
const resultados: BusETA[] = []; const resultados: BusETA[] = [];
@ -132,20 +124,14 @@ export function useETA() {
const [hStr, mStr] = salida.split(':'); const [hStr, mStr] = salida.split(':');
const minutosSalida = parseInt(hStr) * 60 + parseInt(mStr); const minutosSalida = parseInt(hStr) * 60 + parseInt(mStr);
// Hora estimada de llegada a la parada del usuario:
const minutosLlegadaParada = minutosSalida + minutosHastaParada; const minutosLlegadaParada = minutosSalida + minutosHastaParada;
// ETA = cuántos minutos faltan desde ahora:
const etaMinutos = minutosLlegadaParada - minutosAhora; const etaMinutos = minutosLlegadaParada - minutosAhora;
const horaLlegada = minutosAHora(minutosLlegadaParada); const horaLlegada = minutosAHora(minutosLlegadaParada);
const horaSalidaFormato = `${hStr.padStart(2, '0')}:${mStr.padStart(2, '0')}`; const horaSalidaFormato = `${hStr.padStart(2, '0')}:${mStr.padStart(2, '0')}`;
let estado: BusETA['estado']; let estado: BusETA['estado'];
if (etaMinutos > 5) { if (etaMinutos > 5) {
const yaPartio = minutosAhora >= minutosSalida; estado = (minutosAhora >= minutosSalida) ? 'en_camino' : 'próximo';
estado = yaPartio ? 'en_camino' : 'próximo';
} else if (etaMinutos >= -2) { } else if (etaMinutos >= -2) {
estado = 'en_camino'; estado = 'en_camino';
} else { } else {
@ -165,14 +151,11 @@ export function useETA() {
resultados.sort((a, b) => { resultados.sort((a, b) => {
const prioridad = { 'en_camino': 0, 'próximo': 1, 'pasó': 2 }; const prioridad = { 'en_camino': 0, 'próximo': 1, 'pasó': 2 };
if (prioridad[a.estado] !== prioridad[b.estado]) { if (prioridad[a.estado] !== prioridad[b.estado]) return prioridad[a.estado] - prioridad[b.estado];
return prioridad[a.estado] - prioridad[b.estado];
}
return a.etaMinutos - b.etaMinutos; return a.etaMinutos - b.etaMinutos;
}); });
busesActivos.value = resultados.slice(0, 3); busesActivos.value = resultados.slice(0, 3);
} catch (e) { } catch (e) {
console.error('SIBU | Error calculando ETA:', e); console.error('SIBU | Error calculando ETA:', e);
} finally { } finally {

View File

@ -59,24 +59,14 @@ export function useParadaCercana() {
const { RoutesService } = await google.maps.importLibrary("routes") as any; const { RoutesService } = await google.maps.importLibrary("routes") as any;
const routeService = new RoutesService(); const routeService = new RoutesService();
for (const stop of top5) { const routePromises = top5.map(async (stop) => {
try { try {
const response = await routeService.computeRoutes({ const response = await routeService.computeRoutes({
origin: { origin: {
location: { location: { latLng: { latitude: ubicacionUsuario.lat, longitude: ubicacionUsuario.lng } }
latLng: {
latitude: ubicacionUsuario.lat,
longitude: ubicacionUsuario.lng
}
}
}, },
destination: { destination: {
location: { location: { latLng: { latitude: stop.latitude, longitude: stop.longitude } }
latLng: {
latitude: stop.latitude,
longitude: stop.longitude
}
}
}, },
travelMode: 'DRIVE', travelMode: 'DRIVE',
routingPreference: 'TRAFFIC_UNAWARE', routingPreference: 'TRAFFIC_UNAWARE',
@ -86,31 +76,28 @@ export function useParadaCercana() {
if (response.routes && response.routes.length > 0) { if (response.routes && response.routes.length > 0) {
const route = response.routes[0]; const route = response.routes[0];
let distTotal = 0; return {
let durTotal = 0; stop,
distance: route.distanceMeters || Infinity,
if (route.distanceMeters) { duration: parseInt(route.duration || "0"),
distTotal = route.distanceMeters; points: route.polyline?.encodedPolyline ? google.maps.geometry.encoding.decodePath(route.polyline.encodedPolyline) : []
} };
if (route.duration) {
// La duración viene como string "123s"
durTotal = parseInt(route.duration);
}
if (distTotal < minimaDistanciaCalles) {
minimaDistanciaCalles = distTotal;
mejorDuracion = durTotal;
mejorParada = stop;
if (route.polyline && route.polyline.encodedPolyline) {
mejorRutaPuntos = google.maps.geometry.encoding.decodePath(route.polyline.encodedPolyline);
}
}
} }
} catch (e) { } catch (e) {
console.warn('Error calculando ruta a parada', stop.name, e); console.warn('Error calculando ruta a parada', stop.name, e);
} }
return null;
});
const results = await Promise.all(routePromises);
for (const res of results) {
if (res && res.distance < minimaDistanciaCalles) {
minimaDistanciaCalles = res.distance;
mejorDuracion = res.duration;
mejorParada = res.stop;
mejorRutaPuntos = res.points;
}
} }
} catch (e) { } catch (e) {
console.error('Error cargando Routes API en useParadaCercana', e); console.error('Error cargando Routes API en useParadaCercana', e);

View File

@ -276,10 +276,9 @@ function selectRouteAndClose(route: any) {
} }
async function updateActiveUnits() { async function updateActiveUnits() {
if (!isLoaded.value) return; if (!isLoaded.value || !routeStore.selectedRouteId) return;
if (routeStore.selectedRouteId && paradaCercana.value) { // Llamamos a calcularETA incluso si no hay paradaCercana aún para un chequeo rápido de disponibilidad
await calcularETA(routeStore.selectedRouteId, paradaCercana.value as BusStop); await calcularETA(routeStore.selectedRouteId, (paradaCercana.value as BusStop) || null);
}
} }
function locateUser(): Promise<void> { function locateUser(): Promise<void> {
@ -333,24 +332,56 @@ const sonarHtml = `
watch([etaCargando, () => busesActivos.value.length], ([loading, count]) => { watch([etaCargando, () => busesActivos.value.length], ([loading, count]) => {
if (!loading && count === 0 && routeStore.selectedRouteId && routeStore.wasSelectedFromMap) { if (!loading && count === 0 && routeStore.selectedRouteId && routeStore.wasSelectedFromMap) {
showETACard.value = true; showETACard.value = true;
// PROBLEMA 2 & 3: Limpieza automática cuando no hay buses
// Reseteamos el estado de la ruta en el store para que el buscador se limpie
// y el mapa se limpie a través de los watchers existentes.
// Pequeño delay para asegurar que ETACard capture los datos antes de limpiar el store
setTimeout(() => {
if (showETACard.value && busesActivos.value.length === 0 && routeStore.selectedRouteId) {
routeStore.clearSelection();
router.replace({ query: {} });
console.log("SIBU | Ruta autolimpiada por falta de buses");
}
}, 300);
} }
}); });
// Watch for route selection changes
watch(() => routeStore.selectedRouteId, (routeId) => { watch(() => routeStore.selectedRouteId, (routeId) => {
if (routeId) { if (routeId) {
if (routeStore.wasSelectedFromMap) { if (routeStore.wasSelectedFromMap) {
// OPTIMIZACIÓN PROBLEMA 1: Paralelismo Total
// Iniciamos dibujo y búsqueda de disponibilidad en paralelo
updateMapMarkers(false); updateMapMarkers(false);
updateActiveUnits();
} else { } else {
clearMapMarkers(); clearMapMarkers();
} }
} else { } else {
clearMapMarkers(); clearMapMarkers();
showETACard.value = false;
}
});
// Watch for paradaCercana to recalculate ETA as soon as it's identified
watch(paradaCercana, (newStop) => {
if (newStop && routeStore.selectedRouteId) {
updateActiveUnits();
} }
}); });
function handleImageError(event: Event) { function handleImageError(event: Event) {
(event.target as HTMLImageElement).src = getImageUrl(null, 'coupon'); (event.target as HTMLImageElement).src = getImageUrl(null, 'coupon');
} }
// AUTO-LOCATION: Watch for user profile to trigger location if preference is enabled
watch(() => authStore.userProfile?.auto_location, (canLocate) => {
if (canLocate && isLoaded.value && !userCoords.value) {
locateUser();
}
}, { immediate: true });
</script> </script>
<template> <template>
@ -380,9 +411,20 @@ function handleImageError(event: Event) {
<span v-if="couponStore.coupons.length > 0" class="offers-badge">{{ couponStore.coupons.length }}</span> <span v-if="couponStore.coupons.length > 0" class="offers-badge">{{ couponStore.coupons.length }}</span>
</button> </button>
<button v-if="isLoaded && (!authStore.userProfile?.auto_location || isMapMoved)" class="location-loader-btn" @click="locateUser"> <!-- SMART LOCATION BUTTON: Hidden by default if auto-location is active, shows up with text when map moved -->
<Transition name="fade-scale">
<button
v-if="isLoaded && (!authStore.userProfile?.auto_location || isMapMoved)"
class="location-btn-smart"
:class="{ 'moved': isMapMoved }"
@click="locateUser"
>
<div class="btn-content">
<span class="material-icons">my_location</span> <span class="material-icons">my_location</span>
<span v-if="isMapMoved" class="btn-text">Volver a Mi Ubicación</span>
</div>
</button> </button>
</Transition>
</div> </div>
</div> </div>
@ -534,15 +576,54 @@ function handleImageError(event: Event) {
border: 2px solid #fff; border: 2px solid #fff;
} }
.location-loader-btn { .location-btn-smart {
background: var(--header-bg); background: var(--header-bg);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
width: 50px;
height: 50px; height: 50px;
border-radius: 50%; border-radius: 25px;
color: var(--active-color); color: var(--active-color);
box-shadow: var(--shadow); box-shadow: var(--shadow);
padding: 0 13px;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
display: flex;
align-items: center;
justify-content: center;
width: 50px; /* Default circular */
overflow: hidden;
}
.location-btn-smart.moved {
width: auto; /* Expand for text */
padding: 0 20px;
background: var(--active-color);
color: #000;
border-color: #000;
}
.btn-content {
display: flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.btn-text {
font-size: 0.85rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.fade-scale-enter-active,
.fade-scale-leave-active {
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.fade-scale-enter-from,
.fade-scale-leave-to {
opacity: 0;
transform: scale(0.5) translateY(20px);
} }
.promo-modal-overlay { .promo-modal-overlay {