refactor: migrate fully to Supabase, remove Firebase/Render/Python backend
- DELETED: entire backend/ (Python/FastAPI — replaced by Supabase)
- DELETED: old/ directory (obsolete code)
- DELETED: render.yaml, inject_api.py, check_tags.py, PENDING_FOR_TOMORROW.md
- DELETED: frontend/src/firebaseConfig.ts (Firebase Auth replaced by Supabase Auth)
- DELETED: frontend/src/services/apiClient.ts (HTTP client for dead backend)
- MIGRATED services to Supabase native:
schedulesService, favoritesService, usersService,
telemetryService (stub), reportsService, analyticsService (stub)
- MIGRATED stores/favorites.ts to Supabase direct queries
- MIGRATED views: SplashScreen, AdminTaxis, AdminDrivers, StrategicAnalytics
- MIGRATED utils/imageUrl.ts to Supabase Storage URLs
- FIXED router/index.ts: guard now uses supabase.auth.getSession()
instead of old localStorage auth_token (fixes logout + map loading)
- FIXED AuthView.vue: removed aggressive watch({ immediate: true })
that caused wrong redirects on map route
- FIXED SplashScreen.vue: navigate() now reads Supabase session + role
- FIXED RLS: added INSERT policy on public.users for trigger
- CONFIRMED: admin@sibu.com assigned ADMIN role in Supabase
2026-02-25 21:49:26 -05:00
< template >
2026-02-21 09:53:31 -05:00
< div class = "strategic-analytics" >
< div class = "header-section" >
< div class = "top-row" >
< button class = "download-btn" @click ="generateReport" >
< span class = "material-icons" > description < / span >
Descargar Informe
< / button >
< div class = "badge" > INTELIGENCIA ESTRATÉGICA < / div >
< / div >
< h1 > Centro de Operaciones < / h1 >
< p class = "subtitle" > Análisis segmentado de rendimiento SIBU < / p >
< / div >
<!-- TACTICAL TAB SELECTOR -- >
< div class = "tabs-control" >
< button
class = "tab-btn"
: class = "{ active: activeTab === 'overview' }"
@ click = "activeTab = 'overview'"
>
< span class = "material-icons" > dashboard < / span >
Visión General
< / button >
< button
class = "tab-btn"
: class = "{ active: activeTab === 'transport' }"
@ click = "activeTab = 'transport'"
>
< span class = "material-icons" > directions _bus < / span >
Logística de Transporte
< / button >
< button
class = "tab-btn"
: class = "{ active: activeTab === 'commerce' }"
@ click = "activeTab = 'commerce'"
>
< span class = "material-icons" > storefront < / span >
Inteligencia Comercial
< / button >
< / div >
< div v-if = "loading" class="loading-state" >
< span class = "material-icons spin" > sync < / span >
< p > Sincronizando con la red ... < / p >
< / div >
< template v-else >
<!-- SECTION 1 : OVERVIEW -- >
< div v-if = "activeTab === 'overview'" class="tab-content animate-fade" >
< div class = "dashboard-layout" >
< div class = "main-content" >
< div class = "kpi-grid" >
< div class = "kpi-card user-active" >
< div class = "kpi-icon" > < span class = "material-icons" > person < / span > < / div >
< div class = "kpi-data" >
< span class = "kpi-value" > { { stats . users ? . registered _active || 0 } } < / span >
< span class = "kpi-label" > Usuarios Registrados Activos < / span >
< / div >
< / div >
< div class = "kpi-card" >
< div class = "kpi-icon" > < span class = "material-icons" > analytics < / span > < / div >
< div class = "kpi-data" >
< span class = "kpi-value" > { { totalInteractionCount } } < / span >
< span class = "kpi-label" > Interacciones Totales Hoy < / span >
< / div >
< / div >
< / div >
< section class = "analysis-section mini" >
< div class = "section-header" >
< span class = "material-icons" > schedule < / span >
< h2 > Mapa de Calor Horario < / h2 >
< / div >
< div class = "chart-container large" >
< Line :data = "usageChartData" :options = "usageChartOptions" / >
< / div >
< / section >
< / div >
< aside class = "side-info" >
< div class = "info-box" >
< span class = "material-icons" > groups < / span >
< h4 > Control de Tráfico < / h4 >
< p > Esta sección muestra la salud general de la app . Si la línea de invitados supera por mucho a la de registrados , es momento de lanzar una campaña de fidelización . < / p >
< / div >
< / aside >
< / div >
< / div >
<!-- SECTION 2 : TRANSPORT -- >
< div v-if = "activeTab === 'transport'" class="tab-content animate-fade" >
< div class = "dashboard-layout" >
< div class = "main-content" >
<!-- RUTAS -- >
< section class = "analysis-section" >
< div class = "section-header" >
< span class = "material-icons" > bar _chart < / span >
< h2 > Rutas Turísticas más Consultadas < / h2 >
< / div >
< div class = "chart-container" >
< Bar :data = "routesChartData" :options = "routesChartOptions" / >
< / div >
< / section >
<!-- CASETAS -- >
< section class = "analysis-section" >
< div class = "section-header" >
< span class = "material-icons" > location _on < / span >
< h2 > Puntos de Interés : Casetas ( Paradas ) < / h2 >
< / div >
< div class = "data-table-wrapper" >
< table class = "data-table" >
< thead >
< tr >
< th > ID Caseta < / th >
< th > Peticiones < / th >
< th > Popularidad < / th >
< / tr >
< / thead >
< tbody >
< tr v-for = "stop in stats.top_stops" :key="stop.id" >
< td class = "id-cell" > # { { stop . id } } < / td >
< td > { { stop . count } } < / td >
< td >
< div class = "progress-bar" >
< div class = "progress-fill" : style = "{ width: (stop.count / maxStopCount * 100) + '%' }" > < / div >
< / div >
< / td >
< / tr >
< / tbody >
< / table >
< / div >
< / section >
<!-- RENDIMIENTO SHUTTLES -- >
< section class = "analysis-section" >
< div class = "section-header" >
< span class = "material-icons" > trending _up < / span >
< h2 > Tasa de Reservación ( Shuttles ) < / h2 >
< / div >
< div class = "data-table-wrapper" >
< table class = "data-table" >
< thead >
< tr >
2026-03-04 20:36:31 -05:00
< th > Shuttle < / th >
< th > Interés ( Vistas ) < / th >
< th > Reservas & Llamadas < / th >
< th > Ratio de Conversión < / th >
2026-02-21 09:53:31 -05:00
< / tr >
< / thead >
< tbody >
2026-03-04 20:36:31 -05:00
< tr v-for = "(data, name) in stats.shuttles" :key="name" >
< td class = "id-cell" > { { name } } < / td >
< td > { { data . views } } < / td >
< td >
< div style = "display:flex; gap: 12px; align-items:center;" >
< span title = "WhatsApp" style = "display:flex; align-items:center; gap:2px;" > < span class = "material-icons" style = "font-size:14px; color:#25D366;" > chat < / span > { { data . whatsapp } } < / span >
< span title = "Llamadas" style = "display:flex; align-items:center; gap:2px;" > < span class = "material-icons" style = "font-size:14px; color:#cbd5e1;" > phone < / span > { { data . calls } } < / span >
< / div >
< / td >
2026-02-21 09:53:31 -05:00
< td >
2026-03-04 20:36:31 -05:00
< div style = "display:flex; flex-direction:column; gap:4px;" >
< div class = "mini-bar" > < div class = "fill" : style = "{ width: calculateConversion(data.views, data.contacts) + '%' }" > < / div > < / div >
< span style = "font-size: 0.65rem; color: #64748b; font-weight:700;" > { { calculateConversion ( data . views , data . contacts ) } } % < / span >
< / div >
2026-02-21 09:53:31 -05:00
< / td >
< / tr >
< / tbody >
< / table >
< / div >
< / section >
< / div >
< aside class = "side-info" >
< div class = "info-box accent" >
< span class = "material-icons" > local _shipping < / span >
< h4 > Optimización de Logística < / h4 >
< p > Identifique paradas saturadas para coordinar con los conductores . Las rutas con conversión mayor al 15 % son candidatas para ser rutas 'Express' . < / p >
< / div >
< / aside >
< / div >
< / div >
<!-- SECTION 3 : COMMERCE -- >
< div v-if = "activeTab === 'commerce'" class="tab-content animate-fade" >
< div class = "dashboard-layout" >
< div class = "main-content" >
< div class = "kpi-grid" >
< div class = "kpi-card" >
< div class = "kpi-icon promo" > < span class = "material-icons" > confirmation _number < / span > < / div >
< div class = "kpi-data" >
< span class = "kpi-value" > { { stats . summary ? . total _promo _clicks || 0 } } < / span >
< span class = "kpi-label" > Cupones Activados < / span >
< / div >
< / div >
< div class = "kpi-card" >
< div class = "kpi-icon biz" > < span class = "material-icons" > storefront < / span > < / div >
< div class = "kpi-data" >
< span class = "kpi-value" > { { stats . summary ? . total _biz _views || 0 } } < / span >
< span class = "kpi-label" > Visitas a Negocios < / span >
< / div >
< / div >
< / div >
< section class = "analysis-section" >
< div class = "section-header" >
< span class = "material-icons" > ads _click < / span >
< h2 > Impacto de Aliados Comerciales < / h2 >
< / div >
2026-03-04 21:07:35 -05:00
< div class = "business-list" >
< div v-for = "(data, name) in stats.businesses" :key="name" class="business-card-analytics" >
<!-- Business Header -- >
< div class = "business-header" >
< div class = "business-title-info" >
< div class = "biz-icon-box" > < span class = "material-icons" > storefront < / span > < / div >
< h3 > { { name } } < / h3 >
< / div >
< div class = "business-total-badge" >
< span class = "material-icons" > data _exploration < / span >
< span > < b > { { data . views + data . social + data . calls + data . location + data . promos + data . favs } } < / b > Interacciones < / span >
< / div >
< / div >
<!-- Details Grid -- >
< div class = "business-details-grid" >
< div class = "detail-item" >
< span class = "material-icons" style = "color:#cbd5e1" > visibility < / span >
< div class = "detail-info" >
< span class = "detail-value" > { { data . views } } < / span >
< span class = "detail-label" > Vistas del Local < / span >
< / div >
< / div >
< div class = "detail-item" >
< span class = "material-icons" style = "color:#25D366" > chat < / span >
< div class = "detail-info" >
< span class = "detail-value" > { { data . social } } < / span >
< span class = "detail-label" > Redes / WP < / span >
< / div >
< / div >
< div class = "detail-item" >
< span class = "material-icons" style = "color:#f87171" > phone < / span >
< div class = "detail-info" >
< span class = "detail-value" > { { data . calls } } < / span >
< span class = "detail-label" > Llamadas Directas < / span >
< / div >
< / div >
< div class = "detail-item" >
< span class = "material-icons" style = "color:#60a5fa" > place < / span >
< div class = "detail-info" >
< span class = "detail-value" > { { data . location } } < / span >
< span class = "detail-label" > Usos del Mapa < / span >
< / div >
< / div >
< div class = "detail-item" >
< span class = "material-icons" style = "color:#e91e63" > favorite < / span >
< div class = "detail-info" >
< span class = "detail-value" > { { data . favs } } < / span >
< span class = "detail-label" > Veces Favorito < / span >
< / div >
< / div >
< / div >
<!-- Coupons / Promos Summary -- >
< div class = "business-coupons-section" : class = "{ 'has-coupons': Object.keys(data.coupons || {}).length > 0 }" >
< div class = "coupons-header" >
< div style = "display:flex; align-items:center; gap: 8px;" >
< span class = "material-icons" style = "color:#fee715" > confirmation _number < / span >
< h4 > Tráfico por Promociones < / h4 >
< / div >
< div class = "status-pill-wrap" >
2026-02-21 09:53:31 -05:00
< span class = "status-pill" : class = "getHealthClass(calculateConversion(data.views, data.promos))" >
2026-03-04 21:07:35 -05:00
Salud : { { getHealthLabel ( calculateConversion ( data . views , data . promos ) ) } }
2026-02-21 09:53:31 -05:00
< / span >
2026-03-04 21:07:35 -05:00
< / div >
< / div >
< div v-if = "Object.keys(data.coupons || {}).length > 0" class="coupon-list" >
< div v-for = "(couponData, couponName) in data.coupons" :key="couponName" class="coupon-item" >
< div class = "coupon-name-box" >
< span class = "material-icons" style = "font-size:14px; color:var(--text-secondary)" > local _offer < / span >
< span class = "coupon-name" > { { couponName } } < / span >
< / div >
< div class = "coupon-stats" >
< span class = "stat" title = "Clicks a la promo" > < span class = "material-icons" > visibility < / span > { { couponData . views } } < / span >
< span class = "stat" title = "Clicks al mapa desde promo" > < span class = "material-icons" > place < / span > { { couponData . location } } < / span >
< / div >
< / div >
< / div >
< div v-else class = "no-coupons" >
< span class = "material-icons" > info < / span > No hay promociones generadas .
< / div >
< / div >
< / div >
2026-02-21 09:53:31 -05:00
< / div >
< / section >
< / div >
< aside class = "side-info" >
< div class = "info-box" >
< span class = "material-icons" > shopping _bag < / span >
< h4 > Retorno Comercial < / h4 >
< p > Analice qué negocios están monetizando mejor el tráfico de SIBU . Use estos datos para ofrecer espacios publicitarios premium a los negocios con salud 'Baja' . < / p >
< / div >
< / aside >
< / div >
< / div >
< / template >
< / div >
< / template >
< script setup lang = "ts" >
import { ref , onMounted , computed } from 'vue' ;
refactor: migrate fully to Supabase, remove Firebase/Render/Python backend
- DELETED: entire backend/ (Python/FastAPI — replaced by Supabase)
- DELETED: old/ directory (obsolete code)
- DELETED: render.yaml, inject_api.py, check_tags.py, PENDING_FOR_TOMORROW.md
- DELETED: frontend/src/firebaseConfig.ts (Firebase Auth replaced by Supabase Auth)
- DELETED: frontend/src/services/apiClient.ts (HTTP client for dead backend)
- MIGRATED services to Supabase native:
schedulesService, favoritesService, usersService,
telemetryService (stub), reportsService, analyticsService (stub)
- MIGRATED stores/favorites.ts to Supabase direct queries
- MIGRATED views: SplashScreen, AdminTaxis, AdminDrivers, StrategicAnalytics
- MIGRATED utils/imageUrl.ts to Supabase Storage URLs
- FIXED router/index.ts: guard now uses supabase.auth.getSession()
instead of old localStorage auth_token (fixes logout + map loading)
- FIXED AuthView.vue: removed aggressive watch({ immediate: true })
that caused wrong redirects on map route
- FIXED SplashScreen.vue: navigate() now reads Supabase session + role
- FIXED RLS: added INSERT policy on public.users for trigger
- CONFIRMED: admin@sibu.com assigned ADMIN role in Supabase
2026-02-25 21:49:26 -05:00
import { supabase } from '@/supabase' ;
2026-02-21 09:53:31 -05:00
import { Bar , Line } from 'vue-chartjs' ;
import { Chart as ChartJS , Title , Tooltip , Legend , BarElement , CategoryScale , LinearScale , PointElement , LineElement } from 'chart.js' ;
ChartJS . register ( Title , Tooltip , Legend , BarElement , CategoryScale , LinearScale , PointElement , LineElement ) ;
const loading = ref ( true ) ;
const activeTab = ref ( 'overview' ) ;
const stats = ref < any > ( {
shuttles : { } ,
businesses : { } ,
top _stops : [ ] ,
users : { registered _active : 0 , patterns : { registered : { } , guests : { } } } ,
summary : { total _shuttle _contacts : 0 , total _promo _clicks : 0 , total _biz _views : 0 }
} ) ;
const totalInteractionCount = computed ( ( ) => {
const s = stats . value . summary ;
return ( s . total _shuttle _contacts || 0 ) + ( s . total _promo _clicks || 0 ) + ( s . total _biz _views || 0 ) ;
} ) ;
const maxStopCount = computed ( ( ) => {
if ( ! stats . value . top _stops . length ) return 1 ;
return Math . max ( ... stats . value . top _stops . map ( ( s : any ) => s . count ) ) ;
} ) ;
const generateReport = async ( ) => {
2026-03-01 17:35:13 -05:00
// OPTIMIZACIÓN: Carga dinámica de librerías pesadas para no afectar el rendimiento inicial
const [ { jsPDF } , html2canvas ] = await Promise . all ( [
import ( 'jspdf' ) ,
import ( 'html2canvas' ) . then ( m => m . default )
] ) ;
2026-02-21 09:53:31 -05:00
const date = new Date ( ) . toLocaleDateString ( 'es-ES' , { month : 'long' , year : 'numeric' } ) ;
const doc = new jsPDF ( 'p' , 'mm' , 'a4' ) ;
const pageWidth = doc . internal . pageSize . getWidth ( ) ;
// 1. ENCABEZADO STARK STYLE
doc . setFillColor ( 30 , 41 , 59 ) ; // Color oscuro SIBU
doc . rect ( 0 , 0 , pageWidth , 40 , 'F' ) ;
doc . setTextColor ( 254 , 231 , 21 ) ; // Amarillo Activo
doc . setFontSize ( 22 ) ;
doc . setFont ( 'helvetica' , 'bold' ) ;
doc . text ( 'SIBU COMMAND CENTER' , 15 , 20 ) ;
doc . setTextColor ( 255 , 255 , 255 ) ;
doc . setFontSize ( 10 ) ;
doc . setFont ( 'helvetica' , 'normal' ) ;
doc . text ( ` INFORME DE INTELIGENCIA ESTRATÉGICA - ${ date . toUpperCase ( ) } ` , 15 , 30 ) ;
doc . text ( ` Generado el: ${ new Date ( ) . toLocaleString ( ) } ` , pageWidth - 15 , 30 , { align : 'right' } ) ;
let cursorY = 55 ;
// 2. RESUMEN EJECUTIVO
doc . setTextColor ( 30 , 41 , 59 ) ;
doc . setFontSize ( 14 ) ;
doc . setFont ( 'helvetica' , 'bold' ) ;
doc . text ( '1. RESUMEN DEL ECOSISTEMA' , 15 , cursorY ) ;
cursorY += 10 ;
doc . setFontSize ( 11 ) ;
doc . setFont ( 'helvetica' , 'normal' ) ;
const summaryText = ` Durante el periodo actual, se han detectado ${ stats . value . users ? . registered _active || 0 } usuarios registrados activos. Las interacciones totales en la red ascienden a ${ totalInteractionCount . value } , demostrando un flujo de actividad estable. ` ;
const splitSummary = doc . splitTextToSize ( summaryText , pageWidth - 30 ) ;
doc . text ( splitSummary , 15 , cursorY ) ;
cursorY += splitSummary . length * 7 ;
// 3. CAPTURA DE GRÁFICOS (Solo si están visibles o los forzamos)
// Nota: html2canvas captura el DOM. Intentaremos capturar los contenedores de las gráficas
const charts = document . querySelectorAll ( '.chart-container' ) ;
if ( charts . length > 0 ) {
doc . setFontSize ( 14 ) ;
doc . setFont ( 'helvetica' , 'bold' ) ;
doc . text ( '2. ANÁLISIS VISUAL DE TENDENCIAS' , 15 , cursorY ) ;
cursorY += 10 ;
for ( const chart of Array . from ( charts ) . slice ( 0 , 2 ) ) {
if ( cursorY > 220 ) { doc . addPage ( ) ; cursorY = 20 ; }
const canvas = await html2canvas ( chart as HTMLElement , { backgroundColor : '#1e293b' } ) ;
const imgData = canvas . toDataURL ( 'image/png' ) ;
doc . addImage ( imgData , 'PNG' , 15 , cursorY , pageWidth - 30 , 60 ) ;
cursorY += 70 ;
}
}
// 4. TABLAS DE DATOS (TRANSPORTE & CASETAS)
if ( cursorY > 200 ) { doc . addPage ( ) ; cursorY = 20 ; }
doc . setTextColor ( 30 , 41 , 59 ) ;
doc . setFontSize ( 14 ) ;
doc . setFont ( 'helvetica' , 'bold' ) ;
doc . text ( '3. LOGÍSTICA Y MOVILIDAD TÁCTICA' , 15 , cursorY ) ;
cursorY += 10 ;
doc . setFontSize ( 10 ) ;
doc . text ( 'Top 5 Casetas con más concurrencia:' , 15 , cursorY ) ;
cursorY += 7 ;
stats . value . top _stops . slice ( 0 , 5 ) . forEach ( ( stop : any ) => {
doc . text ( ` - Caseta # ${ stop . id } : ${ stop . count } peticiones directas detactadas. ` , 20 , cursorY ) ;
cursorY += 6 ;
} ) ;
// 5. INTELIGENCIA COMERCIAL
cursorY += 10 ;
if ( cursorY > 240 ) { doc . addPage ( ) ; cursorY = 20 ; }
doc . setFontSize ( 14 ) ;
doc . setFont ( 'helvetica' , 'bold' ) ;
doc . text ( '4. IMPACTO COMERCIAL (ALIADOS)' , 15 , cursorY ) ;
cursorY += 10 ;
doc . setFontSize ( 11 ) ;
doc . setFont ( 'helvetica' , 'normal' ) ;
doc . text ( ` Promociones activadas: ${ stats . value . summary ? . total _promo _clicks || 0 } veces. ` , 15 , cursorY ) ;
cursorY += 6 ;
doc . text ( ` Interés en perfiles de negocio: ${ stats . value . summary ? . total _biz _views || 0 } visitas registradas. ` , 15 , cursorY ) ;
// FOOTER
const totalPages = doc . internal . pages . length - 1 ;
for ( let i = 1 ; i <= totalPages ; i ++ ) {
doc . setPage ( i ) ;
doc . setFontSize ( 8 ) ;
doc . setTextColor ( 150 ) ;
doc . text ( ` SIBU Command Center - Página ${ i } de ${ totalPages } - Confidencial Admin ` , pageWidth / 2 , 285 , { align : 'center' } ) ;
}
doc . save ( ` Informe_Estrategico_SIBU_ ${ date . replace ( / /g , '_' ) } .pdf ` ) ;
} ;
// CHARTS CONFIGURATION (MISMOS DATOS QUE ANTES)
const usageChartData = computed ( ( ) => {
const hours = Array . from ( { length : 24 } , ( _ , i ) => i ) ;
return {
labels : hours . map ( h => ` ${ h } :00 ` ) ,
datasets : [
{ label : 'Registrados' , data : hours . map ( h => stats . value . users . patterns . registered [ h ] || 0 ) , borderColor : '#fee715' , backgroundColor : 'rgba(254, 231, 21, 0.2)' , tension : 0.4 , fill : true } ,
{ label : 'Invitados' , data : hours . map ( h => stats . value . users . patterns . guests [ h ] || 0 ) , borderColor : '#64748b' , backgroundColor : 'rgba(100, 116, 139, 0.1)' , tension : 0.4 , fill : true }
]
} ;
} ) ;
const routesChartData = computed ( ( ) => {
const routes = stats . value . shuttles || { } ;
const labels = Object . keys ( routes ) ;
return {
labels : labels . slice ( 0 , 8 ) ,
datasets : [ { label : 'Consultas' , data : labels . slice ( 0 , 8 ) . map ( l => routes [ l ] . views ) , backgroundColor : '#fee715' , borderRadius : 10 } ]
} ;
} ) ;
const usageChartOptions = { responsive : true , maintainAspectRatio : false , plugins : { legend : { labels : { color : '#cbd5e1' } } } , scales : { y : { grid : { color : 'rgba(255,255,255,0.05)' } , ticks : { color : '#64748b' } } , x : { ticks : { color : '#64748b' } } } } ;
const routesChartOptions = { responsive : true , maintainAspectRatio : false , plugins : { legend : { display : false } } , scales : { y : { beginAtZero : true , grid : { color : 'rgba(255,255,255,0.05)' } , ticks : { color : '#64748b' } } , x : { ticks : { color : '#64748b' } } } } ;
const calculateConversion = ( views : number , actions : number ) => ( views > 0 ? ( ( actions / views ) * 100 ) . toFixed ( 1 ) : 0 ) ;
const getHealthClass = ( rate : any ) => ( parseFloat ( rate ) > 20 ? 'excellent' : parseFloat ( rate ) > 10 ? 'good' : 'low' ) ;
const getHealthLabel = ( rate : any ) => ( parseFloat ( rate ) > 20 ? 'Alta' : parseFloat ( rate ) > 10 ? 'Media' : 'Baja' ) ;
onMounted ( async ( ) => {
try {
2026-03-01 17:35:13 -05:00
const [
{ count : userCount } ,
2026-03-04 20:59:35 -05:00
{ data : events } ,
{ data : allBusinesses } ,
2026-03-05 12:13:25 -05:00
{ data : allShuttles } ,
{ data : allCoupons }
2026-03-01 17:35:13 -05:00
] = await Promise . all ( [
supabase . from ( 'users' ) . select ( '*' , { count : 'exact' , head : true } ) . eq ( 'is_active' , true ) ,
2026-03-04 20:36:31 -05:00
// In a production app with >1M rows we might use a group-by RPC
2026-03-04 20:59:35 -05:00
supabase . from ( 'analytics_events' ) . select ( '*' ) ,
supabase . from ( 'businesses' ) . select ( 'name' ) ,
2026-03-05 12:13:25 -05:00
supabase . from ( 'shuttles' ) . select ( 'company_name, vehicle_type' ) ,
supabase . from ( 'coupons' ) . select ( 'title, businesses(name)' )
2026-03-01 17:35:13 -05:00
] )
refactor: migrate fully to Supabase, remove Firebase/Render/Python backend
- DELETED: entire backend/ (Python/FastAPI — replaced by Supabase)
- DELETED: old/ directory (obsolete code)
- DELETED: render.yaml, inject_api.py, check_tags.py, PENDING_FOR_TOMORROW.md
- DELETED: frontend/src/firebaseConfig.ts (Firebase Auth replaced by Supabase Auth)
- DELETED: frontend/src/services/apiClient.ts (HTTP client for dead backend)
- MIGRATED services to Supabase native:
schedulesService, favoritesService, usersService,
telemetryService (stub), reportsService, analyticsService (stub)
- MIGRATED stores/favorites.ts to Supabase direct queries
- MIGRATED views: SplashScreen, AdminTaxis, AdminDrivers, StrategicAnalytics
- MIGRATED utils/imageUrl.ts to Supabase Storage URLs
- FIXED router/index.ts: guard now uses supabase.auth.getSession()
instead of old localStorage auth_token (fixes logout + map loading)
- FIXED AuthView.vue: removed aggressive watch({ immediate: true })
that caused wrong redirects on map route
- FIXED SplashScreen.vue: navigate() now reads Supabase session + role
- FIXED RLS: added INSERT policy on public.users for trigger
- CONFIRMED: admin@sibu.com assigned ADMIN role in Supabase
2026-02-25 21:49:26 -05:00
const shuttleStats : any = { }
const bizStats : any = { }
2026-03-04 20:36:31 -05:00
let total _promo _clicks = 0
let total _shuttle _contacts = 0
let total _biz _views = 0
2026-03-04 20:59:35 -05:00
// PRE-FILL all businesses so they always show even with 0 clicks
if ( allBusinesses ) {
for ( const b of allBusinesses ) {
if ( b . name ) {
2026-03-04 21:07:35 -05:00
bizStats [ b . name ] = { views : 0 , promos : 0 , favs : 0 , social : 0 , location : 0 , calls : 0 , coupons : { } }
2026-03-04 20:59:35 -05:00
}
}
}
2026-03-05 12:13:25 -05:00
// PRE-FILL all coupons so they always show even with 0 clicks
if ( allCoupons ) {
for ( const c of allCoupons ) {
// Handle Supabase relation mapping (it might be array or object depending on generated types)
const bizData : any = c . businesses
const bizName = ( Array . isArray ( bizData ) ? bizData [ 0 ] ? . name : bizData ? . name ) || 'Desconocido'
if ( c . title ) {
if ( ! bizStats [ bizName ] ) {
bizStats [ bizName ] = { views : 0 , promos : 0 , favs : 0 , social : 0 , location : 0 , calls : 0 , coupons : { } }
}
if ( ! bizStats [ bizName ] . coupons [ c . title ] ) {
bizStats [ bizName ] . coupons [ c . title ] = { views : 0 , location : 0 }
}
}
}
}
2026-03-04 20:59:35 -05:00
// PRE-FILL all shuttles so they always show even with 0 clicks
if ( allShuttles ) {
for ( const s of allShuttles ) {
const name = s . company _name || s . vehicle _type || 'shuttle'
if ( ! shuttleStats [ name ] ) {
shuttleStats [ name ] = { views : 0 , contacts : 0 , calls : 0 , whatsapp : 0 }
}
}
}
2026-03-04 20:36:31 -05:00
const safeRows = events || [ ]
for ( const ev of safeRows ) {
const nameKey = ev . entity _name || ev . entity _id
if ( ev . entity _type === 'shuttle' ) {
if ( ! shuttleStats [ nameKey ] ) {
shuttleStats [ nameKey ] = { views : 0 , contacts : 0 , calls : 0 , whatsapp : 0 }
}
if ( ev . event _name === 'view_details' ) {
shuttleStats [ nameKey ] . views ++
} else if ( ev . event _name === 'shuttle_contact' ) {
shuttleStats [ nameKey ] . contacts ++
total _shuttle _contacts ++
if ( ev . properties ? . action === 'whatsapp' ) shuttleStats [ nameKey ] . whatsapp ++
if ( ev . properties ? . action === 'call' ) shuttleStats [ nameKey ] . calls ++
}
} else if ( ev . entity _type === 'business' ) {
if ( ! bizStats [ nameKey ] ) {
2026-03-04 21:07:35 -05:00
bizStats [ nameKey ] = { views : 0 , promos : 0 , favs : 0 , social : 0 , location : 0 , calls : 0 , coupons : { } }
2026-03-04 20:36:31 -05:00
}
if ( ev . event _name === 'view_details' ) {
bizStats [ nameKey ] . views ++
total _biz _views ++
} else if ( ev . event _name === 'favorite_add' ) {
bizStats [ nameKey ] . favs ++
} else if ( ev . event _name === 'social_click' ) {
bizStats [ nameKey ] . social ++
} else if ( ev . event _name === 'location_click' ) {
bizStats [ nameKey ] . location ++
} else if ( ev . event _name === 'contact_click' ) {
bizStats [ nameKey ] . calls ++
} else if ( ev . event _name === 'promo_click' ) {
bizStats [ nameKey ] . promos ++
total _promo _clicks ++
}
2026-03-04 20:59:35 -05:00
} else if ( ev . entity _type === 'coupon' ) {
// Los cupones se suman a la parte del negocio correspondiente
const bizName = ev . properties ? . business || nameKey
if ( ! bizStats [ bizName ] ) {
2026-03-04 21:07:35 -05:00
bizStats [ bizName ] = { views : 0 , promos : 0 , favs : 0 , social : 0 , location : 0 , calls : 0 , coupons : { } }
}
if ( ! bizStats [ bizName ] . coupons [ nameKey ] ) {
bizStats [ bizName ] . coupons [ nameKey ] = { views : 0 , location : 0 }
2026-03-04 20:59:35 -05:00
}
if ( ev . event _name === 'promo_view' ) {
bizStats [ bizName ] . promos ++
2026-03-04 21:07:35 -05:00
bizStats [ bizName ] . coupons [ nameKey ] . views ++
2026-03-04 20:59:35 -05:00
total _promo _clicks ++
} else if ( ev . event _name === 'location_click' ) {
bizStats [ bizName ] . location ++
2026-03-04 21:07:35 -05:00
bizStats [ bizName ] . coupons [ nameKey ] . location ++
2026-03-04 20:59:35 -05:00
}
2026-03-04 20:36:31 -05:00
}
refactor: migrate fully to Supabase, remove Firebase/Render/Python backend
- DELETED: entire backend/ (Python/FastAPI — replaced by Supabase)
- DELETED: old/ directory (obsolete code)
- DELETED: render.yaml, inject_api.py, check_tags.py, PENDING_FOR_TOMORROW.md
- DELETED: frontend/src/firebaseConfig.ts (Firebase Auth replaced by Supabase Auth)
- DELETED: frontend/src/services/apiClient.ts (HTTP client for dead backend)
- MIGRATED services to Supabase native:
schedulesService, favoritesService, usersService,
telemetryService (stub), reportsService, analyticsService (stub)
- MIGRATED stores/favorites.ts to Supabase direct queries
- MIGRATED views: SplashScreen, AdminTaxis, AdminDrivers, StrategicAnalytics
- MIGRATED utils/imageUrl.ts to Supabase Storage URLs
- FIXED router/index.ts: guard now uses supabase.auth.getSession()
instead of old localStorage auth_token (fixes logout + map loading)
- FIXED AuthView.vue: removed aggressive watch({ immediate: true })
that caused wrong redirects on map route
- FIXED SplashScreen.vue: navigate() now reads Supabase session + role
- FIXED RLS: added INSERT policy on public.users for trigger
- CONFIRMED: admin@sibu.com assigned ADMIN role in Supabase
2026-02-25 21:49:26 -05:00
}
stats . value = {
shuttles : shuttleStats ,
businesses : bizStats ,
2026-03-04 20:36:31 -05:00
top _stops : [ ] , // Legacy logic can still go here if stops tracking is added
refactor: migrate fully to Supabase, remove Firebase/Render/Python backend
- DELETED: entire backend/ (Python/FastAPI — replaced by Supabase)
- DELETED: old/ directory (obsolete code)
- DELETED: render.yaml, inject_api.py, check_tags.py, PENDING_FOR_TOMORROW.md
- DELETED: frontend/src/firebaseConfig.ts (Firebase Auth replaced by Supabase Auth)
- DELETED: frontend/src/services/apiClient.ts (HTTP client for dead backend)
- MIGRATED services to Supabase native:
schedulesService, favoritesService, usersService,
telemetryService (stub), reportsService, analyticsService (stub)
- MIGRATED stores/favorites.ts to Supabase direct queries
- MIGRATED views: SplashScreen, AdminTaxis, AdminDrivers, StrategicAnalytics
- MIGRATED utils/imageUrl.ts to Supabase Storage URLs
- FIXED router/index.ts: guard now uses supabase.auth.getSession()
instead of old localStorage auth_token (fixes logout + map loading)
- FIXED AuthView.vue: removed aggressive watch({ immediate: true })
that caused wrong redirects on map route
- FIXED SplashScreen.vue: navigate() now reads Supabase session + role
- FIXED RLS: added INSERT policy on public.users for trigger
- CONFIRMED: admin@sibu.com assigned ADMIN role in Supabase
2026-02-25 21:49:26 -05:00
users : {
registered _active : userCount || 0 ,
patterns : { registered : { } , guests : { } }
} ,
summary : {
2026-03-04 20:36:31 -05:00
total _shuttle _contacts ,
total _promo _clicks ,
total _biz _views
refactor: migrate fully to Supabase, remove Firebase/Render/Python backend
- DELETED: entire backend/ (Python/FastAPI — replaced by Supabase)
- DELETED: old/ directory (obsolete code)
- DELETED: render.yaml, inject_api.py, check_tags.py, PENDING_FOR_TOMORROW.md
- DELETED: frontend/src/firebaseConfig.ts (Firebase Auth replaced by Supabase Auth)
- DELETED: frontend/src/services/apiClient.ts (HTTP client for dead backend)
- MIGRATED services to Supabase native:
schedulesService, favoritesService, usersService,
telemetryService (stub), reportsService, analyticsService (stub)
- MIGRATED stores/favorites.ts to Supabase direct queries
- MIGRATED views: SplashScreen, AdminTaxis, AdminDrivers, StrategicAnalytics
- MIGRATED utils/imageUrl.ts to Supabase Storage URLs
- FIXED router/index.ts: guard now uses supabase.auth.getSession()
instead of old localStorage auth_token (fixes logout + map loading)
- FIXED AuthView.vue: removed aggressive watch({ immediate: true })
that caused wrong redirects on map route
- FIXED SplashScreen.vue: navigate() now reads Supabase session + role
- FIXED RLS: added INSERT policy on public.users for trigger
- CONFIRMED: admin@sibu.com assigned ADMIN role in Supabase
2026-02-25 21:49:26 -05:00
}
}
2026-03-04 20:36:31 -05:00
} catch ( error ) { console . error ( 'Error fetching analytics:' , error ) ; } finally { loading . value = false ; }
2026-02-21 09:53:31 -05:00
} ) ;
refactor: migrate fully to Supabase, remove Firebase/Render/Python backend
- DELETED: entire backend/ (Python/FastAPI — replaced by Supabase)
- DELETED: old/ directory (obsolete code)
- DELETED: render.yaml, inject_api.py, check_tags.py, PENDING_FOR_TOMORROW.md
- DELETED: frontend/src/firebaseConfig.ts (Firebase Auth replaced by Supabase Auth)
- DELETED: frontend/src/services/apiClient.ts (HTTP client for dead backend)
- MIGRATED services to Supabase native:
schedulesService, favoritesService, usersService,
telemetryService (stub), reportsService, analyticsService (stub)
- MIGRATED stores/favorites.ts to Supabase direct queries
- MIGRATED views: SplashScreen, AdminTaxis, AdminDrivers, StrategicAnalytics
- MIGRATED utils/imageUrl.ts to Supabase Storage URLs
- FIXED router/index.ts: guard now uses supabase.auth.getSession()
instead of old localStorage auth_token (fixes logout + map loading)
- FIXED AuthView.vue: removed aggressive watch({ immediate: true })
that caused wrong redirects on map route
- FIXED SplashScreen.vue: navigate() now reads Supabase session + role
- FIXED RLS: added INSERT policy on public.users for trigger
- CONFIRMED: admin@sibu.com assigned ADMIN role in Supabase
2026-02-25 21:49:26 -05:00
2026-02-21 09:53:31 -05:00
< / script >
< style scoped >
. strategic - analytics { padding : 40 px 24 px 120 px ; max - width : 1350 px ; margin : 0 auto ; color : var ( -- text - primary ) ; }
. header - section { margin - bottom : 30 px ; }
. top - row { display : flex ; justify - content : space - between ; align - items : flex - start ; margin - bottom : 12 px ; }
. download - btn {
background : rgba ( 254 , 231 , 21 , 0.1 ) ;
border : 1 px solid var ( -- active - color ) ;
color : var ( -- active - color ) ;
padding : 8 px 16 px ;
border - radius : 12 px ;
display : flex ;
align - items : center ;
gap : 8 px ;
font - size : 0.8 rem ;
font - weight : 800 ;
cursor : pointer ;
transition : all 0.3 s ;
}
. download - btn : hover {
background : var ( -- active - color ) ;
color : # 101820 ;
transform : translateY ( - 2 px ) ;
box - shadow : 0 4 px 15 px rgba ( 254 , 231 , 21 , 0.3 ) ;
}
h1 { font - size : 2.2 rem ; font - weight : 900 ; margin : 0 ; }
. subtitle { color : var ( -- text - secondary ) ; margin - top : 6 px ; }
/* TABS */
. tabs - control { display : flex ; gap : 12 px ; margin - bottom : 40 px ; padding - bottom : 20 px ; border - bottom : 1 px solid var ( -- border - color ) ; }
. tab - btn { background : var ( -- bg - secondary ) ; border : 1 px solid var ( -- border - color ) ; color : var ( -- text - secondary ) ; padding : 12 px 24 px ; border - radius : 16 px ; display : flex ; align - items : center ; gap : 10 px ; cursor : pointer ; transition : all 0.3 s ; font - weight : 700 ; }
. tab - btn . active { background : var ( -- active - color ) ; color : # 101820 ; border - color : var ( -- active - color ) ; }
. tab - btn : hover : not ( . active ) { border - color : var ( -- active - color ) ; color : var ( -- active - color ) ; }
/* CONTENT */
. dashboard - layout { display : grid ; grid - template - columns : 1 fr 340 px ; gap : 40 px ; }
. analysis - section { margin - bottom : 60 px ; }
. section - header { display : flex ; align - items : center ; gap : 12 px ; margin - bottom : 20 px ; }
. section - header h2 { font - size : 1 rem ; font - weight : 800 ; text - transform : uppercase ; color : var ( -- text - secondary ) ; }
. side - info { display : flex ; flex - direction : column ; gap : 16 px ; }
. info - box { background : var ( -- bg - secondary ) ; padding : 24 px ; border - radius : 24 px ; border : 1 px solid var ( -- border - color ) ; }
. info - box . material - icons { color : # fee715 ; margin - bottom : 12 px ; }
. info - box h4 { margin : 0 0 8 px ; font - weight : 800 ; color : # fee715 ; }
. info - box p { font - size : 0.85 rem ; line - height : 1.6 ; color : var ( -- text - secondary ) ; margin : 0 ; }
/* KPI */
. kpi - grid { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 240 px , 1 fr ) ) ; gap : 24 px ; margin - bottom : 40 px ; }
. kpi - card { background : var ( -- card - bg ) ; padding : 24 px ; border - radius : 24 px ; border : 1 px solid var ( -- border - color ) ; display : flex ; align - items : center ; gap : 20 px ; box - shadow : 0 10 px 30 px rgba ( 0 , 0 , 0 , 0.1 ) ; }
. kpi - icon { width : 50 px ; height : 50 px ; border - radius : 14 px ; display : flex ; align - items : center ; justify - content : center ; background : rgba ( 254 , 231 , 21 , 0.1 ) ; color : # fee715 ; }
. kpi - value { display : block ; font - size : 2 rem ; font - weight : 900 ; }
. kpi - label { font - size : 0.75 rem ; color : var ( -- text - secondary ) ; font - weight : 600 ; text - transform : uppercase ; }
/* TABLES & CHARTS */
. chart - container { height : 320 px ; background : rgba ( 0 , 0 , 0 , 0.2 ) ; border - radius : 24 px ; padding : 24 px ; border : 1 px solid var ( -- border - color ) ; }
. chart - container . large { height : 400 px ; }
. data - table - wrapper { background : var ( -- card - bg ) ; border - radius : 24 px ; border : 1 px solid var ( -- border - color ) ; overflow : hidden ; }
. data - table { width : 100 % ; border - collapse : collapse ; }
. data - table th { padding : 16 px ; font - size : 0.7 rem ; color : var ( -- text - secondary ) ; text - transform : uppercase ; text - align : left ; background : rgba ( 255 , 255 , 255 , 0.02 ) ; }
. data - table td { padding : 18 px 16 px ; border - bottom : 1 px solid var ( -- border - color ) ; }
. id - cell { font - family : monospace ; color : # fee715 ; font - weight : 700 ; }
. progress - bar { width : 100 % ; height : 6 px ; background : rgba ( 255 , 255 , 255 , 0.05 ) ; border - radius : 10 px ; overflow : hidden ; }
. progress - fill { height : 100 % ; background : # fee715 ; }
. mini - bar { width : 80 px ; height : 5 px ; background : rgba ( 255 , 255 , 255 , 0.05 ) ; border - radius : 10 px ; overflow : hidden ; }
. mini - bar . fill { height : 100 % ; background : # fee715 ; }
. status - pill { padding : 4 px 12 px ; border - radius : 100 px ; font - size : 0.7 rem ; font - weight : 800 ; }
. status - pill . excellent { background : rgba ( 16 , 185 , 129 , 0.1 ) ; color : # 10 b981 ; }
. status - pill . good { background : rgba ( 59 , 130 , 246 , 0.1 ) ; color : # 3 b82f6 ; }
. status - pill . low { background : rgba ( 244 , 63 , 94 , 0.1 ) ; color : # f43f5e ; }
2026-03-04 21:07:35 -05:00
/* BUSINESS LIST CARDS */
. business - list { display : flex ; flex - direction : column ; gap : 20 px ; }
. business - card - analytics { background : var ( -- card - bg ) ; border - radius : 20 px ; border : 1 px solid var ( -- border - color ) ; padding : 24 px ; box - shadow : 0 10 px 30 px rgba ( 0 , 0 , 0 , 0.05 ) ; }
. business - header { display : flex ; justify - content : space - between ; align - items : center ; margin - bottom : 24 px ; padding - bottom : 16 px ; border - bottom : 1 px solid var ( -- border - color ) ; }
. business - title - info { display : flex ; align - items : center ; gap : 12 px ; }
. biz - icon - box { background : rgba ( 254 , 231 , 21 , 0.1 ) ; color : # fee715 ; width : 44 px ; height : 44 px ; border - radius : 12 px ; display : flex ; justify - content : center ; align - items : center ; }
. business - title - info h3 { margin : 0 ; font - size : 1.3 rem ; font - weight : 800 ; color : var ( -- text - primary ) ; }
. business - total - badge { background : # 1 e293b ; color : white ; padding : 6 px 14 px ; border - radius : 20 px ; display : flex ; align - items : center ; gap : 6 px ; font - size : 0.85 rem ; }
. business - total - badge . material - icons { font - size : 16 px ; color : # fee715 ; }
. business - details - grid { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 130 px , 1 fr ) ) ; gap : 16 px ; margin - bottom : 24 px ; }
. detail - item { display : flex ; align - items : center ; gap : 12 px ; background : var ( -- bg - secondary ) ; padding : 12 px 14 px ; border - radius : 14 px ; border : 1 px solid var ( -- border - color ) ; }
. detail - item . material - icons { font - size : 20 px ; }
. detail - info { display : flex ; flex - direction : column ; }
. detail - value { font - size : 1.2 rem ; font - weight : 900 ; color : var ( -- text - primary ) ; }
. detail - label { font - size : 0.65 rem ; color : var ( -- text - secondary ) ; text - transform : uppercase ; font - weight : 700 ; margin - top : 2 px ; }
. business - coupons - section { background : var ( -- bg - secondary ) ; border - radius : 16 px ; padding : 20 px ; border : 1 px solid var ( -- border - color ) ; }
. business - coupons - section . has - coupons { border - color : rgba ( 254 , 231 , 21 , 0.3 ) ; background : rgba ( 254 , 231 , 21 , 0.02 ) ; }
. coupons - header { display : flex ; justify - content : space - between ; align - items : center ; margin - bottom : 16 px ; }
. business - coupons - section h4 { margin : 0 ; font - size : 1 rem ; font - weight : 800 ; color : var ( -- text - primary ) ; }
. coupon - list { display : flex ; flex - direction : column ; gap : 10 px ; }
. coupon - item { display : flex ; justify - content : space - between ; align - items : center ; background : var ( -- card - bg ) ; padding : 12 px 16 px ; border - radius : 12 px ; border : 1 px solid var ( -- border - color ) ; box - shadow : 0 2 px 10 px rgba ( 0 , 0 , 0 , 0.05 ) ; }
. coupon - name - box { display : flex ; align - items : center ; gap : 8 px ; }
. coupon - name { font - weight : 700 ; font - size : 0.9 rem ; color : var ( -- text - primary ) ; }
. coupon - stats { display : flex ; gap : 16 px ; font - size : 0.85 rem ; font - weight : 700 ; color : var ( -- text - primary ) ; }
. coupon - stats . stat { display : flex ; align - items : center ; gap : 4 px ; background : var ( -- bg - secondary ) ; padding : 4 px 10 px ; border - radius : 8 px ; }
. coupon - stats . stat . material - icons { font - size : 14 px ; color : var ( -- text - secondary ) ; }
. no - coupons { font - size : 0.85 rem ; color : var ( -- text - secondary ) ; display : flex ; align - items : center ; gap : 8 px ; font - weight : 600 ; }
2026-02-21 09:53:31 -05:00
/* ANIMATIONS */
. animate - fade { animation : fadeIn 0.4 s ease - out ; }
@ keyframes fadeIn { from { opacity : 0 ; transform : translateY ( 10 px ) ; } to { opacity : 1 ; transform : translateY ( 0 ) ; } }
. spin { animation : spin 2 s linear infinite ; }
@ keyframes spin { from { transform : rotate ( 0 deg ) ; } to { transform : rotate ( 360 deg ) ; } }
@ media ( max - width : 1100 px ) { . dashboard - layout { grid - template - columns : 1 fr ; } . side - info { order : 2 ; } }
< / style >
refactor: migrate fully to Supabase, remove Firebase/Render/Python backend
- DELETED: entire backend/ (Python/FastAPI — replaced by Supabase)
- DELETED: old/ directory (obsolete code)
- DELETED: render.yaml, inject_api.py, check_tags.py, PENDING_FOR_TOMORROW.md
- DELETED: frontend/src/firebaseConfig.ts (Firebase Auth replaced by Supabase Auth)
- DELETED: frontend/src/services/apiClient.ts (HTTP client for dead backend)
- MIGRATED services to Supabase native:
schedulesService, favoritesService, usersService,
telemetryService (stub), reportsService, analyticsService (stub)
- MIGRATED stores/favorites.ts to Supabase direct queries
- MIGRATED views: SplashScreen, AdminTaxis, AdminDrivers, StrategicAnalytics
- MIGRATED utils/imageUrl.ts to Supabase Storage URLs
- FIXED router/index.ts: guard now uses supabase.auth.getSession()
instead of old localStorage auth_token (fixes logout + map loading)
- FIXED AuthView.vue: removed aggressive watch({ immediate: true })
that caused wrong redirects on map route
- FIXED SplashScreen.vue: navigate() now reads Supabase session + role
- FIXED RLS: added INSERT policy on public.users for trigger
- CONFIRMED: admin@sibu.com assigned ADMIN role in Supabase
2026-02-25 21:49:26 -05:00