diff --git a/frontend/src/components/BusStopInfoModal.vue b/frontend/src/components/BusStopInfoModal.vue index eca02fe..f1a48ca 100644 --- a/frontend/src/components/BusStopInfoModal.vue +++ b/frontend/src/components/BusStopInfoModal.vue @@ -1,7 +1,7 @@ @@ -81,25 +103,32 @@ watch(() => props.isOpen, async (isOpen) => {
roofing - Shelter + Cubierta
event_seat - Seating + Asientos
accessible - Accessible + Accesible
@@ -128,11 +172,11 @@ watch(() => props.isOpen, async (isOpen) => { @@ -237,13 +281,42 @@ watch(() => props.isOpen, async (isOpen) => { padding: 4px; } +.arrivals-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + .section-title { font-size: 1rem; font-weight: 600; - margin-bottom: 12px; + margin: 0; color: var(--text-primary); } +.gps-badge { + display: flex; + align-items: center; + gap: 4px; + font-size: 0.7rem; + font-weight: 700; + padding: 3px 8px; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.03em; +} +.gps-badge.gps-on { + background: rgba(34, 197, 94, 0.12); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.3); +} +.gps-badge.gps-off { + background: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + .arrivals-list { display: flex; flex-direction: column; @@ -263,19 +336,74 @@ watch(() => props.isOpen, async (isOpen) => { display: flex; align-items: center; gap: 8px; + flex: 1; + min-width: 0; +} + +.route-texts { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; } .bus-icon { color: var(--header-bg); + flex-shrink: 0; } .route-name { - font-weight: 500; + font-weight: 600; + font-size: 0.875rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } +.departure-hint { + font-size: 0.72rem; + color: var(--text-secondary); +} + +/* Raw time (no GPS) */ .arrival-time { font-weight: 700; + font-size: 0.95rem; color: var(--active-color, green); + white-space: nowrap; + flex-shrink: 0; +} + +/* GPS mode: wait badge + clock */ +.arrival-time-block { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + flex-shrink: 0; +} + +.wait-badge { + font-size: 0.8rem; + font-weight: 800; + padding: 2px 8px; + border-radius: 10px; + white-space: nowrap; +} +.wait-badge.urgent { + background: rgba(239, 68, 68, 0.12); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); +} +.wait-badge.normal { + background: rgba(34, 197, 94, 0.12); + color: #22c55e; + border: 1px solid rgba(34, 197, 94, 0.25); +} + +.arrival-clock { + font-size: 0.72rem; + color: var(--text-secondary); } /* Amenities Styles */ @@ -393,4 +521,33 @@ watch(() => props.isOpen, async (isOpen) => { } @keyframes spin { 100% { transform: rotate(360deg); } } + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 24px 16px; + text-align: center; + color: var(--text-secondary); +} + +.empty-state .material-icons { + font-size: 2.5rem; + opacity: 0.4; +} + +.empty-state p { + margin: 0; + font-size: 0.9rem; + font-weight: 500; + color: var(--text-primary); +} + +.empty-hint { + font-size: 0.78rem; + color: var(--text-secondary); + opacity: 0.75; +} + diff --git a/frontend/src/services/busStopsService.ts b/frontend/src/services/busStopsService.ts index 0bbf679..01ac746 100644 --- a/frontend/src/services/busStopsService.ts +++ b/frontend/src/services/busStopsService.ts @@ -2,6 +2,41 @@ import { supabase } from '@/supabase' import type { BusStop, Route } from '@/types' +// Map JS getDay() (0=Sun,6=Sat) to Spanish day names used in dias_operacion +const DAY_MAP: Record = { + 0: 'domingo', + 1: 'lunes', + 2: 'martes', + 3: 'miercoles', + 4: 'jueves', + 5: 'viernes', + 6: 'sabado' +} + +/** Convert "HH:MM:SS" time string to total minutes since midnight */ +function timeToMinutes(t: string): number { + const parts = t.split(':') + return parseInt(parts[0] || '0') * 60 + parseInt(parts[1] || '0') +} + +/** Convert total minutes since midnight back to "HH:MM" string */ +function minutesToTime(m: number): string { + const safeM = ((m % 1440) + 1440) % 1440 // wrap around midnight + const h = Math.floor(safeM / 60) + const min = safeM % 60 + return `${h.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}` +} + +export interface BusArrival { + routeName: string + routeId: string + arrivalTime: string // "HH:MM" when bus arrives at this stop + waitMinutes: number | null // null = no GPS, raw schedule shown + hasGps: boolean + departureTime: string // "HH:MM" when bus departs origin + alreadyPassed: boolean // true if bus has already passed this stop +} + export const busStopsService = { /** Get all bus stops */ async getAllBusStops(): Promise { @@ -29,20 +64,121 @@ export const busStopsService = { return (data || []).map((row: any) => row.routes) as Route[] }, - /** Get estimated next bus arrivals (Mock Data) */ - async getNextBusArrivals(_stopId: string): Promise<{ routeName: string; arrivalTime: string }[]> { - // Mock delay to simulate network request - await new Promise(resolve => setTimeout(resolve, 500)); + /** + * Real-time bus arrival calculator for a specific stop. + * + * Logic: + * 1. Get all routes passing through this stop + their travel_time_minutes to the stop + * 2. Get today's published schedules for those routes + * 3. For each schedule: arrivalAtStop = departure_time + travel_time_minutes + * 4. With GPS: filter to buses not yet passed, calculate wait minutes + * Without GPS: show all future arrivals as raw times for today (option A) + * 5. Return sorted by arrivalTime asc, max 8 results within next 2 hours + */ + async getNextBusesForStop(stopId: string, userLat?: number, userLng?: number): Promise { + const hasGps = userLat !== undefined && userLng !== undefined - // Generate some random mock arrivals - const mockArrivals = [ - { routeName: "Ruta Boquete - David", arrivalTime: "5 min" }, - { routeName: "Ruta David - Boquete", arrivalTime: "12 min" }, - { routeName: "Ruta Circular", arrivalTime: "25 min" } - ]; + // ── STEP 1: Get route_stops entries for this stop (includes travel_time_minutes) ── + const { data: routeStopsData, error: rsError } = await supabase + .from('route_stops') + .select('route_id, travel_time_minutes, stop_delay_minutes') + .eq('stop_id', stopId) - // Randomly return 1-3 arrivals - return mockArrivals.slice(0, Math.floor(Math.random() * 3) + 1); + if (rsError) throw new Error(rsError.message) + if (!routeStopsData || routeStopsData.length === 0) return [] + + const routeIds = routeStopsData.map((rs: any) => rs.route_id) + + // Build a lookup: routeId → travel_time_minutes to THIS stop + const travelTimeMap: Record = {} + for (const rs of routeStopsData as any[]) { + travelTimeMap[rs.route_id] = (rs.travel_time_minutes || 0) + (rs.stop_delay_minutes || 0) + } + + // ── STEP 2: Get routes metadata (name) ── + const { data: routesData, error: routesError } = await supabase + .from('routes') + .select('id, name') + .in('id', routeIds) + + if (routesError) throw new Error(routesError.message) + const routeNameMap: Record = {} + for (const r of (routesData || []) as any[]) { + routeNameMap[r.id] = r.name + } + + // ── STEP 3: Get today's schedules ── + const now = new Date() + const todayDay = DAY_MAP[now.getDay()] + const nowMinutes = now.getHours() * 60 + now.getMinutes() + + const { data: schedulesData, error: schError } = await supabase + .from('bus_schedules') + .select('route_id, departure_time, dias_operacion') + .in('route_id', routeIds) + .eq('is_published', true) + .order('departure_time', { ascending: true }) + + if (schError) throw new Error(schError.message) + if (!schedulesData || schedulesData.length === 0) return [] + + // ── STEP 4: Calculate arrival time for each schedule at THIS stop ── + const arrivals: BusArrival[] = [] + + for (const sched of schedulesData as any[]) { + // Check if this schedule runs today + const diasOp: string[] = sched.dias_operacion || [] + if (!todayDay || !diasOp.includes(todayDay)) continue + + + const depMinutes = timeToMinutes(sched.departure_time) + const travelMins = travelTimeMap[sched.route_id] ?? 0 + const arrivalMinutes = depMinutes + travelMins + + // Handle midnight crossover (e.g. departure 23:50 + 20min travel = 00:10 next day) + const arrivalMinutesNormalized = arrivalMinutes % 1440 + + const alreadyPassed = arrivalMinutesNormalized < nowMinutes + const waitMinutes = hasGps ? (arrivalMinutesNormalized - nowMinutes) : null + + // Without GPS (option A): show all future arrivals as raw times + // With GPS: only show buses that haven't passed yet + if (hasGps && alreadyPassed) continue + + // Skip buses more than 3 hours in the future to avoid showing the whole day + if (hasGps && waitMinutes !== null && waitMinutes > 180) continue + + // Without GPS: skip past arrivals but show up to next 3 hours anyway + if (!hasGps && alreadyPassed && Math.abs(arrivalMinutesNormalized - nowMinutes) > 10) continue + + arrivals.push({ + routeName: routeNameMap[sched.route_id] || 'Ruta desconocida', + routeId: sched.route_id, + arrivalTime: minutesToTime(arrivalMinutesNormalized), + departureTime: minutesToTime(depMinutes), + waitMinutes, + hasGps, + alreadyPassed + }) + } + + // Sort by arrival time ascending, return first 8 + arrivals.sort((a, b) => { + const aMin = timeToMinutes(a.arrivalTime) + const bMin = timeToMinutes(b.arrivalTime) + return aMin - bMin + }) + + return arrivals.slice(0, 8) + }, + + /** + * @deprecated Use getNextBusesForStop instead. + * Kept for compatibility — calls the real engine without GPS context. + */ + async getNextBusArrivals(stopId: string): Promise<{ routeName: string; arrivalTime: string }[]> { + const real = await this.getNextBusesForStop(stopId) + return real.map(a => ({ routeName: a.routeName, arrivalTime: a.arrivalTime })) }, /** Create a new bus stop (Admin) */