Initial commit

This commit is contained in:
marianesaldana
2026-05-23 08:59:34 -06:00
commit 80dbd947e5
36446 changed files with 3729147 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

315
README.md Normal file
View File

@@ -0,0 +1,315 @@
# Mi Ruta Limpia
> **Sistema de Notificación Inteligente y Privada de Recolección de Residuos**
> Hackathon LINCEHACK · Gobierno de Celaya
---
## 📑 Tabla de contenidos
1. [Problemática](#-problemática)
2. [Solución](#-solución)
3. [Características principales](#-características-principales)
4. [Stack tecnológico](#-stack-tecnológico)
5. [Arquitectura](#-arquitectura)
6. [Instalación rápida](#-instalación-rápida)
7. [Credenciales demo](#-credenciales-demo)
8. [Estructura del proyecto](#-estructura-del-proyecto)
9. [Privacidad por diseño](#-privacidad-por-diseño)
10. [Documentación adicional](#-documentación-adicional)
---
## 🎯 Problemática
En Celaya, la ciudadanía no sabe con certeza cuándo el camión recolector pasará por su domicilio. Esto provoca:
- Basura sacada demasiado **temprano** o **tarde**
- Acumulación cuando el camión ya pasó
- Problemas de salud pública: fauna nociva, malos olores, infecciones
- Molestias vecinales por basura en la calle
**La solución no puede ser un GPS público:** exponer la ruta completa del camión es un riesgo operativo (gente persigue al camión) y de privacidad.
---
## 💡 Solución
Una aplicación móvil que:
- Indica **hora aproximada de llegada** del camión a tu domicilio registrado
- Envía **notificaciones** sobre cambios operativos y retrasos
- Educa sobre **separación de residuos**
- Permite **reportar incidencias** con folio oficial
- Tiene 3 vistas: **Ciudadano**, **Empleado operativo** y **Administrador**
Todo bajo un principio **innegociable de Privacidad por Diseño**:
**el ciudadano solo ve la información de su zona, nunca el mapa completo del camión ni rutas de otros.**
---
## ✨ Características principales
### 👤 Vista Ciudadano
- 🏠 Múltiples domicilios con etiquetas (Casa, Trabajo, etc.)
- ⏰ ETA por ventana horaria (ej. *"Entre 7:20 y 7:35"*) — **sin coordenadas del camión**
- 📊 Barra de progreso de ruta sin exponer ubicación
- ✓ Confirmación *"¿Ya pasó la basura?"* con opción de deshacer
- ⭐ Calificación del servicio (1-5 estrellas)
- 📋 Reportes con **folio único** (`MRL-YYYYMMDD-XXXXXX`)
- 📤 Compartir reporte como comprobante
- 🌱 Educación ambiental con 4 categorías, datos de impacto y puntos de acopio
- 📅 Horarios por domicilio
### 👮 Vista Empleado Operativo
- 🚛 Reportes operativos (camión no arrancó, falla mecánica, accidente, obstáculo, etc.) con folio `OP-YYYYMMDD-XXXXXX`
- 🔥 **Racha de puntualidad** (días consecutivos)
- 💰 **Bonos** por puntualidad ($50 MXN/día puntual)
- 🏆 Próximo bono con cuenta regresiva
- 📅 Horario preestablecido con descansos técnicos
- 💬 Mensajes motivacionales rotativos
-**Sin acceso** a reportes ciudadanos (403 Forbidden)
### 🛡️ Vista Administrador
- 📊 Dashboard con KPIs en vivo (ciudadanos, domicilios, reportes 24h, calificación promedio)
- 📈 Gráficas de reportes por estado y por tipo
- 📋 Gestión de reportes ciudadanos (cambiar PENDIENTE → EN_PROCESO → RESUELTO → CERRADO)
- 👥 Gestión de usuarios y cambio de roles
- 🚛 Vista de flotilla (62 camiones)
- ⭐ Feedback ciudadano agregado
---
## 🛠️ Stack tecnológico
### Backend
- **Python 3.13** con **FastAPI 0.115**
- **SQLAlchemy 2.0** + **SQLite** (escalable a PostgreSQL)
- **JWT** (python-jose) + **bcrypt** para autenticación
- **Pydantic v2** para validación
- **PyTZ** para manejo correcto de zonas horarias (Celaya/CST)
### Frontend
- **React Native 0.74** con **Expo SDK 51**
- **React 18.2** + **React Navigation 6**
- **expo-linear-gradient**, **@expo/vector-icons**, **react-native-svg**
- **AsyncStorage** para persistencia local
- **react-native-url-polyfill** para compatibilidad WHATWG URL
### DevOps
- **Watchman** para file watching en macOS
- **Metro** bundler (Expo CLI)
- Migraciones manuales con SQL inline en seeds
---
## 🏗️ Arquitectura
```
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND (Expo) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ CIUDADANO │ │ EMPLEADO │ │ ADMIN │ │
│ │ 5 tabs │ │ 3 tabs │ │ 4 tabs │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ └────────────────┼──────────────────┘ │
│ │ │
│ fetch + AsyncStorage (JWT) │
└──────────────────────────┼───────────────────────────────────────┘
│ HTTPS REST
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND (FastAPI) │
│ │
│ Routers: │
│ /api/v1/auth — login, register, OAuth, /me │
│ /api/v1/addresses — CRUD de domicilios + geo-routing │
│ /api/v1/eta — cálculo de ETA privado │
│ /api/v1/reports — reportes ciudadanos │
│ /api/v1/admin — endpoints restringidos a ADMIN │
│ /api/v1/staff — endpoints para EMPLEADO/ADMIN │
│ │
│ Middleware: │
│ - JWT auth (HTTPBearer) │
│ - RBAC (require_admin, require_staff) │
│ - CORS │
│ │
│ Services: │
│ - eta_service: simulación temporal de la flotilla │
│ - auth_service: hash, verify, JWT │
└──────────────────────────┼───────────────────────────────────────┘
┌──────────┴───────────┐
▼ ▼
┌──────────────┐ ┌───────────────┐
│ SQLite │ │ routes.json │
│ │ │ (mock GPS) │
│ 6 tablas: │ │ │
│ - users │ │ 15 rutas con │
│ - addresses │ │ waypoints + │
│ - reports │ │ timestamps │
│ - op_reports│ └───────────────┘
│ - ratings │
│ - trucks │
└──────────────┘
```
Ver detalles en [`docs/ARQUITECTURA.md`](docs/ARQUITECTURA.md).
---
## 🚀 Instalación rápida
### Prerequisitos
- **Node.js 20 LTS** (NO usar 22+)
- **Python 3.11+**
- **macOS** o **Linux** (instrucciones probadas en macOS con Xcode + iOS Simulator)
### Backend
```bash
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Poblar BD con datos demo + masivos
python seed_massive.py
# Arrancar API
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
API disponible en `http://localhost:8000` · Docs interactivos en `http://localhost:8000/docs`
### Frontend
```bash
cd frontend
ulimit -n 65536 # macOS file descriptors
npm install
npx expo start --clear
# Presiona 'i' para iOS simulator | 'w' para web | escanea QR con Expo Go
```
> ⚠️ La URL del backend está en [`frontend/src/constants/config.js`](frontend/src/constants/config.js). Cámbiala si tu IP local no es `10.82.68.125`.
Ver guía completa en [`docs/INSTALACION.md`](docs/INSTALACION.md).
---
## 🔑 Credenciales demo
| Rol | Email | Contraseña | Acceso |
|-----|-------|-----------|--------|
| 🧑 Ciudadano | `demo@celaya.gob.mx` | `Celaya2026` | 5 pestañas: Inicio, Horarios, Educación, Reportes, Perfil |
| 👮 Empleado | `empleado@celaya.gob.mx` | `Empleado2026` | 3 pestañas: Inicio (puntualidad), Reportes operativos, Cuenta |
| 🛡️ Admin | `admin@celaya.gob.mx` | `Admin2026` | 4 pestañas: Dashboard, Reportes ciudadanos, Usuarios, Cuenta |
La BD tiene además **202 usuarios totales, 302 domicilios, 280 reportes ciudadanos, 150 reportes operativos, 220 calificaciones y 62 camiones** generados con `seed_massive.py`.
---
## 📂 Estructura del proyecto
```
mi-ruta-limpia/
├── README.md ← este archivo
├── docs/
│ ├── ARQUITECTURA.md ← decisiones técnicas
│ ├── MANUAL_USUARIO.md ← guía visual por pantalla
│ ├── API.md ← referencia REST
│ ├── INSTALACION.md ← guía paso a paso
│ └── PRESENTACION.md ← script de demo
├── backend/
│ ├── main.py ← entry point + CORS + routers
│ ├── requirements.txt
│ ├── seed.py ← seed básico (3 usuarios)
│ ├── seed_massive.py ← seed con 200+ usuarios y datos
│ └── app/
│ ├── config.py ← settings (JWT, DB)
│ ├── database.py ← SQLAlchemy session
│ ├── models/ ← User, Address, Report, OperationalReport, Truck, ServiceRating
│ ├── schemas/ ← Pydantic v2
│ ├── routers/ ← auth, addresses, eta, reports, admin, staff
│ ├── services/ ← auth_service, eta_service
│ └── data/routes.json ← 15 rutas mock
└── frontend/
├── App.js
├── index.js ← polyfill + entry
├── package.json
├── babel.config.js
├── app.json
├── assets/ ← logos, hero-eco, splash, icons
└── src/
├── constants/ ← colors, config
├── context/ ← AuthContext (JWT + role)
├── navigation/ ← AppNavigator (routing por rol)
├── components/ ← AppHeader, GoogleGIcon
├── services/api.js ← fetch + interceptors
└── screens/
├── auth/ ← Login, Register
├── HomeScreen.js ← ciudadano (5 tabs)
├── SchedulesScreen.js
├── ReportsScreen.js
├── ProfileScreen.js
├── EducationScreen.js
├── staff/ ← empleado (3 tabs)
│ ├── StaffDashboard.js
│ └── StaffReports.js
└── admin/ ← admin (4 tabs)
├── AdminDashboard.js
├── AdminReports.js
├── AdminUsers.js
└── AdminProfile.js
```
---
## 🔒 Privacidad por diseño
Este es el **principio innegociable** del reto del hackathon. Cumplimos así:
| Riesgo | Mitigación |
|--------|-----------|
| Ciudadanos persiguiendo camiones | El endpoint `/eta/address/:id` **nunca devuelve coordenadas del camión**. Solo: status, mensaje, eta_minutes, window_start/end, progress% |
| Snooping de otras rutas | El usuario solo puede consultar sus propios `address_id`. El backend valida `Address.user_id == JWT.user_id` |
| Exposición de información operativa | Las rutas de los demás camiones no son visibles ni siquiera con manipulación del request |
| Confianza en la app | La interfaz **desalienta explícitamente** perseguir al camión (`"Por tu seguridad, no persigas al camión"`) |
| Datos sensibles | Contraseñas con bcrypt · JWT firmado con HS256 · ningún PII se expone en logs |
Ver [`docs/ARQUITECTURA.md#privacidad-por-diseño`](docs/ARQUITECTURA.md) para el análisis completo.
---
## 📚 Documentación adicional
| Documento | Para quién | Contenido |
|-----------|------------|-----------|
| [`docs/ARQUITECTURA.md`](docs/ARQUITECTURA.md) | Jueces técnicos | Decisiones de diseño, algoritmo ETA, RBAC, BD, simulación |
| [`docs/MANUAL_USUARIO.md`](docs/MANUAL_USUARIO.md) | Usuarios finales | Guía visual por pantalla y flujo |
| [`docs/API.md`](docs/API.md) | Desarrolladores | Referencia REST completa con ejemplos |
| [`docs/INSTALACION.md`](docs/INSTALACION.md) | Equipo de operaciones | Setup paso a paso, troubleshooting |
| [`docs/PRESENTACION.md`](docs/PRESENTACION.md) | Equipo presentador | Script de demo para los 5-10 min del pitch |
---
## 🏆 Equipo
**LINCEHACK 2026** — Hackathon Gobierno de Celaya
---
## 📝 Licencia
Proyecto desarrollado para fines educativos en el marco del hackathon LINCEHACK. Datos de Celaya son referenciales.
---
🌱 *Celaya, es la esperanza.*

BIN
backend/.DS_Store vendored Normal file

Binary file not shown.

5
backend/.env.example Normal file
View File

@@ -0,0 +1,5 @@
SECRET_KEY=cambia-esta-clave-super-secreta-en-produccion
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=10080
DATABASE_URL=sqlite:///./mi_ruta_limpia.db
FRONTEND_URL=http://localhost:8081

155
backend/DATABASE_README.md Normal file
View File

@@ -0,0 +1,155 @@
# 📦 Restaurar la base de datos en otra computadora
Este directorio contiene un **dump completo de la BD** para que cualquier persona pueda replicar el estado actual de la base de datos en su propia máquina.
---
## 📁 Archivos clave
| Archivo | Tamaño | Descripción |
|---------|--------|-------------|
| `database_dump.sql` | ~209 KB | Dump SQL completo (schema + 1,216 INSERTs) |
| `restore_db.py` | — | Script Python que aplica el dump |
| `mi_ruta_limpia.db` | ~266 KB | (Opcional) Base de datos SQLite ya hidratada |
---
## 🚀 Restaurar en otra máquina (paso a paso)
### Opción 1 (Recomendada): con Python
```bash
# 1. Clona o copia el proyecto en la otra máquina
cd backend
# 2. Activa el venv si no lo has hecho
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
# 3. Restaura la BD desde el dump
python restore_db.py
```
Verás:
```
✅ BASE DE DATOS RESTAURADA EXITOSAMENTE
✓ users 202/202
✓ addresses 302/302
✓ reports 280/280
✓ operational_reports 150/150
✓ service_ratings 220/220
✓ trucks 62/62
```
### Opción 2: con `sqlite3` (sin Python)
Si solo tienes `sqlite3` instalado (sin Python):
```bash
cd backend
rm -f mi_ruta_limpia.db
sqlite3 mi_ruta_limpia.db < database_dump.sql
```
### Opción 3 (más rápida): copiar el `.db` directamente
Si simplemente quieres llevarte la BD ya hecha:
```bash
# Solo asegúrate de incluir mi_ruta_limpia.db al copiar/clonar
# SQLite es un solo archivo, portable a cualquier OS
```
---
## ✅ Verificar después de restaurar
Una vez restaurado, arranca el backend:
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
Y prueba el login con cualquiera de los 3 usuarios demo:
| Rol | Email | Contraseña |
|-----|-------|-----------|
| 🧑 Ciudadano | `demo@celaya.gob.mx` | `Celaya2026` |
| 👮 Empleado | `empleado@celaya.gob.mx` | `Empleado2026` |
| 🛡️ Admin | `admin@celaya.gob.mx` | `Admin2026` |
Test rápido con curl:
```bash
curl -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@celaya.gob.mx","password":"Admin2026"}'
```
Debe responder con un token JWT y `"role":"ADMIN"`.
---
## 📊 Contenido de la BD restaurada
| Tabla | Registros |
|-------|----------:|
| `users` | **202** (185 ciudadanos · 14 empleados · 3 admins) |
| `addresses` | **302** (con `route_id` ya asignado por geo-routing) |
| `reports` | **280** reportes ciudadanos con folios reales |
| `operational_reports` | **150** reportes operativos de empleados |
| `service_ratings` | **220** calificaciones 1-5 estrellas |
| `trucks` | **62** camiones (CEL-001 a CEL-062) |
| **TOTAL** | **1,216 registros** |
Los datos cubren ~60 días hacia atrás con timestamps realistas.
---
## 🔄 Regenerar el dump (solo si modificaste la BD original)
Si haces cambios en tu BD local y quieres actualizar el dump:
```bash
cd backend
sqlite3 mi_ruta_limpia.db .dump > database_dump.sql
```
Eso reemplaza el archivo. Después haz commit del nuevo `database_dump.sql`.
---
## 🆘 Troubleshooting
### "no such table" al restaurar
Borra la BD existente y reintenta:
```bash
rm mi_ruta_limpia.db
python restore_db.py
```
### Las contraseñas no funcionan después de restaurar
Las contraseñas están hasheadas con bcrypt. Verifica que tienes la versión correcta:
```bash
pip install 'bcrypt==4.0.1'
```
### El archivo `database_dump.sql` está vacío o corrupto
Pide a tu equipo el original o, si tienes acceso a la BD original:
```bash
sqlite3 mi_ruta_limpia.db .dump > database_dump.sql
wc -l database_dump.sql # Debe ser >1000 líneas
```
---
## 💡 Alternativa: generar datos nuevos (no restaurar)
Si prefieres datos generados aleatoriamente en vez de los exactos del original:
```bash
python seed_massive.py
```
Esto crea cantidades similares pero con seed determinístico (mismo resultado siempre, pero diferente al dump).
> Las credenciales demo son las mismas en ambos métodos.

Binary file not shown.

0
backend/app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

15
backend/app/config.py Normal file
View File

@@ -0,0 +1,15 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
SECRET_KEY: str = "dev-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 days
DATABASE_URL: str = "sqlite:///./mi_ruta_limpia.db"
FRONTEND_URL: str = "http://localhost:8081"
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,17 @@
[
{"routeId":"RUTA-01","name":"Zona Centro - Las Arboledas","truckId":101,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:00:00Z"},{"positionId":2,"lat":20.5185,"lng":-100.8450,"speed":45,"timestamp":"2026-05-22T06:12:00Z"},{"positionId":3,"lat":20.5215,"lng":-100.8142,"speed":22,"timestamp":"2026-05-22T06:25:00Z"},{"positionId":4,"lat":20.5212,"lng":-100.8175,"speed":15,"timestamp":"2026-05-22T06:38:00Z"},{"positionId":5,"lat":20.5210,"lng":-100.8210,"speed":0,"timestamp":"2026-05-22T06:50:00Z"},{"positionId":6,"lat":20.5235,"lng":-100.8212,"speed":18,"timestamp":"2026-05-22T07:05:00Z"},{"positionId":7,"lat":20.5260,"lng":-100.8215,"speed":20,"timestamp":"2026-05-22T07:18:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":40,"timestamp":"2026-05-22T07:40:00Z"}]},
{"routeId":"RUTA-02","name":"Sector Norte - Av. Tecnológico","truckId":102,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:05:00Z"},{"positionId":2,"lat":20.5280,"lng":-100.8135,"speed":38,"timestamp":"2026-05-22T06:18:00Z"},{"positionId":3,"lat":20.5410,"lng":-100.8130,"speed":25,"timestamp":"2026-05-22T06:30:00Z"},{"positionId":4,"lat":20.5445,"lng":-100.8132,"speed":12,"timestamp":"2026-05-22T06:45:00Z"},{"positionId":5,"lat":20.5480,"lng":-100.8135,"speed":0,"timestamp":"2026-05-22T06:58:00Z"},{"positionId":6,"lat":20.5515,"lng":-100.8138,"speed":15,"timestamp":"2026-05-22T07:10:00Z"},{"positionId":7,"lat":20.5540,"lng":-100.8110,"speed":22,"timestamp":"2026-05-22T07:25:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":45,"timestamp":"2026-05-22T07:50:00Z"}]},
{"routeId":"RUTA-03","name":"Sector Poniente - San Juanico","truckId":103,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:10:00Z"},{"positionId":2,"lat":20.5250,"lng":-100.8510,"speed":42,"timestamp":"2026-05-22T06:20:00Z"},{"positionId":3,"lat":20.5290,"lng":-100.8320,"speed":20,"timestamp":"2026-05-22T06:35:00Z"},{"positionId":4,"lat":20.5315,"lng":-100.8355,"speed":15,"timestamp":"2026-05-22T06:48:00Z"},{"positionId":5,"lat":20.5340,"lng":-100.8390,"speed":0,"timestamp":"2026-05-22T07:00:00Z"},{"positionId":6,"lat":20.5362,"lng":-100.8425,"speed":10,"timestamp":"2026-05-22T07:15:00Z"},{"positionId":7,"lat":20.5330,"lng":-100.8430,"speed":18,"timestamp":"2026-05-22T07:28:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":35,"timestamp":"2026-05-22T07:45:00Z"}]},
{"routeId":"RUTA-04","name":"Oriente - Los Olivos","truckId":104,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:15:00Z"},{"positionId":2,"lat":20.5260,"lng":-100.8010,"speed":45,"timestamp":"2026-05-22T06:30:00Z"},{"positionId":3,"lat":20.5295,"lng":-100.7890,"speed":24,"timestamp":"2026-05-22T06:45:00Z"},{"positionId":4,"lat":20.5320,"lng":-100.7850,"speed":12,"timestamp":"2026-05-22T06:58:00Z"},{"positionId":5,"lat":20.5350,"lng":-100.7790,"speed":0,"timestamp":"2026-05-22T07:12:00Z"},{"positionId":6,"lat":20.5310,"lng":-100.7760,"speed":15,"timestamp":"2026-05-22T07:25:00Z"},{"positionId":7,"lat":20.5270,"lng":-100.7820,"speed":26,"timestamp":"2026-05-22T07:38:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":48,"timestamp":"2026-05-22T07:58:00Z"}]},
{"routeId":"RUTA-05","name":"Sector Sur - Rancho Seco","truckId":105,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:20:00Z"},{"positionId":2,"lat":20.5050,"lng":-100.8620,"speed":35,"timestamp":"2026-05-22T06:32:00Z"},{"positionId":3,"lat":20.5020,"lng":-100.8350,"speed":22,"timestamp":"2026-05-22T06:45:00Z"},{"positionId":4,"lat":20.4995,"lng":-100.8210,"speed":14,"timestamp":"2026-05-22T06:58:00Z"},{"positionId":5,"lat":20.4970,"lng":-100.8150,"speed":0,"timestamp":"2026-05-22T07:10:00Z"},{"positionId":6,"lat":20.5010,"lng":-100.8120,"speed":16,"timestamp":"2026-05-22T07:22:00Z"},{"positionId":7,"lat":20.5060,"lng":-100.8160,"speed":25,"timestamp":"2026-05-22T07:35:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":40,"timestamp":"2026-05-22T07:55:00Z"}]},
{"routeId":"RUTA-06","name":"Norte Extremo - Rumbos de Roque","truckId":106,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:00:00Z"},{"positionId":2,"lat":20.5380,"lng":-100.8380,"speed":40,"timestamp":"2026-05-22T06:15:00Z"},{"positionId":3,"lat":20.5610,"lng":-100.8370,"speed":30,"timestamp":"2026-05-22T06:30:00Z"},{"positionId":4,"lat":20.5750,"lng":-100.8360,"speed":15,"timestamp":"2026-05-22T06:45:00Z"},{"positionId":5,"lat":20.5820,"lng":-100.8350,"speed":0,"timestamp":"2026-05-22T07:00:00Z"},{"positionId":6,"lat":20.5780,"lng":-100.8310,"speed":20,"timestamp":"2026-05-22T07:15:00Z"},{"positionId":7,"lat":20.5650,"lng":-100.8320,"speed":28,"timestamp":"2026-05-22T07:30:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":45,"timestamp":"2026-05-22T07:55:00Z"}]},
{"routeId":"RUTA-07","name":"Nororiente - Ciudad Industrial","truckId":107,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:10:00Z"},{"positionId":2,"lat":20.5350,"lng":-100.8050,"speed":44,"timestamp":"2026-05-22T06:24:00Z"},{"positionId":3,"lat":20.5450,"lng":-100.7950,"speed":25,"timestamp":"2026-05-22T06:38:00Z"},{"positionId":4,"lat":20.5480,"lng":-100.7850,"speed":18,"timestamp":"2026-05-22T06:52:00Z"},{"positionId":5,"lat":20.5510,"lng":-100.7750,"speed":0,"timestamp":"2026-05-22T07:05:00Z"},{"positionId":6,"lat":20.5460,"lng":-100.7720,"speed":12,"timestamp":"2026-05-22T07:18:00Z"},{"positionId":7,"lat":20.5390,"lng":-100.7820,"speed":30,"timestamp":"2026-05-22T07:30:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":42,"timestamp":"2026-05-22T07:52:00Z"}]},
{"routeId":"RUTA-08","name":"Suroriente - Universidad Latina","truckId":108,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:15:00Z"},{"positionId":2,"lat":20.5180,"lng":-100.8310,"speed":38,"timestamp":"2026-05-22T06:28:00Z"},{"positionId":3,"lat":20.5245,"lng":-100.7980,"speed":30,"timestamp":"2026-05-22T06:42:00Z"},{"positionId":4,"lat":20.5210,"lng":-100.7995,"speed":14,"timestamp":"2026-05-22T06:55:00Z"},{"positionId":5,"lat":20.5175,"lng":-100.8010,"speed":0,"timestamp":"2026-05-22T07:08:00Z"},{"positionId":6,"lat":20.5140,"lng":-100.8030,"speed":18,"timestamp":"2026-05-22T07:20:00Z"},{"positionId":7,"lat":20.5110,"lng":-100.8055,"speed":22,"timestamp":"2026-05-22T07:32:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":40,"timestamp":"2026-05-22T07:54:00Z"}]},
{"routeId":"RUTA-09","name":"Poniente - Hospital General","truckId":109,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:02:00Z"},{"positionId":2,"lat":20.5210,"lng":-100.8650,"speed":45,"timestamp":"2026-05-22T06:12:00Z"},{"positionId":3,"lat":20.5260,"lng":-100.8520,"speed":26,"timestamp":"2026-05-22T06:24:00Z"},{"positionId":4,"lat":20.5275,"lng":-100.8490,"speed":12,"timestamp":"2026-05-22T06:36:00Z"},{"positionId":5,"lat":20.5285,"lng":-100.8460,"speed":0,"timestamp":"2026-05-22T06:48:00Z"},{"positionId":6,"lat":20.5250,"lng":-100.8470,"speed":15,"timestamp":"2026-05-22T07:00:00Z"},{"positionId":7,"lat":20.5220,"lng":-100.8550,"speed":32,"timestamp":"2026-05-22T07:12:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":44,"timestamp":"2026-05-22T07:30:00Z"}]},
{"routeId":"RUTA-10","name":"Eje Juan Pablo II - Sede UG Sur","truckId":110,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:22:00Z"},{"positionId":2,"lat":20.5015,"lng":-100.8520,"speed":40,"timestamp":"2026-05-22T06:34:00Z"},{"positionId":3,"lat":20.4990,"lng":-100.8390,"speed":28,"timestamp":"2026-05-22T06:46:00Z"},{"positionId":4,"lat":20.4950,"lng":-100.8320,"speed":18,"timestamp":"2026-05-22T06:58:00Z"},{"positionId":5,"lat":20.4920,"lng":-100.8280,"speed":0,"timestamp":"2026-05-22T07:10:00Z"},{"positionId":6,"lat":20.4945,"lng":-100.8240,"speed":14,"timestamp":"2026-05-22T07:22:00Z"},{"positionId":7,"lat":20.4980,"lng":-100.8300,"speed":30,"timestamp":"2026-05-22T07:34:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":38,"timestamp":"2026-05-22T07:52:00Z"}]},
{"routeId":"RUTA-11","name":"Zona de Oro - Torres Landa","truckId":111,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:04:00Z"},{"positionId":2,"lat":20.5240,"lng":-100.8350,"speed":36,"timestamp":"2026-05-22T06:16:00Z"},{"positionId":3,"lat":20.5280,"lng":-100.8250,"speed":22,"timestamp":"2026-05-22T06:29:00Z"},{"positionId":4,"lat":20.5295,"lng":-100.8210,"speed":10,"timestamp":"2026-05-22T06:42:00Z"},{"positionId":5,"lat":20.5310,"lng":-100.8170,"speed":0,"timestamp":"2026-05-22T06:55:00Z"},{"positionId":6,"lat":20.5290,"lng":-100.8140,"speed":16,"timestamp":"2026-05-22T07:08:00Z"},{"positionId":7,"lat":20.5260,"lng":-100.8220,"speed":28,"timestamp":"2026-05-22T07:21:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":42,"timestamp":"2026-05-22T07:42:00Z"}]},
{"routeId":"RUTA-12","name":"Nororiente - Las Insurgentes","truckId":112,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:08:00Z"},{"positionId":2,"lat":20.5280,"lng":-100.8080,"speed":40,"timestamp":"2026-05-22T06:22:00Z"},{"positionId":3,"lat":20.5320,"lng":-100.7980,"speed":24,"timestamp":"2026-05-22T06:35:00Z"},{"positionId":4,"lat":20.5340,"lng":-100.7940,"speed":15,"timestamp":"2026-05-22T06:48:00Z"},{"positionId":5,"lat":20.5360,"lng":-100.7900,"speed":0,"timestamp":"2026-05-22T07:00:00Z"},{"positionId":6,"lat":20.5310,"lng":-100.7920,"speed":12,"timestamp":"2026-05-22T07:12:00Z"},{"positionId":7,"lat":20.5270,"lng":-100.8020,"speed":26,"timestamp":"2026-05-22T07:25:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":44,"timestamp":"2026-05-22T07:48:00Z"}]},
{"routeId":"RUTA-13","name":"Sector Norte - Trojes e Irrigación","truckId":113,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:12:00Z"},{"positionId":2,"lat":20.5360,"lng":-100.8190,"speed":35,"timestamp":"2026-05-22T06:26:00Z"},{"positionId":3,"lat":20.5420,"lng":-100.8080,"speed":28,"timestamp":"2026-05-22T06:40:00Z"},{"positionId":4,"lat":20.5440,"lng":-100.8040,"speed":14,"timestamp":"2026-05-22T06:54:00Z"},{"positionId":5,"lat":20.5460,"lng":-100.8000,"speed":0,"timestamp":"2026-05-22T07:06:00Z"},{"positionId":6,"lat":20.5410,"lng":-100.8020,"speed":18,"timestamp":"2026-05-22T07:18:00Z"},{"positionId":7,"lat":20.5370,"lng":-100.8120,"speed":25,"timestamp":"2026-05-22T07:30:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":39,"timestamp":"2026-05-22T07:54:00Z"}]},
{"routeId":"RUTA-14","name":"Sur Poniente - La Toscana","truckId":114,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:16:00Z"},{"positionId":2,"lat":20.5150,"lng":-100.8580,"speed":42,"timestamp":"2026-05-22T06:28:00Z"},{"positionId":3,"lat":20.5140,"lng":-100.8390,"speed":26,"timestamp":"2026-05-22T06:41:00Z"},{"positionId":4,"lat":20.5125,"lng":-100.8310,"speed":16,"timestamp":"2026-05-22T06:54:00Z"},{"positionId":5,"lat":20.5110,"lng":-100.8250,"speed":0,"timestamp":"2026-05-22T07:06:00Z"},{"positionId":6,"lat":20.5135,"lng":-100.8280,"speed":12,"timestamp":"2026-05-22T07:18:00Z"},{"positionId":7,"lat":20.5160,"lng":-100.8420,"speed":32,"timestamp":"2026-05-22T07:30:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":45,"timestamp":"2026-05-22T07:51:00Z"}]},
{"routeId":"RUTA-15","name":"Norponiente - Camino a San José de Celaya","truckId":115,"status":"EN_RUTA","positions":[{"positionId":1,"lat":20.5111,"lng":-100.9037,"speed":0,"timestamp":"2026-05-22T06:18:00Z"},{"positionId":2,"lat":20.5320,"lng":-100.8590,"speed":38,"timestamp":"2026-05-22T06:31:00Z"},{"positionId":3,"lat":20.5390,"lng":-100.8480,"speed":24,"timestamp":"2026-05-22T06:44:00Z"},{"positionId":4,"lat":20.5420,"lng":-100.8440,"speed":15,"timestamp":"2026-05-22T06:57:00Z"},{"positionId":5,"lat":20.5450,"lng":-100.8410,"speed":0,"timestamp":"2026-05-22T07:09:00Z"},{"positionId":6,"lat":20.5410,"lng":-100.8430,"speed":14,"timestamp":"2026-05-22T07:21:00Z"},{"positionId":7,"lat":20.5360,"lng":-100.8520,"speed":28,"timestamp":"2026-05-22T07:33:00Z"},{"positionId":8,"lat":20.5111,"lng":-100.9037,"speed":41,"timestamp":"2026-05-22T07:54:00Z"}]}
]

19
backend/app/database.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from .config import settings
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {},
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,4 @@
from .user import User
from .address import Address
from .report import Report, ServiceRating, OperationalReport, ReportType, ReportStatus
from .truck import Truck

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from datetime import datetime
from ..database import Base
class Address(Base):
__tablename__ = "addresses"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
label = Column(String, nullable=False, default="Casa") # e.g. "Casa", "Trabajo"
street = Column(String, nullable=False)
colony = Column(String, nullable=True)
city = Column(String, nullable=False, default="Celaya")
lat = Column(Float, nullable=True)
lng = Column(Float, nullable=True)
route_id = Column(String, nullable=True) # assigned route from geo-matching
is_default = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="addresses")
reports = relationship("Report", back_populates="address")
ratings = relationship("ServiceRating", back_populates="address")

View File

@@ -0,0 +1,69 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..database import Base
class ReportType(str, enum.Enum):
NO_PASO = "NO_PASO"
RETRASO = "RETRASO"
ACUMULACION = "ACUMULACION"
OTRO = "OTRO"
class ReportStatus(str, enum.Enum):
PENDIENTE = "PENDIENTE"
EN_PROCESO = "EN_PROCESO"
RESUELTO = "RESUELTO"
CERRADO = "CERRADO"
class Report(Base):
__tablename__ = "reports"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
address_id = Column(Integer, ForeignKey("addresses.id"), nullable=False)
folio = Column(String, unique=True, index=True, nullable=False)
report_type = Column(String, nullable=False)
description = Column(Text, nullable=True)
status = Column(String, default=ReportStatus.PENDIENTE)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="reports")
address = relationship("Address", back_populates="reports")
class ServiceRating(Base):
__tablename__ = "service_ratings"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
address_id = Column(Integer, ForeignKey("addresses.id"), nullable=False)
rating = Column(Integer, nullable=False) # 1-5
comment = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="ratings")
address = relationship("Address", back_populates="ratings")
class OperationalReport(Base):
"""Reportes generados por el personal operativo (empleados)."""
__tablename__ = "operational_reports"
id = Column(Integer, primary_key=True, index=True)
employee_id = Column(Integer, ForeignKey("users.id"), nullable=False)
folio = Column(String, unique=True, index=True, nullable=False)
category = Column(String, nullable=False)
# NO_ARRANQUE | FALLA_MECANICA | ACCIDENTE | OBSTACULO | TRAFICO |
# COMBUSTIBLE | CLIMA | OTRO
description = Column(Text, nullable=True)
severity = Column(String, default="MEDIA") # BAJA | MEDIA | ALTA
route_id = Column(String, nullable=True) # RUTA-XX (opcional)
truck_id = Column(Integer, nullable=True)
status = Column(String, default="REPORTADO") # REPORTADO | EN_ATENCION | RESUELTO
created_at = Column(DateTime, default=datetime.utcnow)
resolved_at = Column(DateTime, nullable=True)

View File

@@ -0,0 +1,25 @@
from sqlalchemy import Column, Integer, String, DateTime, Float
from datetime import datetime
from ..database import Base
class Truck(Base):
"""Flotilla de recolección de basura del Gobierno de Celaya."""
__tablename__ = "trucks"
id = Column(Integer, primary_key=True, index=True)
unit_number = Column(String, unique=True, index=True, nullable=False) # CEL-001
plate = Column(String, unique=True, nullable=True) # GTO-123-A
model = Column(String, nullable=True)
year = Column(Integer, nullable=True)
capacity_kg = Column(Integer, nullable=True)
fuel_type = Column(String, default="DIESEL")
status = Column(String, default="OPERATIVO")
# OPERATIVO | EN_RUTA | TALLER | MANTENIMIENTO | FUERA_SERVICIO | RESERVA
route_id = Column(String, nullable=True, index=True)
base = Column(String, nullable=True) # Patio Norte | Patio Sur | etc.
odometer_km = Column(Integer, default=0)
fuel_level_pct = Column(Integer, default=80)
last_maintenance = Column(DateTime, nullable=True)
next_maintenance_km = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,31 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..database import Base
class UserRole(str, enum.Enum):
CIUDADANO = "CIUDADANO"
EMPLEADO = "EMPLEADO"
ADMIN = "ADMIN"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String, unique=True, index=True, nullable=True)
phone = Column(String, unique=True, index=True, nullable=True)
full_name = Column(String, nullable=False)
hashed_password = Column(String, nullable=True)
oauth_provider = Column(String, nullable=True) # google, facebook, apple
oauth_id = Column(String, nullable=True)
push_token = Column(String, nullable=True)
role = Column(String, nullable=False, default=UserRole.CIUDADANO.value, index=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
addresses = relationship("Address", back_populates="user", cascade="all, delete-orphan")
reports = relationship("Report", back_populates="user", cascade="all, delete-orphan")
ratings = relationship("ServiceRating", back_populates="user", cascade="all, delete-orphan")

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,74 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.address import Address
from ..schemas.address import AddressCreate, AddressUpdate, AddressOut
from ..services import eta_service
from .deps import get_current_user
router = APIRouter(prefix="/addresses", tags=["addresses"])
@router.get("/", response_model=list[AddressOut])
def list_addresses(db: Session = Depends(get_db), user=Depends(get_current_user)):
return db.query(Address).filter(Address.user_id == user.id).all()
@router.post("/", response_model=AddressOut, status_code=201)
def create_address(data: AddressCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
# Assign the closest route based on coordinates
route_id = None
if data.lat and data.lng:
route_id = eta_service.assign_route(data.lat, data.lng)
# If this is default, unset others
if data.is_default:
db.query(Address).filter(Address.user_id == user.id).update({"is_default": False})
# First address is automatically default
existing_count = db.query(Address).filter(Address.user_id == user.id).count()
is_default = data.is_default or existing_count == 0
address = Address(
user_id=user.id,
label=data.label,
street=data.street,
colony=data.colony,
city=data.city,
lat=data.lat,
lng=data.lng,
route_id=route_id,
is_default=is_default,
)
db.add(address)
db.commit()
db.refresh(address)
return address
@router.patch("/{address_id}", response_model=AddressOut)
def update_address(address_id: int, data: AddressUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
address = db.query(Address).filter(Address.id == address_id, Address.user_id == user.id).first()
if not address:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
if data.label is not None:
address.label = data.label
if data.street is not None:
address.street = data.street
if data.colony is not None:
address.colony = data.colony
if data.is_default is True:
db.query(Address).filter(Address.user_id == user.id).update({"is_default": False})
address.is_default = True
db.commit()
db.refresh(address)
return address
@router.delete("/{address_id}", status_code=204)
def delete_address(address_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
address = db.query(Address).filter(Address.id == address_id, Address.user_id == user.id).first()
if not address:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
db.delete(address)
db.commit()

View File

@@ -0,0 +1,179 @@
"""
Endpoints exclusivos para personal del gobierno (EMPLEADO / ADMIN).
Cierra el loop: ciudadano reporta → personal recibe y resuelve.
"""
from typing import Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from ..database import get_db
from ..models.user import User
from ..models.address import Address
from ..models.report import Report, ServiceRating
from ..services import eta_service
from .deps import require_staff, require_admin
router = APIRouter(prefix="/admin", tags=["admin"])
# ─── 1. DASHBOARD: estadísticas globales ─────────────────────────────────────
@router.get("/stats")
def get_dashboard_stats(db: Session = Depends(get_db), _=Depends(require_admin)):
total_users = db.query(func.count(User.id)).filter(User.role == "CIUDADANO").scalar()
total_addresses = db.query(func.count(Address.id)).scalar()
total_reports = db.query(func.count(Report.id)).scalar()
by_status = dict(
db.query(Report.status, func.count(Report.id)).group_by(Report.status).all()
)
by_type = dict(
db.query(Report.report_type, func.count(Report.id)).group_by(Report.report_type).all()
)
# Reportes en últimas 24h
yesterday = datetime.utcnow() - timedelta(days=1)
recent_count = db.query(func.count(Report.id)).filter(Report.created_at >= yesterday).scalar()
# Promedio de calificaciones
avg_rating = db.query(func.avg(ServiceRating.rating)).scalar() or 0
return {
"total_ciudadanos": total_users,
"total_domicilios": total_addresses,
"total_reportes": total_reports,
"reportes_24h": recent_count,
"promedio_calificacion": round(float(avg_rating), 2),
"reportes_por_estado": by_status,
"reportes_por_tipo": by_type,
"rutas_activas": len(eta_service.get_all_routes_summary()),
}
# ─── 2. REPORTES: ver todos / cambiar estado ─────────────────────────────────
@router.get("/reports")
def list_all_reports(
status: Optional[str] = Query(None, description="Filtrar por estado"),
report_type: Optional[str] = Query(None),
db: Session = Depends(get_db),
_=Depends(require_admin),
):
q = db.query(Report).order_by(desc(Report.created_at))
if status:
q = q.filter(Report.status == status)
if report_type:
q = q.filter(Report.report_type == report_type)
results = []
for r in q.all():
addr = db.query(Address).filter(Address.id == r.address_id).first()
user = db.query(User).filter(User.id == r.user_id).first()
results.append({
"id": r.id,
"folio": r.folio,
"report_type": r.report_type,
"description": r.description,
"status": r.status,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
"user_name": user.full_name if user else "?",
"user_email": user.email if user else None,
"address_label": addr.label if addr else "?",
"address_street": addr.street if addr else "?",
"address_colony": addr.colony if addr else None,
"route_id": addr.route_id if addr else None,
})
return results
@router.patch("/reports/{report_id}/status")
def update_report_status(
report_id: int,
status: str = Query(..., description="PENDIENTE | EN_PROCESO | RESUELTO | CERRADO"),
db: Session = Depends(get_db),
_=Depends(require_admin),
):
if status not in ("PENDIENTE", "EN_PROCESO", "RESUELTO", "CERRADO"):
raise HTTPException(status_code=400, detail="Estado inválido")
report = db.query(Report).filter(Report.id == report_id).first()
if not report:
raise HTTPException(status_code=404, detail="Reporte no encontrado")
report.status = status
report.updated_at = datetime.utcnow()
db.commit()
db.refresh(report)
return {"id": report.id, "folio": report.folio, "status": report.status}
# ─── 3. RUTAS: estado operativo de la flotilla ───────────────────────────────
@router.get("/routes")
def list_routes_status(_=Depends(require_admin)):
"""Lista todas las rutas con su estado actual (cálculo desde el simulador)."""
return eta_service.get_all_routes_summary()
# ─── 4. USUARIOS: gestionar ciudadanos / empleados ───────────────────────────
@router.get("/users")
def list_users(
role: Optional[str] = Query(None),
db: Session = Depends(get_db),
_=Depends(require_admin), # Solo ADMIN puede ver usuarios
):
q = db.query(User).order_by(desc(User.created_at))
if role:
q = q.filter(User.role == role)
results = []
for u in q.all():
n_addrs = db.query(func.count(Address.id)).filter(Address.user_id == u.id).scalar()
n_reports = db.query(func.count(Report.id)).filter(Report.user_id == u.id).scalar()
results.append({
"id": u.id,
"full_name": u.full_name,
"email": u.email,
"phone": u.phone,
"role": u.role,
"is_active": u.is_active,
"created_at": u.created_at.isoformat(),
"total_domicilios": n_addrs,
"total_reportes": n_reports,
})
return results
@router.patch("/users/{user_id}/role")
def update_user_role(
user_id: int,
role: str = Query(..., description="CIUDADANO | EMPLEADO | ADMIN"),
db: Session = Depends(get_db),
_=Depends(require_admin),
):
if role not in ("CIUDADANO", "EMPLEADO", "ADMIN"):
raise HTTPException(status_code=400, detail="Rol inválido")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="Usuario no encontrado")
user.role = role
db.commit()
return {"id": user.id, "role": user.role}
# ─── 5. ANUNCIOS / COMUNICACIÓN ──────────────────────────────────────────────
@router.get("/feedback")
def list_recent_feedback(db: Session = Depends(get_db), _=Depends(require_admin)):
"""Últimas calificaciones del servicio para ver feedback ciudadano."""
ratings = db.query(ServiceRating).order_by(desc(ServiceRating.created_at)).limit(50).all()
results = []
for r in ratings:
user = db.query(User).filter(User.id == r.user_id).first()
addr = db.query(Address).filter(Address.id == r.address_id).first()
results.append({
"id": r.id,
"rating": r.rating,
"comment": r.comment,
"created_at": r.created_at.isoformat(),
"user_name": user.full_name if user else "?",
"address_label": addr.label if addr else "?",
"route_id": addr.route_id if addr else None,
})
return results

View File

@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..schemas.auth import UserRegister, UserLogin, OAuthLogin, Token, UserOut
from ..services import auth_service
from .deps import get_current_user
router = APIRouter(prefix="/auth", tags=["auth"])
def _make_token(user) -> Token:
"""user puede ser un User o un int (id)."""
if isinstance(user, int):
# Fallback antiguo
token = auth_service.create_access_token({"sub": str(user)})
return Token(access_token=token, role="CIUDADANO")
token = auth_service.create_access_token({"sub": str(user.id), "role": user.role})
return Token(access_token=token, role=user.role)
@router.post("/register", response_model=Token, status_code=201)
def register(data: UserRegister, db: Session = Depends(get_db)):
if data.email and auth_service.get_user_by_email(db, data.email):
raise HTTPException(status_code=400, detail="El correo ya está registrado")
user = auth_service.create_user(db, data.full_name, data.email, data.phone, data.password)
return _make_token(user)
@router.post("/login", response_model=Token)
def login(data: UserLogin, db: Session = Depends(get_db)):
# Normalizar entrada: trim espacios, lowercase email
email = (data.email or "").strip().lower()
password = (data.password or "").strip()
# Log de debug — muestra qué llega exactamente (sin la contraseña completa)
print(f"[LOGIN] email_recibido={email!r} pw_len={len(password)}")
if not email or not password:
raise HTTPException(status_code=401, detail="Credenciales inválidas — campos vacíos")
# Buscar usuario (case-insensitive con LOWER en SQL)
from sqlalchemy import func
from ..models.user import User as UserModel
user = db.query(UserModel).filter(func.lower(UserModel.email) == email).first()
if not user:
print(f"[LOGIN] ✗ usuario no encontrado")
raise HTTPException(status_code=401, detail="Credenciales inválidas")
if not user.hashed_password:
print(f"[LOGIN] ✗ usuario sin password (cuenta OAuth)")
raise HTTPException(status_code=401, detail="Esta cuenta usa OAuth, no contraseña")
if not auth_service.verify_password(password, user.hashed_password):
print(f"[LOGIN] ✗ password incorrecta")
raise HTTPException(status_code=401, detail="Credenciales inválidas")
print(f"[LOGIN] ✓ login OK para id={user.id}")
return _make_token(user)
@router.post("/oauth", response_model=Token)
def oauth_login(data: OAuthLogin, db: Session = Depends(get_db)):
user = auth_service.get_or_create_oauth_user(db, data.provider, data.oauth_id, data.email, data.full_name)
if data.push_token:
user.push_token = data.push_token
db.commit()
return _make_token(user)

View File

@@ -0,0 +1,35 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from ..database import get_db
from ..services.auth_service import decode_token, get_user_by_id
bearer_scheme = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: Session = Depends(get_db),
):
payload = decode_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token inválido o expirado")
user_id = int(payload.get("sub", 0))
user = get_user_by_id(db, user_id)
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Usuario no encontrado")
return user
def require_admin(user=Depends(get_current_user)):
"""Solo permite acceso a usuarios con rol ADMIN."""
if user.role != "ADMIN":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Acceso denegado: requiere rol ADMIN")
return user
def require_staff(user=Depends(get_current_user)):
"""Permite acceso a EMPLEADO o ADMIN (personal del gobierno)."""
if user.role not in ("EMPLEADO", "ADMIN"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Acceso denegado: requiere rol EMPLEADO o ADMIN")
return user

View File

@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.address import Address
from ..models.report import ServiceRating
from ..schemas.eta import ETAResponse, RouteScheduleResponse, ServiceRatingCreate
from ..services import eta_service
from .deps import get_current_user
router = APIRouter(prefix="/eta", tags=["eta"])
def _get_owned_address(address_id: int, user, db: Session) -> Address:
address = db.query(Address).filter(Address.id == address_id, Address.user_id == user.id).first()
if not address:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
if not address.route_id:
raise HTTPException(status_code=422, detail="Este domicilio no tiene una ruta asignada todavía")
return address
@router.get("/address/{address_id}", response_model=ETAResponse)
def get_eta_for_address(address_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""
Returns the ETA for the garbage truck to reach the user's registered address.
Privacy-by-design: never exposes truck coordinates or other users' routes.
"""
address = _get_owned_address(address_id, user, db)
result = eta_service.get_eta(address.route_id, address.lat, address.lng)
return result
@router.get("/schedule/{address_id}", response_model=RouteScheduleResponse)
def get_schedule_for_address(address_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
address = _get_owned_address(address_id, user, db)
schedule = eta_service.get_route_schedule(address.route_id)
if not schedule:
raise HTTPException(status_code=404, detail="Horario no disponible")
return schedule
@router.post("/rate", status_code=201)
def rate_service(data: ServiceRatingCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
address = db.query(Address).filter(Address.id == data.address_id, Address.user_id == user.id).first()
if not address:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
if not 1 <= data.rating <= 5:
raise HTTPException(status_code=400, detail="La calificación debe ser entre 1 y 5")
rating = ServiceRating(
user_id=user.id,
address_id=data.address_id,
rating=data.rating,
comment=data.comment,
)
db.add(rating)
db.commit()
return {"message": "Gracias por tu calificación"}

View File

@@ -0,0 +1,77 @@
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.report import Report
from ..models.address import Address
from ..schemas.report import ReportCreate, ReportOut, ReportListItem
from .deps import get_current_user
router = APIRouter(prefix="/reports", tags=["reports"])
REPORT_TYPE_LABELS = {
"NO_PASO": "Camión no pasó",
"RETRASO": "Retraso en la ruta",
"ACUMULACION": "Acumulación de basura",
"OTRO": "Otro",
}
def _generate_folio() -> str:
date_str = datetime.utcnow().strftime("%Y%m%d")
short = str(uuid.uuid4())[:6].upper()
return f"MRL-{date_str}-{short}"
@router.post("/", response_model=ReportOut, status_code=201)
def create_report(data: ReportCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
address = db.query(Address).filter(Address.id == data.address_id, Address.user_id == user.id).first()
if not address:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
if data.report_type not in REPORT_TYPE_LABELS:
raise HTTPException(status_code=400, detail="Tipo de reporte inválido")
report = Report(
user_id=user.id,
address_id=data.address_id,
folio=_generate_folio(),
report_type=data.report_type,
description=data.description,
)
db.add(report)
db.commit()
db.refresh(report)
return ReportOut(
id=report.id,
folio=report.folio,
report_type=report.report_type,
description=report.description,
status=report.status,
created_at=report.created_at,
address_label=address.label,
)
@router.get("/", response_model=list[ReportListItem])
def list_reports(db: Session = Depends(get_db), user=Depends(get_current_user)):
reports = db.query(Report).filter(Report.user_id == user.id).order_by(Report.created_at.desc()).all()
return reports
@router.get("/{report_id}", response_model=ReportOut)
def get_report(report_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
report = db.query(Report).filter(Report.id == report_id, Report.user_id == user.id).first()
if not report:
raise HTTPException(status_code=404, detail="Reporte no encontrado")
address = db.query(Address).filter(Address.id == report.address_id).first()
return ReportOut(
id=report.id,
folio=report.folio,
report_type=report.report_type,
description=report.description,
status=report.status,
created_at=report.created_at,
address_label=address.label if address else None,
)

View File

@@ -0,0 +1,175 @@
"""
Endpoints para EMPLEADOS operativos.
- NO pueden gestionar reportes ciudadanos (eso es solo de ADMIN).
- SÍ pueden levantar reportes operativos (problemas con el camión, ruta, etc.)
- Reciben info de su horario, puntualidad y bonos para motivar el servicio.
"""
import uuid
import hashlib
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, func
from pydantic import BaseModel
from ..database import get_db
from ..models.user import User
from ..models.report import OperationalReport
from .deps import get_current_user
router = APIRouter(prefix="/staff", tags=["staff"])
# ─── Helpers ─────────────────────────────────────────────────────────────────
def _require_employee_or_admin(user: User):
"""Asegura que solo empleados o admins acceden. Ciudadanos: 403."""
if user.role not in ("EMPLEADO", "ADMIN"):
raise HTTPException(status_code=403, detail="Acceso solo para personal")
return user
def _generate_folio() -> str:
date = datetime.utcnow().strftime("%Y%m%d")
short = str(uuid.uuid4())[:6].upper()
return f"OP-{date}-{short}"
# ─── Schemas ────────────────────────────────────────────────────────────────
class OperationalReportCreate(BaseModel):
category: str
description: Optional[str] = None
severity: str = "MEDIA"
route_id: Optional[str] = None
truck_id: Optional[int] = None
class OperationalReportOut(BaseModel):
id: int
folio: str
category: str
description: Optional[str]
severity: str
route_id: Optional[str]
truck_id: Optional[int]
status: str
created_at: datetime
class Config:
from_attributes = True
CATEGORY_LABELS = {
"NO_ARRANQUE": "El camión no arrancó",
"FALLA_MECANICA": "Falla mecánica en ruta",
"ACCIDENTE": "Accidente vial",
"OBSTACULO": "Obstáculo bloqueando la ruta",
"TRAFICO": "Tráfico intenso / retraso",
"COMBUSTIBLE": "Nivel bajo de combustible",
"CLIMA": "Clima adverso",
"OTRO": "Otro incidente",
}
# ─── 1. CATEGORÍAS DISPONIBLES ────────────────────────────────────────────────
@router.get("/categories")
def list_categories(user=Depends(get_current_user)):
_require_employee_or_admin(user)
return [{"key": k, "label": v} for k, v in CATEGORY_LABELS.items()]
# ─── 2. CREAR REPORTE OPERATIVO ──────────────────────────────────────────────
@router.post("/operational-reports", response_model=OperationalReportOut, status_code=201)
def create_op_report(data: OperationalReportCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
_require_employee_or_admin(user)
if data.category not in CATEGORY_LABELS:
raise HTTPException(status_code=400, detail="Categoría inválida")
if data.severity not in ("BAJA", "MEDIA", "ALTA"):
raise HTTPException(status_code=400, detail="Severidad inválida")
rep = OperationalReport(
employee_id=user.id,
folio=_generate_folio(),
category=data.category,
description=data.description,
severity=data.severity,
route_id=data.route_id,
truck_id=data.truck_id,
)
db.add(rep)
db.commit()
db.refresh(rep)
return rep
# ─── 3. LISTAR MIS REPORTES OPERATIVOS ───────────────────────────────────────
@router.get("/operational-reports", response_model=list[OperationalReportOut])
def list_my_op_reports(db: Session = Depends(get_db), user=Depends(get_current_user)):
_require_employee_or_admin(user)
return db.query(OperationalReport).filter(
OperationalReport.employee_id == user.id
).order_by(desc(OperationalReport.created_at)).all()
# ─── 4. HORARIO DE TRABAJO ───────────────────────────────────────────────────
@router.get("/schedule")
def get_my_schedule(user=Depends(get_current_user)):
_require_employee_or_admin(user)
# Horario preestablecido (en producción vendría de una tabla)
return {
"shift_name": "Turno Matutino",
"shift_start": "05:30",
"shift_end": "09:00",
"route_block": "06:00 - 08:00",
"breaks": [
{"name": "Descanso técnico", "time": "07:00", "duration_min": 10, "icon": "coffee"},
{"name": "Pausa estiramiento", "time": "08:00", "duration_min": 5, "icon": "yoga"},
],
"days_per_week": "Lunes a sábado",
"rest_day": "Domingo",
"notes": "Llegada puntual a las 05:30 garantiza salida del camión a las 06:00 en tiempo.",
}
# ─── 5. PUNTUALIDAD Y BONOS (datos mock pero coherentes) ─────────────────────
@router.get("/dashboard")
def get_employee_dashboard(db: Session = Depends(get_db), user=Depends(get_current_user)):
_require_employee_or_admin(user)
# Generar valores determinísticos por employee_id (sin random, así no cambia entre llamadas)
seed = int(hashlib.md5(str(user.id).encode()).hexdigest(), 16)
streak_days = 7 + (seed % 18) # 7-24 días
punctuality_pct = 88 + (seed % 12) # 88-99%
bonus_mxn = streak_days * 50 # $50 por día puntual
next_milestone_days = 30 - streak_days if streak_days < 30 else 60 - streak_days
next_milestone_mxn = 500
# Total de reportes operativos generados por este empleado
my_reports_count = db.query(func.count(OperationalReport.id)).filter(
OperationalReport.employee_id == user.id
).scalar() or 0
# Mensaje motivacional rotativo (basado en día del año + user id)
motivations = [
"¡Tu puntualidad permite que miles de familias planifiquen su día!",
"Cada salida a tiempo es un acto de servicio que transforma a Celaya.",
"Hoy también cuentan contigo. Manejen seguro, equipo.",
"Una ruta puntual = vecinos contentos y ciudad más limpia.",
"Tu trabajo refleja el orgullo del Gobierno de Celaya. ¡Gracias!",
]
motivation = motivations[(datetime.utcnow().timetuple().tm_yday + user.id) % len(motivations)]
return {
"employee_name": user.full_name,
"streak_days": streak_days,
"punctuality_pct": punctuality_pct,
"bonus_accumulated_mxn": bonus_mxn,
"next_milestone_days": max(1, next_milestone_days),
"next_milestone_mxn": next_milestone_mxn,
"reports_generated": my_reports_count,
"motivation_quote": motivation,
"rating_label": (
"EXCELENTE" if punctuality_pct >= 95
else "MUY BUENO" if punctuality_pct >= 90
else "BUENO"
),
}

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,34 @@
from pydantic import BaseModel
from typing import Optional
class AddressCreate(BaseModel):
label: str = "Casa"
street: str
colony: Optional[str] = None
city: str = "Celaya"
lat: Optional[float] = None
lng: Optional[float] = None
is_default: bool = False
class AddressUpdate(BaseModel):
label: Optional[str] = None
street: Optional[str] = None
colony: Optional[str] = None
is_default: Optional[bool] = None
class AddressOut(BaseModel):
id: int
label: str
street: str
colony: Optional[str] = None
city: str
lat: Optional[float] = None
lng: Optional[float] = None
route_id: Optional[str] = None
is_default: bool
class Config:
from_attributes = True

View File

@@ -0,0 +1,45 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
class UserRegister(BaseModel):
full_name: str
email: Optional[EmailStr] = None
phone: Optional[str] = None
password: str
class UserLogin(BaseModel):
email: Optional[str] = None
phone: Optional[str] = None
password: str
class OAuthLogin(BaseModel):
provider: str # google | facebook | apple
oauth_id: str
email: Optional[str] = None
full_name: Optional[str] = None
push_token: Optional[str] = None
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
role: str = "CIUDADANO"
class UserOut(BaseModel):
id: int
full_name: str
email: Optional[str] = None
phone: Optional[str] = None
oauth_provider: Optional[str] = None
role: str = "CIUDADANO"
class Config:
from_attributes = True
class TokenData(BaseModel):
user_id: Optional[int] = None

View File

@@ -0,0 +1,28 @@
from pydantic import BaseModel
from typing import Optional
class ETAResponse(BaseModel):
status: str # PROGRAMADO | EN_CAMINO | LLEGANDO | PASO | NO_SERVICIO
message: str
eta_minutes: Optional[int] = None
window_start: Optional[str] = None # "07:20"
window_end: Optional[str] = None # "07:35"
progress: float = 0.0 # 0-100 route completion %
route_name: Optional[str] = None
passes_today: bool = True
next_service: Optional[str] = None # ISO date when it passes next
class RouteScheduleResponse(BaseModel):
route_id: str
route_name: str
days_of_week: list[str] # ["Lunes", "Miércoles", "Viernes"]
approximate_time: str # "6:00 - 8:00 AM"
truck_id: int
class ServiceRatingCreate(BaseModel):
address_id: int
rating: int # 1-5
comment: Optional[str] = None

View File

@@ -0,0 +1,33 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class ReportCreate(BaseModel):
address_id: int
report_type: str # NO_PASO | RETRASO | ACUMULACION | OTRO
description: Optional[str] = None
class ReportOut(BaseModel):
id: int
folio: str
report_type: str
description: Optional[str] = None
status: str
created_at: datetime
address_label: Optional[str] = None
class Config:
from_attributes = True
class ReportListItem(BaseModel):
id: int
folio: str
report_type: str
status: str
created_at: datetime
class Config:
from_attributes = True

View File

View File

@@ -0,0 +1,76 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from ..models.user import User
from ..config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except JWTError:
return None
def get_user_by_email(db: Session, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()
def get_user_by_id(db: Session, user_id: int) -> Optional[User]:
return db.query(User).filter(User.id == user_id).first()
def create_user(db: Session, full_name: str, email: Optional[str], phone: Optional[str], password: str) -> User:
user = User(
full_name=full_name,
email=email,
phone=phone,
hashed_password=hash_password(password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
def get_or_create_oauth_user(db: Session, provider: str, oauth_id: str, email: Optional[str], full_name: Optional[str]) -> User:
user = db.query(User).filter(User.oauth_provider == provider, User.oauth_id == oauth_id).first()
if user:
return user
if email:
user = db.query(User).filter(User.email == email).first()
if user:
user.oauth_provider = provider
user.oauth_id = oauth_id
db.commit()
db.refresh(user)
return user
user = User(
full_name=full_name or "Usuario",
email=email,
oauth_provider=provider,
oauth_id=oauth_id,
)
db.add(user)
db.commit()
db.refresh(user)
return user

View File

@@ -0,0 +1,192 @@
import json
import math
import os
from datetime import datetime, timedelta
from typing import Optional
import pytz
CELAYA_TZ = pytz.timezone("America/Mexico_City")
_routes_cache: Optional[list] = None
def _load_routes() -> list:
global _routes_cache
if _routes_cache is None:
path = os.path.join(os.path.dirname(__file__), "../data/routes.json")
with open(path, encoding="utf-8") as f:
_routes_cache = json.load(f)
return _routes_cache
def _haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
"""Distance in meters between two lat/lng points."""
R = 6_371_000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lng2 - lng1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def assign_route(lat: float, lng: float) -> Optional[str]:
"""Return the route_id whose waypoints are closest to the given address."""
routes = _load_routes()
best_id = None
best_dist = float("inf")
for route in routes:
for pos in route["positions"]:
d = _haversine(lat, lng, pos["lat"], pos["lng"])
if d < best_dist:
best_dist = d
best_id = route["routeId"]
return best_id
def _parse_time_as_local(ts_str: str) -> datetime:
"""
The mock timestamps carry Z but represent local Celaya (CST/CDT) times.
We strip the Z and treat them as naive local time, then localize to Celaya TZ.
"""
naive = datetime.fromisoformat(ts_str.replace("Z", ""))
return CELAYA_TZ.localize(naive)
def _today_at(template: datetime) -> datetime:
"""Move a template datetime to today keeping hour/minute/second."""
now = datetime.now(CELAYA_TZ)
return now.replace(
hour=template.hour,
minute=template.minute,
second=template.second,
microsecond=0,
)
def get_eta(route_id: str, user_lat: float, user_lng: float) -> dict:
routes = _load_routes()
route = next((r for r in routes if r["routeId"] == route_id), None)
if not route:
return {
"status": "NO_SERVICIO",
"message": "No se encontró una ruta asignada a este domicilio.",
"passes_today": False,
"progress": 0,
}
positions = route["positions"]
now = datetime.now(CELAYA_TZ)
# Schedule for today
first_t = _today_at(_parse_time_as_local(positions[0]["timestamp"]))
last_t = _today_at(_parse_time_as_local(positions[-1]["timestamp"]))
# Find the waypoint whose scheduled arrival is closest to user_lat/lng
closest_idx = min(
range(len(positions)),
key=lambda i: _haversine(user_lat, user_lng, positions[i]["lat"], positions[i]["lng"]),
)
user_scheduled_t = _today_at(_parse_time_as_local(positions[closest_idx]["timestamp"]))
# Determine progress along the route (0-100)
total_seconds = (last_t - first_t).total_seconds()
elapsed_seconds = (now - first_t).total_seconds()
progress = max(0.0, min(100.0, (elapsed_seconds / total_seconds) * 100)) if total_seconds > 0 else 0.0
# Route hasn't started yet today
if now < first_t:
eta_min = int((user_scheduled_t - now).total_seconds() / 60)
w_start = user_scheduled_t - timedelta(minutes=8)
w_end = user_scheduled_t + timedelta(minutes=8)
return {
"status": "PROGRAMADO",
"message": f"El camión llegará a tu zona hoy",
"eta_minutes": eta_min,
"window_start": w_start.strftime("%H:%M"),
"window_end": w_end.strftime("%H:%M"),
"progress": 0.0,
"route_name": route["name"],
"passes_today": True,
}
# Route finished for the day
if now > last_t:
return {
"status": "PASO",
"message": "El camión ya pasó por tu zona hoy. ¡Hasta mañana!",
"progress": 100.0,
"route_name": route["name"],
"passes_today": True,
}
# Route in progress — simulate current waypoint index via interpolation
current_idx = 0
for i in range(len(positions) - 1):
t_a = _today_at(_parse_time_as_local(positions[i]["timestamp"]))
t_b = _today_at(_parse_time_as_local(positions[i + 1]["timestamp"]))
if t_a <= now <= t_b:
current_idx = i
break
if current_idx > closest_idx:
return {
"status": "PASO",
"message": "El camión ya pasó por tu zona hace un momento.",
"progress": progress,
"route_name": route["name"],
"passes_today": True,
}
delta = user_scheduled_t - now
eta_min = max(1, int(delta.total_seconds() / 60))
if eta_min <= 10:
return {
"status": "LLEGANDO",
"message": "¡El camión está llegando a tu zona! Prepara tu basura.",
"eta_minutes": eta_min,
"progress": progress,
"route_name": route["name"],
"passes_today": True,
}
w_start = (user_scheduled_t - timedelta(minutes=8)).strftime("%H:%M")
w_end = (user_scheduled_t + timedelta(minutes=8)).strftime("%H:%M")
return {
"status": "EN_CAMINO",
"message": f"El camión llegará a tu zona entre las {w_start} y {w_end}",
"eta_minutes": eta_min,
"window_start": w_start,
"window_end": w_end,
"progress": progress,
"route_name": route["name"],
"passes_today": True,
}
def get_route_schedule(route_id: str) -> Optional[dict]:
routes = _load_routes()
route = next((r for r in routes if r["routeId"] == route_id), None)
if not route:
return None
positions = route["positions"]
first_t = _parse_time_as_local(positions[0]["timestamp"])
last_t = _parse_time_as_local(positions[-1]["timestamp"])
return {
"route_id": route["routeId"],
"route_name": route["name"],
"days_of_week": ["Lunes", "Miércoles", "Viernes"],
"approximate_time": f"{first_t.strftime('%H:%M')} - {last_t.strftime('%H:%M')}",
"truck_id": route["truckId"],
}
def get_all_routes_summary() -> list:
return [
{
"route_id": r["routeId"],
"name": r["name"],
"truck_id": r["truckId"],
"status": r["status"],
}
for r in _load_routes()
]

1318
backend/database_dump.sql Normal file

File diff suppressed because it is too large Load Diff

48
backend/main.py Normal file
View File

@@ -0,0 +1,48 @@
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from app.database import Base, engine, get_db
from app.models import User, Address, Report, ServiceRating
from app.routers import auth, addresses, eta, reports, admin, staff
from app.routers.deps import get_current_user
from app.schemas.auth import UserOut
# Create all tables on startup
Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Mi Ruta Limpia API",
description="Sistema de notificación inteligente de recolección de residuos — Gobierno de Celaya",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/v1")
app.include_router(addresses.router, prefix="/api/v1")
app.include_router(eta.router, prefix="/api/v1")
app.include_router(reports.router, prefix="/api/v1")
app.include_router(admin.router, prefix="/api/v1")
app.include_router(staff.router, prefix="/api/v1")
@app.get("/api/v1/me", response_model=UserOut, tags=["auth"])
def get_me(user=Depends(get_current_user)):
return user
@app.get("/health", tags=["system"])
def health():
return {"status": "ok", "service": "Mi Ruta Limpia API"}
@app.get("/", tags=["system"])
def root():
return {"message": "Mi Ruta Limpia API — Gobierno de Celaya", "docs": "/docs"}

BIN
backend/mi_ruta_limpia.db Normal file

Binary file not shown.

10
backend/requirements.txt Normal file
View File

@@ -0,0 +1,10 @@
fastapi==0.115.5
uvicorn[standard]==0.32.1
sqlalchemy==2.0.36
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.12
pydantic[email]==2.10.3
pydantic-settings==2.6.1
httpx==0.27.2
pytz==2024.1

111
backend/restore_db.py Normal file
View File

@@ -0,0 +1,111 @@
"""
Restaura la base de datos desde database_dump.sql.
Uso (en cualquier máquina):
cd backend
python restore_db.py
Esto:
1. Borra mi_ruta_limpia.db si existe
2. Lee database_dump.sql
3. Crea una BD SQLite nueva idéntica a la original
4. Verifica conteos de registros
Resultado:
- 202 usuarios (185 ciudadanos, 14 empleados, 3 admins)
- 302 domicilios con rutas asignadas
- 280 reportes ciudadanos
- 150 reportes operativos
- 220 calificaciones de servicio
- 62 camiones de la flotilla
Credenciales demo:
- demo@celaya.gob.mx / Celaya2026 (CIUDADANO)
- empleado@celaya.gob.mx / Empleado2026 (EMPLEADO)
- admin@celaya.gob.mx / Admin2026 (ADMIN)
"""
import os
import sqlite3
import sys
DB_FILE = "mi_ruta_limpia.db"
DUMP_FILE = "database_dump.sql"
def main():
# Verificar que existe el dump
if not os.path.exists(DUMP_FILE):
print(f"✗ ERROR: no se encontró '{DUMP_FILE}' en este directorio.")
print(f" Asegúrate de estar parado en backend/ y que el archivo esté ahí.")
sys.exit(1)
# Borrar BD existente si la hay
if os.path.exists(DB_FILE):
print(f"⚠ La base de datos '{DB_FILE}' ya existe. Borrándola para restaurar limpio...")
os.remove(DB_FILE)
# Crear nueva BD ejecutando el dump
print(f"📥 Restaurando desde '{DUMP_FILE}'...")
conn = sqlite3.connect(DB_FILE)
with open(DUMP_FILE, "r", encoding="utf-8") as f:
sql = f.read()
conn.executescript(sql)
conn.commit()
# Verificar conteos
cursor = conn.cursor()
expected = {
"users": 202,
"addresses": 302,
"reports": 280,
"operational_reports": 150,
"service_ratings": 220,
"trucks": 62,
}
print("\n📊 Verificación de tablas:")
all_ok = True
for table, exp_count in expected.items():
try:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
n = cursor.fetchone()[0]
ok = "" if n == exp_count else ""
print(f" {ok} {table:25} {n:>4}/{exp_count}")
if n != exp_count:
all_ok = False
except sqlite3.OperationalError as e:
print(f"{table:25} ERROR: {e}")
all_ok = False
conn.close()
# Resumen
print()
print("" * 60)
if all_ok:
print(" ✅ BASE DE DATOS RESTAURADA EXITOSAMENTE")
else:
print(" ⚠ RESTAURACIÓN COMPLETADA CON DIFERENCIAS")
print("" * 60)
print()
print(" Credenciales demo para probar la app:")
print()
print(" 🧑 CIUDADANO")
print(" Email: demo@celaya.gob.mx")
print(" Contraseña: Celaya2026")
print()
print(" 👮 EMPLEADO")
print(" Email: empleado@celaya.gob.mx")
print(" Contraseña: Empleado2026")
print()
print(" 🛡️ ADMINISTRADOR")
print(" Email: admin@celaya.gob.mx")
print(" Contraseña: Admin2026")
print()
print(" Ya puedes arrancar el backend con:")
print(" uvicorn main:app --reload --host 0.0.0.0 --port 8000")
print()
if __name__ == "__main__":
main()

161
backend/seed.py Normal file
View File

@@ -0,0 +1,161 @@
"""
Script para poblar la base de datos con usuarios demo (ciudadano + empleado + admin).
Uso: python seed.py
"""
from sqlalchemy import text
from app.database import Base, engine, SessionLocal
from app.models import User, Address, Report, ServiceRating
from app.models.user import UserRole
from app.services.auth_service import hash_password
from app.services.eta_service import assign_route
import uuid
from datetime import datetime, timedelta
# Asegurar que todas las tablas existen (creará las nuevas columnas si no existen)
Base.metadata.create_all(bind=engine)
# Migración manual: si la BD ya existía sin la columna `role`, añadirla
with engine.connect() as conn:
try:
conn.execute(text("ALTER TABLE users ADD COLUMN role VARCHAR DEFAULT 'CIUDADANO'"))
conn.commit()
print("✓ Columna 'role' añadida a tabla users")
except Exception:
pass # Ya existía
db = SessionLocal()
# ─── Limpiar TODO para empezar fresco ────────────────────────────────────
print("Limpiando datos previos...")
db.query(ServiceRating).delete()
db.query(Report).delete()
db.query(Address).delete()
db.query(User).delete()
db.commit()
# ─── Definir usuarios demo ───────────────────────────────────────────────
USERS = [
{
"full_name": "María González Demo",
"email": "demo@celaya.gob.mx",
"phone": "461-123-4567",
"password": "Celaya2026",
"role": UserRole.CIUDADANO.value,
"addresses": [
{"label": "Casa", "street": "Calle Hidalgo 245", "colony": "Centro", "lat": 20.5215, "lng": -100.8142, "is_default": True},
{"label": "Trabajo", "street": "Av. Tecnológico 1500", "colony": "Ciudad Industrial", "lat": 20.5450, "lng": -100.7950, "is_default": False},
],
},
{
"full_name": "Carlos Hernández (Empleado)",
"email": "empleado@celaya.gob.mx",
"phone": "461-200-1000",
"password": "Empleado2026",
"role": UserRole.EMPLEADO.value,
"addresses": [],
},
{
"full_name": "Lic. Patricia Ramírez (Admin)",
"email": "admin@celaya.gob.mx",
"phone": "461-100-0001",
"password": "Admin2026",
"role": UserRole.ADMIN.value,
"addresses": [],
},
]
created_users = {}
for u in USERS:
user = User(
full_name=u["full_name"],
email=u["email"],
phone=u["phone"],
hashed_password=hash_password(u["password"]),
role=u["role"],
is_active=True,
)
db.add(user)
db.commit()
db.refresh(user)
created_users[u["role"]] = user
print(f"{u['role']}: {u['email']} (id={user.id})")
for addr in u["addresses"]:
route_id = assign_route(addr["lat"], addr["lng"])
a = Address(
user_id=user.id,
label=addr["label"],
street=addr["street"],
colony=addr["colony"],
city="Celaya",
lat=addr["lat"],
lng=addr["lng"],
route_id=route_id,
is_default=addr["is_default"],
)
db.add(a)
db.commit()
print(f" 📍 {addr['label']} en {addr['street']}{route_id}")
# ─── Reportes demo del ciudadano ─────────────────────────────────────────
ciudadano = created_users[UserRole.CIUDADANO.value]
addresses = db.query(Address).filter(Address.user_id == ciudadano.id).all()
reportes_demo = [
{"addr": 0, "type": "RETRASO", "desc": "El camión pasó 40 min tarde el viernes", "status": "RESUELTO", "days": 5},
{"addr": 0, "type": "NO_PASO", "desc": "Hoy el camión no pasó por la cuadra", "status": "EN_PROCESO", "days": 2},
{"addr": 1, "type": "ACUMULACION", "desc": "Basura acumulada en la esquina", "status": "PENDIENTE", "days": 1},
{"addr": 0, "type": "OTRO", "desc": "Solicito información sobre día festivo", "status": "PENDIENTE", "days": 0},
]
for r in reportes_demo:
folio = f"MRL-{datetime.utcnow().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6].upper()}"
rep = Report(
user_id=ciudadano.id,
address_id=addresses[r["addr"]].id,
folio=folio,
report_type=r["type"],
description=r["desc"],
status=r["status"],
)
db.add(rep)
db.commit()
db.refresh(rep)
rep.created_at = datetime.utcnow() - timedelta(days=r["days"])
db.commit()
print(f" 📋 {folio} | {r['type']} | {r['status']}")
# Calificación
rating = ServiceRating(
user_id=ciudadano.id,
address_id=addresses[0].id,
rating=4,
comment="El servicio mejoró este mes, gracias",
)
db.add(rating)
db.commit()
print(f" ⭐ Calificación 4★")
db.close()
# ─── Resumen ─────────────────────────────────────────────────────────────
print()
print("" * 65)
print(" ✅ BASE DE DATOS POBLADA")
print("" * 65)
print()
print(" CREDENCIALES DE DEMO:")
print()
print(" 🧑 CIUDADANO")
print(" 📧 demo@celaya.gob.mx")
print(" 🔑 Celaya2026")
print()
print(" 👮 EMPLEADO (ve y resuelve reportes)")
print(" 📧 empleado@celaya.gob.mx")
print(" 🔑 Empleado2026")
print()
print(" 🛡️ ADMIN (acceso total + gestión de usuarios)")
print(" 📧 admin@celaya.gob.mx")
print(" 🔑 Admin2026")
print()

356
backend/seed_massive.py Normal file
View File

@@ -0,0 +1,356 @@
"""
Inserción masiva en la base de datos con datos realistas de Celaya.
Cantidades:
- 200 usuarios (185 ciudadanos + 13 empleados + 2 admins)
- 62 camiones (CEL-001 ... CEL-062)
- ~340 domicilios (1-3 por ciudadano)
- ~280 reportes ciudadanos con varios estados y fechas
- ~150 reportes operativos de empleados
- ~220 calificaciones de servicio
Conserva las credenciales demo:
- demo@celaya.gob.mx / Celaya2026 (CIUDADANO)
- empleado@celaya.gob.mx / Empleado2026 (EMPLEADO)
- admin@celaya.gob.mx / Admin2026 (ADMIN)
"""
import random
import uuid
from datetime import datetime, timedelta
from sqlalchemy import text
from app.database import Base, engine, SessionLocal
from app.models import User, Address, Report, ServiceRating, OperationalReport, Truck
from app.models.user import UserRole
from app.services.auth_service import hash_password
from app.services.eta_service import assign_route
random.seed(42) # Reproducible
# ─── Crear tablas y migrar columnas faltantes ────────────────────────────────
Base.metadata.create_all(bind=engine)
with engine.connect() as conn:
for sql in [
"ALTER TABLE users ADD COLUMN role VARCHAR DEFAULT 'CIUDADANO'",
]:
try: conn.execute(text(sql)); conn.commit()
except Exception: pass
db = SessionLocal()
# ─── Datos realistas ─────────────────────────────────────────────────────────
NOMBRES_M = ['María','Guadalupe','Ana','Patricia','Carmen','Rosa','Laura','Sandra','Verónica','Adriana','Claudia','Mónica','Cecilia','Gabriela','Lourdes','Isabel','Yolanda','Alejandra','Leticia','Norma','Diana','Karla','Fabiola','Brenda','Lucía','Elena','Beatriz','Andrea','Sofía','Valeria','Daniela','Paola','Karina','Marisol','Magdalena']
NOMBRES_H = ['José','Juan','Pedro','Carlos','Luis','Miguel','Jorge','Francisco','Alejandro','Antonio','Ricardo','Roberto','Daniel','Fernando','Mario','Eduardo','Javier','Sergio','Raúl','Manuel','Rafael','Arturo','Gerardo','Héctor','Óscar','Ignacio','Salvador','Octavio','Rubén','Pablo','Andrés','Diego','Iván','Alberto','Enrique']
APELLIDOS = ['García','Hernández','González','Martínez','López','Rodríguez','Pérez','Sánchez','Ramírez','Cruz','Flores','Gómez','Morales','Vázquez','Reyes','Jiménez','Torres','Díaz','Ruiz','Mendoza','Aguilar','Castro','Romero','Vargas','Herrera','Castillo','Ortiz','Moreno','Rivera','Chávez','Ramos','Guzmán','Mendez','Estrada','Salazar','Núñez','Cervantes','Domínguez','Solís','Avila']
COLONIAS = ['Centro','Las Arboledas','San Juanico','Los Olivos','Rancho Seco','Rumbos de Roque','Ciudad Industrial','Hospital General','Sede UG Sur','Torres Landa','Las Insurgentes','Trojes','Irrigación','La Toscana','San José de Celaya','Tecnológico','Las Américas','Las Hadas','Real del Cid','Pueblitos','Villas del Mineral','Casa Blanca','La Aurora','Insurgentes Norte','Las Plazas','San Isidro','La Esperanza','Villas de la Hacienda','Camino Real','Los Sauces','Jardines','Praderas','Vista Hermosa']
CALLES = ['Hidalgo','Juárez','Morelos','Madero','Allende','Zaragoza','Independencia','5 de Mayo','16 de Septiembre','Reforma','Constituyentes','Insurgentes','Av. del Trabajo','Av. Tecnológico','Av. Torres Landa','Av. Irrigación','Av. Las Torres','Av. Juan Pablo II','Av. Universidad','Calzada de los Olivos','Mariano Escobedo','Vicente Guerrero','Benito Juárez','Cuauhtémoc','Galeana','Aldama','Pino Suárez','Niños Héroes','Emiliano Zapata','Lázaro Cárdenas']
LABELS_DOMICILIO = ['Casa','Trabajo','Casa de mis papás','Negocio','Casa de campo','Departamento','Casa de la abuela','Oficina','Local','Familia']
# Coordenadas reales aproximadas de zonas de Celaya
ZONAS_CELAYA = [
(20.5215, -100.8142), # Centro
(20.5410, -100.8130), # Av. Tecnológico
(20.5290, -100.8320), # San Juanico
(20.5295, -100.7890), # Los Olivos
(20.5020, -100.8350), # Sur
(20.5610, -100.8370), # Roque
(20.5450, -100.7950), # Ciudad Industrial
(20.5245, -100.7980), # Universidad Latina
(20.5260, -100.8520), # Hospital
(20.4990, -100.8390), # UG Sur
(20.5280, -100.8250), # Torres Landa
(20.5320, -100.7980), # Las Insurgentes
(20.5420, -100.8080), # Trojes
(20.5140, -100.8390), # La Toscana
(20.5390, -100.8480), # San José
]
MODELOS_CAMION = [
{'model': 'International 4300', 'capacity': 8000, 'year_min': 2018},
{'model': 'International 4400', 'capacity': 10000, 'year_min': 2019},
{'model': 'Mercedes-Benz Axor 1725', 'capacity': 12000, 'year_min': 2020},
{'model': 'Freightliner M2 106', 'capacity': 9000, 'year_min': 2017},
{'model': 'Ford F-750', 'capacity': 7500, 'year_min': 2018},
{'model': 'Hino 268', 'capacity': 8500, 'year_min': 2019},
{'model': 'Isuzu FTR', 'capacity': 7000, 'year_min': 2020},
{'model': 'Kenworth T270', 'capacity': 9500, 'year_min': 2019},
]
PATIOS = ['Patio Norte','Patio Sur','Patio Centro','Patio Industrial']
STATUS_CAMION = ['OPERATIVO','EN_RUTA','TALLER','MANTENIMIENTO','RESERVA']
STATUS_WEIGHTS = [0.45, 0.30, 0.08, 0.07, 0.10]
DEMO_USERS = [
{'full_name':'María González Demo','email':'demo@celaya.gob.mx','phone':'461-123-4567','password':'Celaya2026','role':UserRole.CIUDADANO.value},
{'full_name':'Carlos Hernández (Empleado)','email':'empleado@celaya.gob.mx','phone':'461-200-1000','password':'Empleado2026','role':UserRole.EMPLEADO.value},
{'full_name':'Lic. Patricia Ramírez (Admin)','email':'admin@celaya.gob.mx','phone':'461-100-0001','password':'Admin2026','role':UserRole.ADMIN.value},
]
# ─── Helpers ─────────────────────────────────────────────────────────────────
def random_name():
nombre = random.choice(NOMBRES_H if random.random() < 0.5 else NOMBRES_M)
return f"{nombre} {random.choice(APELLIDOS)} {random.choice(APELLIDOS)}"
def random_email(name, i):
parts = name.lower().replace('á','a').replace('é','e').replace('í','i').replace('ó','o').replace('ú','u').replace('ñ','n').split()
return f"{parts[0]}.{parts[-1]}{i:03d}@celaya.gob.mx"
def random_phone():
return f"461-{random.randint(100,999)}-{random.randint(1000,9999)}"
def random_coords():
lat0, lng0 = random.choice(ZONAS_CELAYA)
return (lat0 + random.uniform(-0.005, 0.005), lng0 + random.uniform(-0.005, 0.005))
def random_password_hash():
return hash_password(f"Pwd{random.randint(1000,9999)}!")
def random_date_in_last_days(days):
return datetime.utcnow() - timedelta(days=random.randint(0, days), hours=random.randint(0,23), minutes=random.randint(0,59))
# ─── Limpiar ─────────────────────────────────────────────────────────────────
print("🧹 Limpiando datos previos...")
db.query(ServiceRating).delete()
db.query(OperationalReport).delete()
db.query(Report).delete()
db.query(Address).delete()
db.query(User).delete()
db.query(Truck).delete()
db.commit()
# ─── 1) Usuarios DEMO + masivos (200 total) ──────────────────────────────────
print("\n👥 Creando usuarios...")
demo_password_hash = hash_password("xyz") # se sobreescribe
created_users = []
for u in DEMO_USERS:
user = User(
full_name=u['full_name'], email=u['email'], phone=u['phone'],
hashed_password=hash_password(u['password']),
role=u['role'], is_active=True,
)
db.add(user); db.commit(); db.refresh(user)
created_users.append(user)
print(f" ✓ Demo: {u['role']:9} {u['email']}")
# 13 empleados adicionales
for i in range(13):
name = random_name()
user = User(
full_name=name, email=random_email(name, 100+i),
phone=random_phone(), hashed_password=random_password_hash(),
role=UserRole.EMPLEADO.value, is_active=True,
)
db.add(user); db.commit(); db.refresh(user); created_users.append(user)
# 2 admins adicionales
for i in range(2):
name = random_name()
user = User(
full_name=f"Lic. {name}", email=random_email(name, 200+i),
phone=random_phone(), hashed_password=random_password_hash(),
role=UserRole.ADMIN.value, is_active=True,
)
db.add(user); db.commit(); db.refresh(user); created_users.append(user)
# 184 ciudadanos adicionales (total 185 con el demo)
for i in range(184):
name = random_name()
user = User(
full_name=name, email=random_email(name, 300+i),
phone=random_phone(), hashed_password=random_password_hash(),
role=UserRole.CIUDADANO.value, is_active=True,
)
db.add(user); db.commit(); db.refresh(user); created_users.append(user)
n_citizen = sum(1 for u in created_users if u.role == 'CIUDADANO')
n_employee = sum(1 for u in created_users if u.role == 'EMPLEADO')
n_admin = sum(1 for u in created_users if u.role == 'ADMIN')
print(f" ✓ Total: {len(created_users)} ({n_citizen} ciudadanos, {n_employee} empleados, {n_admin} admins)")
# ─── 2) Camiones (62) ────────────────────────────────────────────────────────
print("\n🚛 Creando 62 camiones...")
trucks = []
for i in range(1, 63):
spec = random.choice(MODELOS_CAMION)
status = random.choices(STATUS_CAMION, weights=STATUS_WEIGHTS)[0]
# Asignar ruta solo si está OPERATIVO o EN_RUTA
route_id = f"RUTA-{random.randint(1,15):02d}" if status in ('OPERATIVO','EN_RUTA') and random.random() < 0.7 else None
truck = Truck(
unit_number=f"CEL-{i:03d}",
plate=f"GTO-{random.randint(100,999)}-{random.choice('ABCDEFGHJKLMNP')}",
model=spec['model'],
year=random.randint(spec['year_min'], 2025),
capacity_kg=spec['capacity'] + random.randint(-500, 500),
fuel_type='DIESEL' if random.random() < 0.9 else 'GAS_LP',
status=status,
route_id=route_id,
base=random.choice(PATIOS),
odometer_km=random.randint(15000, 280000),
fuel_level_pct=random.randint(20, 100),
last_maintenance=datetime.utcnow() - timedelta(days=random.randint(5, 180)),
next_maintenance_km=random.randint(5000, 15000) * 1,
)
db.add(truck); trucks.append(truck)
db.commit()
print(f"{len(trucks)} camiones (status: {dict((s, sum(1 for t in trucks if t.status==s)) for s in STATUS_CAMION)})")
# ─── 3) Domicilios (1-3 por ciudadano) ───────────────────────────────────────
print("\n🏠 Creando domicilios...")
ciudadanos = [u for u in created_users if u.role == 'CIUDADANO']
total_addresses = 0
created_addresses = []
for citizen in ciudadanos:
n = random.choices([1,1,1,2,2,3], k=1)[0] # mayoría 1, algunos 2-3
for j in range(n):
lat, lng = random_coords()
route_id = assign_route(lat, lng)
addr = Address(
user_id=citizen.id,
label=random.choice(LABELS_DOMICILIO),
street=f"{random.choice(CALLES)} {random.randint(1,1500)}",
colony=random.choice(COLONIAS),
city="Celaya",
lat=lat, lng=lng,
route_id=route_id,
is_default=(j == 0),
)
db.add(addr); created_addresses.append(addr)
total_addresses += 1
db.commit()
for a in created_addresses: db.refresh(a)
print(f"{total_addresses} domicilios creados")
# ─── 4) Reportes ciudadanos (~280) ───────────────────────────────────────────
print("\n📋 Creando reportes ciudadanos...")
TYPES = ['NO_PASO','RETRASO','ACUMULACION','OTRO']
TYPE_WEIGHTS = [0.35, 0.30, 0.25, 0.10]
STATUSES = ['PENDIENTE','EN_PROCESO','RESUELTO','CERRADO']
STATUS_W = [0.30, 0.20, 0.40, 0.10]
DESCRIPCIONES = {
'NO_PASO': ['El camión no pasó por mi calle.', 'Hoy no escuché el camión.', 'No vi pasar el camión en su horario.', 'El camión saltó nuestra cuadra.', 'Llevamos 2 días sin recolección.'],
'RETRASO': ['Pasó más de una hora tarde.', 'Llegó cuando ya nos íbamos a trabajar.', 'El retraso es de cada vez peor en la semana.', 'Llegó casi a mediodía.', 'Estuvo retrasado más de 90 minutos.'],
'ACUMULACION': ['Hay basura acumulada en la esquina.', 'Bolsas regadas en la calle desde hace días.', 'Acumulación que atrae perros.', 'Mal olor por basura acumulada.', 'Bote desbordado en parada de camión.'],
'OTRO': ['Solicito información sobre días festivos.', 'Pregunta sobre separación de residuos.', 'Comentario general sobre el servicio.', '¿Cuándo van a pasar por electrónicos?', 'Felicitación al equipo del camión.'],
}
n_reports = 0
for _ in range(280):
if not created_addresses: break
addr = random.choice(created_addresses)
rtype = random.choices(TYPES, weights=TYPE_WEIGHTS)[0]
status = random.choices(STATUSES, weights=STATUS_W)[0]
folio = f"MRL-{datetime.utcnow().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6].upper()}"
rep = Report(
user_id=addr.user_id,
address_id=addr.id,
folio=folio,
report_type=rtype,
description=random.choice(DESCRIPCIONES[rtype]),
status=status,
)
db.add(rep); db.flush()
rep.created_at = random_date_in_last_days(60)
n_reports += 1
db.commit()
print(f"{n_reports} reportes ciudadanos")
# ─── 5) Reportes operativos de empleados (~150) ──────────────────────────────
print("\n🚧 Creando reportes operativos...")
empleados = [u for u in created_users if u.role == 'EMPLEADO']
CATEGORIES = ['NO_ARRANQUE','FALLA_MECANICA','ACCIDENTE','OBSTACULO','TRAFICO','COMBUSTIBLE','CLIMA','OTRO']
CAT_W = [0.10, 0.20, 0.05, 0.15, 0.20, 0.10, 0.15, 0.05]
SEVERIDADES = ['BAJA','MEDIA','ALTA']
SEV_W = [0.45, 0.40, 0.15]
OP_STATUSES = ['REPORTADO','EN_ATENCION','RESUELTO']
OP_STATUS_W = [0.25, 0.20, 0.55]
DESC_OP = {
'NO_ARRANQUE': ['Batería descargada esta mañana.', 'No prendió, parece arranque.', 'Marcha fallada, intentos múltiples.'],
'FALLA_MECANICA': ['Frenos haciendo ruido.', 'Pérdida de aceite visible.', 'Falla en la transmisión.', 'Sobrecalentamiento del motor.', 'Ruido extraño en la dirección.'],
'ACCIDENTE': ['Golpe leve con vehículo particular en Av. Tecnológico.', 'Raspón con poste, sin lesiones.', 'Choque sin víctimas, esperando ajustador.'],
'OBSTACULO': ['Vehículo bloqueando paso en colonia.', 'Calle inundada, no se puede acceder.', 'Árbol caído atravesando la calle.', 'Obra municipal cerrando la ruta.'],
'TRAFICO': ['Tráfico inusual por evento religioso.', 'Manifestación cerrando avenida principal.', 'Embotellamiento por accidente ajeno.'],
'COMBUSTIBLE': ['Tanque bajo, requiere recarga.', 'Bomba de combustible reportando fallas.'],
'CLIMA': ['Lluvia intensa, suspendido temporalmente.', 'Granizada en la zona alta.', 'Viento fuerte que dificulta operación.'],
'OTRO': ['Comentario al supervisor.', 'Solicitud de mantenimiento preventivo.'],
}
n_op = 0
for _ in range(150):
if not empleados: break
employee = random.choice(empleados)
cat = random.choices(CATEGORIES, weights=CAT_W)[0]
sev = random.choices(SEVERIDADES, weights=SEV_W)[0]
st = random.choices(OP_STATUSES, weights=OP_STATUS_W)[0]
folio = f"OP-{datetime.utcnow().strftime('%Y%m%d')}-{str(uuid.uuid4())[:6].upper()}"
truck = random.choice(trucks)
rep = OperationalReport(
employee_id=employee.id,
folio=folio,
category=cat,
description=random.choice(DESC_OP[cat]),
severity=sev,
route_id=truck.route_id or f"RUTA-{random.randint(1,15):02d}",
truck_id=truck.id,
status=st,
)
db.add(rep); db.flush()
rep.created_at = random_date_in_last_days(45)
if st == 'RESUELTO':
rep.resolved_at = rep.created_at + timedelta(hours=random.randint(2, 72))
n_op += 1
db.commit()
print(f"{n_op} reportes operativos")
# ─── 6) Calificaciones de servicio (~220) ────────────────────────────────────
print("\n⭐ Creando calificaciones...")
COMENTARIOS_POS = ['Servicio puntual, gracias.', 'Buen trabajo del equipo.', 'Mejoró mucho este mes.', 'Excelente atención.', None, None, None]
COMENTARIOS_NEG = ['Llegaron tarde varias veces.', 'Falta limpieza después del paso.', 'Hicieron mucho ruido.', None, None]
n_ratings = 0
for _ in range(220):
if not created_addresses: break
addr = random.choice(created_addresses)
rating_val = random.choices([5,4,3,2,1], weights=[0.45, 0.30, 0.15, 0.07, 0.03])[0]
if rating_val >= 4:
comment = random.choice(COMENTARIOS_POS)
elif rating_val == 3:
comment = None
else:
comment = random.choice(COMENTARIOS_NEG)
rating = ServiceRating(
user_id=addr.user_id,
address_id=addr.id,
rating=rating_val,
comment=comment,
)
db.add(rating); db.flush()
rating.created_at = random_date_in_last_days(45)
n_ratings += 1
db.commit()
print(f"{n_ratings} calificaciones")
db.close()
# ─── Resumen final ───────────────────────────────────────────────────────────
print()
print("" * 65)
print(" ✅ INSERCIÓN MASIVA COMPLETADA")
print("" * 65)
print(f" 👥 Usuarios: {len(created_users):>5}")
print(f" - Ciudadanos: {n_citizen:>5}")
print(f" - Empleados: {n_employee:>5}")
print(f" - Admins: {n_admin:>5}")
print(f" 🏠 Domicilios: {total_addresses:>5}")
print(f" 🚛 Camiones: {len(trucks):>5}")
print(f" 📋 Reportes ciudadanos: {n_reports:>5}")
print(f" 🚧 Reportes operativos: {n_op:>5}")
print(f" ⭐ Calificaciones servicio: {n_ratings:>5}")
print()
print(" CREDENCIALES DEMO (sin cambios):")
print(" 🧑 demo@celaya.gob.mx / Celaya2026")
print(" 👮 empleado@celaya.gob.mx / Empleado2026")
print(" 🛡️ admin@celaya.gob.mx / Admin2026")
print()

261
docs/API.md Normal file
View File

@@ -0,0 +1,261 @@
# API REST — Referencia
> Base URL: `http://10.82.68.125:8000/api/v1` · Docs interactivos: `http://localhost:8000/docs`
Todos los endpoints (excepto `/auth/*`) requieren header:
```
Authorization: Bearer <JWT_TOKEN>
```
---
## 🔐 Autenticación
### POST `/auth/register`
Crea un usuario nuevo (rol CIUDADANO por defecto).
**Request:**
```json
{ "full_name": "Juan Pérez", "email": "juan@example.com", "password": "secret123" }
```
**Response 201:**
```json
{ "access_token": "eyJ...", "token_type": "bearer", "role": "CIUDADANO" }
```
### POST `/auth/login`
**Request:**
```json
{ "email": "demo@celaya.gob.mx", "password": "Celaya2026" }
```
**Response 200:**
```json
{ "access_token": "eyJ...", "token_type": "bearer", "role": "CIUDADANO" }
```
**Response 401:** `{"detail": "Credenciales inválidas"}`
### POST `/auth/oauth`
Login/registro vía OAuth (Google, Facebook, Apple).
```json
{ "provider": "google", "oauth_id": "abc123", "email": "juan@gmail.com", "full_name": "Juan", "push_token": "..." }
```
### GET `/me`
Devuelve el usuario actual.
**Response:** `{ "id": 1, "full_name": "...", "email": "...", "role": "CIUDADANO" }`
---
## 🏠 Domicilios (`/addresses`)
### GET `/addresses/`
Lista tus domicilios.
**Response:** `[{"id":1, "label":"Casa", "street":"...", "lat":20.5, "lng":-100.8, "route_id":"RUTA-01", "is_default":true}, ...]`
### POST `/addresses/`
Crea un domicilio. Si pasas `lat`/`lng`, se asigna ruta automáticamente.
```json
{ "label": "Casa", "street": "Hidalgo 245", "colony": "Centro", "lat": 20.5215, "lng": -100.8142, "is_default": true }
```
### PATCH `/addresses/{id}`
Actualiza un domicilio. Solo campos modificados.
```json
{ "is_default": true }
```
### DELETE `/addresses/{id}` → 204
---
## ⏰ ETA (`/eta`)
### GET `/eta/address/{id}` — núcleo del sistema
**Privacy-by-design:** nunca devuelve coordenadas del camión.
**Response 200:**
```json
{
"status": "EN_CAMINO",
"message": "El camión llegará a tu zona entre las 7:20 y 7:35",
"eta_minutes": 12,
"window_start": "07:20",
"window_end": "07:35",
"progress": 45.2,
"route_name": "Zona Centro - Las Arboledas",
"passes_today": true
}
```
**Estados posibles:**
- `PROGRAMADO` — la ruta aún no empieza hoy
- `EN_CAMINO` — el camión va en ruta
- `LLEGANDO` — faltan ≤10 minutos
- `PASO` — ya pasó por la zona
- `NO_SERVICIO` — no aplica hoy
### GET `/eta/schedule/{address_id}`
Devuelve días y horario aproximado.
```json
{ "route_id":"RUTA-01", "route_name":"Zona Centro - Las Arboledas",
"days_of_week":["Lunes","Miércoles","Viernes"], "approximate_time":"06:00 - 07:40",
"truck_id": 101 }
```
### POST `/eta/rate`
Califica el servicio.
```json
{ "address_id": 1, "rating": 5, "comment": "Excelente puntualidad" }
```
---
## 📋 Reportes ciudadanos (`/reports`)
### POST `/reports/`
Crea un reporte con folio único.
```json
{ "address_id": 1, "report_type": "NO_PASO", "description": "El camión no pasó hoy" }
```
**Response 201:**
```json
{ "id":5, "folio":"MRL-20260523-F6EC92", "status":"PENDIENTE",
"report_type":"NO_PASO", "description":"...", "created_at":"...", "address_label":"Casa" }
```
**Tipos:** `NO_PASO` · `RETRASO` · `ACUMULACION` · `OTRO`
### GET `/reports/`
Tus reportes (no los de otros usuarios).
### GET `/reports/{id}`
Detalle con la etiqueta del domicilio.
---
## 👮 Staff (`/staff`) — solo EMPLEADO o ADMIN
### GET `/staff/dashboard`
Métricas motivacionales del empleado.
```json
{
"employee_name": "Carlos Hernández",
"streak_days": 19,
"punctuality_pct": 88,
"bonus_accumulated_mxn": 950,
"next_milestone_days": 11,
"next_milestone_mxn": 500,
"reports_generated": 5,
"motivation_quote": "Tu puntualidad permite que miles de familias planifiquen su día",
"rating_label": "BUENO"
}
```
### GET `/staff/schedule`
Horario preestablecido con descansos.
### GET `/staff/categories`
Las 8 categorías disponibles para reportes operativos.
### POST `/staff/operational-reports`
Crea un reporte operativo (folio `OP-...`).
```json
{ "category": "FALLA_MECANICA", "severity": "MEDIA", "description": "Frenos haciendo ruido", "route_id": "RUTA-01", "truck_id": 12 }
```
**Categorías:** `NO_ARRANQUE` · `FALLA_MECANICA` · `ACCIDENTE` · `OBSTACULO` · `TRAFICO` · `COMBUSTIBLE` · `CLIMA` · `OTRO`
### GET `/staff/operational-reports`
Lista tus reportes operativos.
---
## 🛡️ Admin (`/admin`) — solo ADMIN
### GET `/admin/stats`
KPIs globales del sistema.
```json
{
"total_ciudadanos": 185, "total_domicilios": 302, "total_reportes": 280,
"reportes_24h": 8, "promedio_calificacion": 4.07,
"reportes_por_estado": {"PENDIENTE": 84, "EN_PROCESO": 56, "RESUELTO": 112, "CERRADO": 28},
"reportes_por_tipo": {"NO_PASO": 98, "RETRASO": 84, ...},
"rutas_activas": 15
}
```
### GET `/admin/reports?status=PENDIENTE&report_type=NO_PASO`
Lista TODOS los reportes ciudadanos (no solo los tuyos). Filtros opcionales.
### PATCH `/admin/reports/{id}/status?status=EN_PROCESO`
Cambia el estado de un reporte. Estados válidos: `PENDIENTE | EN_PROCESO | RESUELTO | CERRADO`.
### GET `/admin/routes`
Lista todas las rutas con su estado actual.
### GET `/admin/users?role=CIUDADANO`
Lista usuarios con conteos de domicilios y reportes.
### PATCH `/admin/users/{id}/role?role=EMPLEADO`
Cambia el rol de un usuario. Roles: `CIUDADANO | EMPLEADO | ADMIN`.
### GET `/admin/feedback`
Últimas 50 calificaciones del servicio.
---
## 🔒 Códigos de respuesta
| Código | Significado |
|--------|-------------|
| 200 | OK |
| 201 | Creado |
| 204 | Sin contenido (DELETE exitoso) |
| 400 | Bad request (validación falló) |
| 401 | No autenticado / token inválido |
| 403 | Sin permisos para este recurso |
| 404 | Recurso no encontrado o no te pertenece |
| 500 | Error del servidor |
---
## 🧪 Ejemplos con curl
### Flujo completo: ciudadano → reporte → admin lo resuelve
```bash
BASE=http://10.82.68.125:8000/api/v1
# 1. Login ciudadano
CTOKEN=$(curl -s -X POST $BASE/auth/login -H "Content-Type: application/json" \
-d '{"email":"demo@celaya.gob.mx","password":"Celaya2026"}' | jq -r .access_token)
# 2. Listar domicilios
curl -s $BASE/addresses/ -H "Authorization: Bearer $CTOKEN" | jq
# 3. Ver ETA del domicilio 1
curl -s $BASE/eta/address/1 -H "Authorization: Bearer $CTOKEN" | jq
# 4. Crear reporte
REPORT=$(curl -s -X POST $BASE/reports/ -H "Authorization: Bearer $CTOKEN" \
-H "Content-Type: application/json" \
-d '{"address_id":1,"report_type":"NO_PASO","description":"Hoy no pasó"}')
echo $REPORT | jq
REPORT_ID=$(echo $REPORT | jq -r .id)
# 5. Login admin
ATOKEN=$(curl -s -X POST $BASE/auth/login -H "Content-Type: application/json" \
-d '{"email":"admin@celaya.gob.mx","password":"Admin2026"}' | jq -r .access_token)
# 6. Admin ve TODOS los reportes
curl -s $BASE/admin/reports -H "Authorization: Bearer $ATOKEN" | jq '. | length'
# 7. Admin cambia el estado a EN_PROCESO
curl -s -X PATCH "$BASE/admin/reports/$REPORT_ID/status?status=EN_PROCESO" \
-H "Authorization: Bearer $ATOKEN" | jq
# 8. El ciudadano vuelve a consultar y ve el cambio
curl -s $BASE/reports/$REPORT_ID -H "Authorization: Bearer $CTOKEN" | jq .status
# → "EN_PROCESO" ✓
```

318
docs/ARQUITECTURA.md Normal file
View File

@@ -0,0 +1,318 @@
# Arquitectura técnica
> Documento técnico para evaluación. Decisiones de diseño, algoritmos clave y consideraciones de privacidad/seguridad.
---
## 1. Visión general
**Mi Ruta Limpia** es una arquitectura **clienteservidor REST** con simulación de eventos en el backend para emular telemetría de la flotilla, sin requerir GPS real.
```
[App móvil RN] ↔ HTTPS/REST + JWT ↔ [FastAPI] ↔ SQLite + routes.json (mock)
```
### Por qué esta arquitectura
| Decisión | Razón |
|----------|-------|
| **FastAPI** (Python) | Validación automática con Pydantic, OpenAPI docs gratis, async-ready, productivo en hackathon |
| **SQLite** (no PostgreSQL) | Zero-config, suficiente para hackathon, migración a PG es trivial (cambiar `DATABASE_URL`) |
| **JSON mock para rutas** | Cumple el requisito de "simular eventos sin telemetría real" sin complejidad innecesaria |
| **JWT** stateless | No requiere sesión en servidor, escalable horizontalmente |
| **React Native + Expo** | Multiplataforma (iOS, Android, web) con un solo código |
| **fetch sobre axios** | Axios rompe en RN nueva arquitectura por `URL.protocol` strict mode |
---
## 2. Backend — Estructura por capas
```
app/
├── config.py ← Settings con pydantic-settings (env vars)
├── database.py ← SQLAlchemy engine + sessionmaker
├── models/ ← ORM (capa de datos)
├── schemas/ ← Pydantic (capa de validación / serialización)
├── routers/ ← Endpoints HTTP (capa de presentación)
└── services/ ← Lógica de negocio (capa de dominio)
```
**Patrón:** los routers solo orquestan. Toda la lógica real vive en `services/`. Los modelos no contienen lógica.
### Inyección de dependencias (FastAPI `Depends`)
```python
# Cada endpoint declara qué necesita:
@router.get("/eta/address/{id}")
def get_eta(
id: int,
db: Session = Depends(get_db), BD sesión
user = Depends(get_current_user), JWT validado
):
...
```
Esto permite testear cada endpoint con mocks fácilmente.
---
## 3. Modelo de datos
```
users (id, email, phone, full_name, hashed_password, role, oauth_*, push_token, is_active)
├──< addresses (id, user_id, label, street, colony, lat, lng, route_id, is_default)
│ │
│ ├──< reports (id, user_id, address_id, folio, report_type, description, status, created_at)
│ │
│ └──< service_ratings (id, user_id, address_id, rating, comment, created_at)
└──< operational_reports (id, employee_id, folio, category, severity, route_id, truck_id, status, ...)
trucks (id, unit_number, plate, model, year, status, route_id, base, odometer_km, fuel_level_pct, ...)
```
### Roles (RBAC)
- **`CIUDADANO`** — default. Solo accede a `/auth`, `/addresses` (suyos), `/eta` (suyos), `/reports` (suyos)
- **`EMPLEADO`** — accede a `/staff/*` (su propio dashboard, sus reportes operativos). NO accede a `/admin/*`
- **`ADMIN`** — accede a todo, incluyendo `/admin/*` (gestión de reportes ciudadanos y usuarios)
### Migraciones
Por simplicidad de hackathon, las migraciones se hacen con `Base.metadata.create_all()` + `ALTER TABLE` inline en `seed.py`. En producción se debe usar **Alembic**.
---
## 4. Algoritmo ETA — núcleo del sistema
### Problema
Dado un domicilio (lat, lng) y la hora actual, calcular cuándo pasará el camión **sin exponer su ubicación real**.
### Datos de entrada
[`backend/app/data/routes.json`](../backend/app/data/routes.json) — 15 rutas, cada una con 8 waypoints, cada waypoint con:
- `lat`, `lng`
- `timestamp` (template, se reinterpreta cada día)
- `speed`
### Algoritmo (`app/services/eta_service.py`)
```python
def get_eta(route_id, user_lat, user_lng):
route = load_route(route_id)
now = datetime.now(CELAYA_TZ)
# 1. Construir horario del día con base en los timestamps templates
first_t = today_at(parse(route.positions[0].timestamp))
last_t = today_at(parse(route.positions[-1].timestamp))
# 2. Determinar fase
if now < first_t: return PROGRAMADO # no ha empezado
if now > last_t: return PASO # ya terminó
# 3. Encontrar entre qué dos waypoints está el camión ahora
current_idx = find_segment(route, now)
# 4. Encontrar el waypoint más cercano al domicilio del usuario
closest_idx = argmin(haversine(user, w) for w in route.positions)
# 5. Comparar
if current_idx > closest_idx: return PASO # ya pasó
if current_idx == closest_idx: return LLEGANDO # muy cerca
# 6. Calcular ventana horaria
user_time = today_at(parse(route.positions[closest_idx].timestamp))
eta_minutes = (user_time - now).minutes
return {
'status': 'EN_CAMINO',
'message': f'Entre las {user_time - 8min} y {user_time + 8min}',
'eta_minutes': eta_minutes,
'progress': elapsed_pct(now, first_t, last_t),
}
```
### Por qué este enfoque
- **No requiere telemetría real:** los timestamps son el "schedule" diario; la posición actual se infiere de la hora
- **Privacy by design built-in:** la API jamás devuelve `lat`/`lng` del camión
- **Predecible:** el ciudadano sabe el rango horario, no un punto exacto
- **Tolerante a tráfico:** las ventanas (`±8 minutos`) absorben la variabilidad
### Geo-routing automático
Cuando un ciudadano registra un domicilio sin `route_id`, `assign_route(lat, lng)` calcula el waypoint más cercano de todas las rutas y asigna la ruta correspondiente (algoritmo: distancia Haversine en O(n) con n=15·8=120 waypoints).
---
## 5. Seguridad
### Autenticación
- **bcrypt** con cost factor 12 para hashing
- **JWT** firmado con HS256, expira en 7 días
- El token incluye `sub` (user_id) y `role`
- Los endpoints sensibles validan el token con `HTTPBearer` + dependency
### Autorización (RBAC)
Tres dependencies en `routers/deps.py`:
```python
get_current_user cualquier rol autenticado
require_staff EMPLEADO o ADMIN (devuelve 403 si no)
require_admin solo ADMIN (devuelve 403 si no)
```
Probado con curl:
```bash
# Ciudadano → /admin/stats → HTTP 403 ✓
# Empleado → /admin/reports → HTTP 403 ✓
# Empleado → /staff/dashboard → HTTP 200 ✓
```
### Pasos adicionales para producción
- [ ] Refresh tokens
- [ ] Rate limiting (slowapi)
- [ ] HTTPS obligatorio
- [ ] CORS estricto (no `*`)
- [ ] Logs auditables (sin PII)
- [ ] Rotación de `SECRET_KEY`
- [ ] 2FA opcional
---
## 6. Privacidad por Diseño
| Requisito hackathon | Cómo se cumple |
|---------------------|----------------|
| "**PROHIBIDO el seguidor de ruta activo**" | El endpoint `/eta/address/:id` **nunca** devuelve `lat`/`lng` del camión. Solo: status, mensaje, ventana horaria, progreso% |
| "**PROHIBIDO el snooping**" | El backend valida `Address.user_id == current_user.id` en cada petición. Si intentas consultar `/eta/address/999` y no te pertenece → 404 |
| "**Visión de túnel: solo tu ruta**" | El cliente solo recibe `route_name` (texto), nunca el polyline de la ruta |
| "**Mensajería preventiva**" | Banner explícito en HomeScreen: *"Por tu seguridad, no persigas al camión. ¡Saca tu basura con tiempo!"* |
### Verificación
```bash
# Login como demo (id=1)
curl -X POST .../auth/login -d '{"email":"demo@celaya.gob.mx","password":"Celaya2026"}'
# Intentar acceder a un domicilio ajeno (id=999)
curl -H "Authorization: Bearer $TOKEN" .../eta/address/999
# → HTTP 404 "Domicilio no encontrado"
```
---
## 7. Simulación de eventos
Cumple el requisito *"Dado que no tendrán acceso a la telemetría real, deberán simular el avance de la ruta"*.
### Estrategia
1. **`routes.json`** contiene 15 rutas con 8 waypoints + timestamps cada una
2. Los timestamps son **templates relativos**: se reinterpretan cada día
3. **`eta_service.py`** los proyecta a la fecha actual y calcula la posición simulada por interpolación temporal
4. **No hay cron job ni websockets** — la simulación es **stateless y on-demand**
### Ventaja
Cualquier petición al backend, en cualquier momento del día, devuelve un ETA coherente con la "hora del camión" sin necesidad de procesos en background.
### Migración a telemetría real
Para producción con GPS real, solo cambiar `eta_service.get_eta()` para que:
1. Lea la posición actual del camión desde una tabla `truck_positions` (alimentada por GPS real)
2. Compare contra el waypoint del usuario
3. Devuelva la misma estructura de respuesta — **el cliente no cambia**
---
## 8. Frontend — Arquitectura
### Routing por rol
```
LoginScreen
↓ (login exitoso)
AuthContext.user.role
├── 'CIUDADANO' → CitizenTabs (5 pestañas)
├── 'EMPLEADO' → EmployeeTabs (3 pestañas)
└── 'ADMIN' → AdminTabs (4 pestañas)
```
### Estado
- **AuthContext** global (React Context) — guarda `user` + métodos `login/logout/register`
- **AsyncStorage** persistente — guarda el JWT entre sesiones
- **Estado local por pantalla** (`useState`) — listas, modales, formularios
- **No usamos Redux/Zustand** — overkill para este alcance
### Networking
- **`fetch`** nativo en lugar de axios (compatibilidad con RN nueva arq.)
- **Interceptor de auth**: `services/api.js` añade el `Authorization: Bearer` automáticamente y limpia el token en 401
- **No hay polling agresivo**: el cliente solo refresca al pull-to-refresh o navegar
### Polyfills críticos
- `react-native-url-polyfill/auto` — reemplaza `global.URL` con la implementación WHATWG completa **antes** de que Expo cargue. Solución al bug *"URL.protocol is not implemented"* en RN 0.74
---
## 9. Decisiones de UX
### Tipografía y color
- **Color guinda institucional** `#7B1A2E` (extraído del logo del Gobierno de Celaya)
- **Acento dorado** `#C8960C` para etiquetas de sección, evocando el slogan "ES GRANDE POR TI"
- **Encabezado guinda detrás de la dynamic island** en todas las pestañas — efecto de marca premium
- Tipografía: pesos 800-900 para títulos, 600 para subtítulos, 400 para cuerpo
### Patrón de pantalla repetido
Todas las pantallas post-login siguen:
```
AppHeader (guinda + "Mi Ruta Limpia")
Hero (gradient o imagen con título grande)
[Etiqueta dorada UPPERCASE] + [Título grande sección]
Cards / contenido
```
### Microinteracciones
- Pull-to-refresh en listas
- Confirmación "ya pasó" con opción **deshacer** (persistente por día/domicilio en AsyncStorage)
- Modales bottom-sheet para formularios largos
- Toast/Alert nativo para feedback
---
## 10. Limitaciones conocidas
| Limitación | Por qué | Mitigación |
|------------|---------|------------|
| Sin push notifications reales | Requiere Firebase Cloud Messaging y certificados | El esquema `push_token` en User está listo; solo falta integrar FCM |
| Sin OAuth real | Requiere SDK de Google/Facebook/Apple | El endpoint `/auth/oauth` está implementado; solo falta integrar el SDK del lado del cliente |
| Sin PDF de reportes | Requiere `expo-print` o servicio externo | Implementamos compartir como texto formateado con `Share` nativo |
| BD no escala a millones | SQLite no es para producción | Cambiar `DATABASE_URL` a PostgreSQL — toda la app sigue funcionando |
| Simulación, no GPS real | Cumple el requisito del hackathon | Endpoint listo para reemplazar con telemetría real |
---
## 11. Sugerencias del brief que SÍ cumplimos
| Sugerencia del reto | Estado |
|--------------------|:------:|
| Consumo inteligente de APIs geográficas (geocodificación solo en registro, cálculos internos) | ✅ |
| Cálculo interno de distancias (Haversine en Python) | ✅ |
| Notificaciones asíncronas listas para FCM | ✅ (esquema preparado) |
| Arquitectura por capas (lógica/datos separados) | ✅ |
| API REST limpia | ✅ |
| Multiplataforma (iOS, Android, Web) | ✅ |
| Manejo eficiente de estado | ✅ (sin polling, refresh manual) |
| Indexación geoespacial básica | ✅ (assign_route por proximidad) |
| Autenticación JWT | ✅ |
| RBAC | ✅ (3 niveles) |
---
## 12. Métricas del proyecto
| Métrica | Valor |
|---------|------:|
| Archivos backend | 27 |
| Archivos frontend (src/) | 22 |
| Líneas de código backend | ~1,800 |
| Líneas de código frontend | ~3,500 |
| Endpoints REST | 28 |
| Modelos de datos | 6 |
| Pantallas | 14 (5 ciudadano + 3 empleado + 4 admin + 2 auth) |
| Componentes reusables | 2 (AppHeader, GoogleGIcon) |
| Datos en BD (seed_massive) | 202 usuarios · 302 domicilios · 280 reportes · 150 op-reportes · 62 camiones · 220 calificaciones |

193
docs/INSTALACION.md Normal file
View File

@@ -0,0 +1,193 @@
# Guía de instalación
> Paso a paso para correr Mi Ruta Limpia en macOS / Linux.
---
## 1. Requisitos previos
| Software | Versión | Verificar con |
|----------|---------|---------------|
| **Node.js** | 20 LTS (**no 22+**) | `node --version` |
| **Python** | 3.11 - 3.13 | `python3 --version` |
| **Xcode** (solo iOS) | 15+ | App Store |
| **Watchman** (opcional pero recomendado) | última | `brew install watchman` |
### Instalar Node 20 si tienes versión nueva
```bash
# Con nvm (recomendado)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
nvm install 20
nvm use 20
# O con Homebrew
brew install node@20
brew link --overwrite node@20
```
---
## 2. Backend
```bash
cd backend
# Crear entorno virtual
python3 -m venv venv
source venv/bin/activate
# Instalar dependencias
pip install -r requirements.txt
# Configurar variables de entorno
cp .env.example .env
# Edita .env si quieres cambiar SECRET_KEY (opcional para hackathon)
# Poblar BD con datos demo + masivos
python seed_massive.py
# Esto crea ~200 usuarios, 62 camiones, 280 reportes, etc.
```
### Arrancar el servidor
```bash
# Con --host 0.0.0.0 para que el simulador iOS lo alcance
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
Verifica que esté corriendo:
- Local: `curl http://localhost:8000/health`
- Docs OpenAPI: abre `http://localhost:8000/docs` en el navegador
---
## 3. Frontend
```bash
cd frontend
# Aumentar límite de file descriptors (macOS)
ulimit -n 65536
# Instalar dependencias
npm install
# Configurar IP del backend
# Edita: frontend/src/constants/config.js
# Cambia la línea:
# export const API_BASE_URL = 'http://10.82.68.125:8000/api/v1';
# por la IP de TU Mac (obténla con `ipconfig getifaddr en0`)
```
### Arrancar Metro y la app
```bash
npx expo start --clear
```
Cuando aparezca el menú:
- **`i`** → iOS Simulator
- **`a`** → Android Emulator
- **`w`** → Navegador web
- Escanea el **QR** con la app **Expo Go** (App Store / Play Store) si quieres usar tu celular físico
> ⚠️ Para celular físico, debes estar en la **misma red Wi-Fi** que la Mac.
---
## 4. Credenciales para probar
Después de `seed_massive.py`, usa cualquiera de estas:
| Rol | Email | Contraseña |
|-----|-------|-----------|
| 🧑 Ciudadano | `demo@celaya.gob.mx` | `Celaya2026` |
| 👮 Empleado | `empleado@celaya.gob.mx` | `Empleado2026` |
| 🛡️ Admin | `admin@celaya.gob.mx` | `Admin2026` |
---
## 5. Troubleshooting
### "Credenciales inválidas" pero las escribí bien
- Verifica que el backend esté corriendo con `--host 0.0.0.0`
- Verifica que la IP en `config.js` coincida con tu IP local actual
- Reinicia Metro con `npx expo start --clear`
### "EMFILE: too many open files"
```bash
ulimit -n 65536
brew install watchman
```
### "URL.protocol is not implemented"
Verifica que `index.js` (en la raíz de `frontend/`) sea:
```js
import 'react-native-url-polyfill/auto';
import 'expo/AppEntry';
```
Y que `package.json` tenga `"main": "index.js"`.
### "main has not been registered"
Causa más común: un módulo falló al cargar silenciosamente. Reinicia con:
```bash
npx expo start --clear
```
### "Cannot find module .../something.types"
Tu Node.js es muy nuevo (22+). Cambia a Node 20 LTS:
```bash
nvm use 20
rm -rf node_modules package-lock.json
npm install
```
### Backend dice "passlib bcrypt error"
```bash
pip install 'bcrypt==4.0.1'
```
### El simulador no encuentra el servidor
Confirma la IP correcta con:
```bash
ipconfig getifaddr en0
```
Y actualiza `frontend/src/constants/config.js` con esa IP.
---
## 6. Reiniciar todo desde cero
Si algo se rompe completamente:
```bash
# Frontend
cd frontend
rm -rf node_modules package-lock.json
npm install
npx expo start --clear
# Backend
cd ../backend
source venv/bin/activate
rm -f mi_ruta_limpia.db # ¡Cuidado! Borra todos los datos
python seed_massive.py
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
---
## 7. Cómo configurar para presentar
### Pre-checklist (5 min antes del pitch):
- [ ] Backend corriendo con `--host 0.0.0.0`
- [ ] Frontend corriendo con `npx expo start`
- [ ] iOS Simulator abierto en iPhone 17 Pro (Cmd+R recargado)
- [ ] Probar login con `demo@celaya.gob.mx` → debe entrar a la vista ciudadana
- [ ] Probar login con `admin@celaya.gob.mx` → debe ver el dashboard con stats
- [ ] WiFi estable
- [ ] Pantalla de Mac compartida en el proyector
### Para la demo:
Sigue el script en [`docs/PRESENTACION.md`](PRESENTACION.md).

446
docs/MANUAL_USUARIO.md Normal file
View File

@@ -0,0 +1,446 @@
# Manual de usuario
> Guía visual de Mi Ruta Limpia para los tres perfiles: ciudadano, empleado operativo y administrador.
---
## 1. Antes de empezar
### ¿Cómo se entra a la app?
1. Abre **Mi Ruta Limpia** desde el ícono en tu teléfono
2. En la pantalla de inicio de sesión, escribe tu **correo** y tu **contraseña**
3. Toca **Iniciar sesión**
> 💡 También puedes entrar con tu cuenta de **Google** o **Facebook** si las vinculaste al registrarte.
### ¿No tienes cuenta?
Toca **"¿No tienes cuenta? Regístrate"** en la pantalla de login.
Necesitarás:
- Tu nombre completo
- Un correo o teléfono
- Una contraseña segura (mínimo 6 caracteres)
### ¿Olvidaste tu contraseña?
Toca **"¿Olvidaste tu contraseña?"** y sigue las instrucciones que llegarán a tu correo.
---
## 2. Vista CIUDADANO 🧑
Después de iniciar sesión, verás 5 pestañas en la parte inferior:
```
[ Inicio ] [ Horarios ] [ 🌱 ] [ Reportes ] [ Perfil ]
```
### 2.1 Pestaña INICIO
Es la pantalla principal. Muestra:
- **Cabecera guinda** con "Mi Ruta Limpia"
- **Logo del Gobierno de Celaya** centrado
- **Selector de domicilio** (chips horizontales): toca el lugar del que quieres ver información
- **Tarjeta de ETA**: tu próxima recolección
- Mensaje con la ventana horaria (ej. *"Entre las 7:20 y 7:35"*)
- Si faltan menos de 30 min, también muestra los minutos exactos
- Barra de progreso de la ruta
- Ícono de camión
- **Botón "¿Ya pasó la basura? Sí"**: tócalo cuando confirmes que pasó
#### Al confirmar que pasó:
1. Aparece un **modal de calificación** (1-5 estrellas) con opción de comentario
2. Al enviar, la tarjeta cambia a verde con **"Ruta completada para hoy"**
3. Aparece un enlace **"↩ Deshacer confirmación"** por si te equivocaste
> ⚠️ Mensaje de seguridad: *"Por tu seguridad, no persigas al camión. ¡Saca tu basura con tiempo!"*
#### Generar reporte rápido
Más abajo hay un botón **"Generación de reportes..."** que te lleva a la pestaña Reportes.
#### Horarios de recolección
Al final ves el horario aproximado de tu domicilio (días y rango horario).
---
### 2.2 Pestaña HORARIOS
Muestra:
- **Hero guinda** con el día actual: *"Sábado 23 de mayo"*
- **Tarjeta por cada uno de tus domicilios** con:
- Etiqueta (Casa, Trabajo, etc.) y dirección
- Badge "Principal" si es tu domicilio default
- Días de la semana de recolección (Lunes · Miércoles · Viernes)
- Horario aproximado (06:00 - 08:00)
- Nombre de la ruta asignada
- **Sección "Recuerda"** con 4 buenas prácticas:
- Saca tu basura máximo 30 min antes
- Separa orgánicos, reciclables y sanitarios
- En días festivos el servicio puede modificarse
- No persigas al camión
---
### 2.3 Pestaña EDUCACIÓN (la hojita verde central)
La pestaña más rica en contenido. Tiene:
#### Hero con foto eco
Imagen borrosa de naturaleza con mensaje *"Tu basura, nuestro futuro"*
#### Por qué importa
4 tarjetas explicando el impacto:
- 🌍 **Ambiental** — separar evita metano en rellenos
- ❤️ **Salud pública** — evita fauna nociva y enfermedades
- 💰 **Económico** — Celaya ahorra hasta $1,400 MXN por tonelada reciclada
- 🏛️ **Celaya limpio** — calles limpias y vecinos saludables
#### Tipos de residuos
4 categorías que puedes tocar para ver detalle:
| Categoría | Color | Bolsa |
|-----------|-------|-------|
| 🌱 **Orgánicos** | Verde | Bolsa verde |
| ♻️ **Reciclables** | Azul | Bolsa azul/transparente |
| 🩹 **Sanitarios** | Naranja | Bolsa roja/negra |
| ⚡ **Especiales/RAEE** | Morado | Punto de acopio |
Al tocar una categoría se abre un **modal con detalle**:
- Descripción larga
- ✅ Lista de "Sí va aquí"
- ❌ Lista de "No va aquí"
- 💡 Consejo práctico
- 📊 Dato de impacto
#### Datos que impactan
4 estadísticas con números fuertes:
- 60% es orgánico
- 30% es reciclable
- 450 años tarda una botella en degradarse
- 17 árboles se salvan por tonelada de papel reciclado
#### Tips prácticos
5 consejos para implementar hoy:
1. En casa: ten 2 botes mínimo
2. Enjuaga envases primero
3. Saca a tiempo (no antes, no después)
4. No mezcles (pilas, medicamentos a puntos de acopio)
5. Composta casera si tienes patio
#### Puntos de acopio en Celaya
Lista con lugares reales para:
- 🔋 Pilas y baterías (Walmart, Soriana, Liverpool)
- 📱 Electrónicos (Office Depot, Best Buy)
- 💊 Medicamentos (SINGREM en farmacias)
- 🛢️ Aceite de cocina (Punto Limpio Municipal)
#### Preguntas frecuentes
7 preguntas plegables (toca para abrir).
#### Compromiso final
Banner guinda: *"Si todos los hogares de Celaya separaran su basura, reduciríamos el relleno sanitario en un 70%"*
---
### 2.4 Pestaña REPORTES
Para levantar quejas formales con folio oficial.
#### Pantalla principal
- **Hero guinda con foto borrosa** y mensaje motivador
- **Estadísticas rápidas**: número de pendientes, en proceso, resueltos
- **Botón "Levantar un reporte nuevo"** destacado
- **Historial filtrable**: Todos / Pendientes / En proceso / Resueltos
#### Crear un reporte (3 pasos)
1. **Domicilio afectado** (chips para elegir)
2. **Tipo de incidencia** (4 opciones):
- 🚛 El camión no pasó
- ⏰ Retraso en la ruta
- 🗑️ Acumulación de basura
- … Otro
3. **Descripción** (opcional, 500 caracteres máx)
Al enviar, recibes un **folio único** tipo `MRL-20260523-A4B5C6`.
#### Confirmación
Aparece una pantalla verde con:
- ✓ "Reporte enviado con éxito"
- Folio destacado
- Estado (PENDIENTE)
- Tipo de incidencia
- Domicilio
- Fecha
- Tu descripción
- Botón **"Compartir reporte"** que abre el menú nativo (WhatsApp, correo, copiar, etc.)
#### Ver reportes anteriores
- Toca cualquier reporte de la lista para ver el detalle
- Puedes compartirlos cuando quieras desde el detalle
---
### 2.5 Pestaña PERFIL
Para gestionar tus datos y domicilios.
#### Hero guinda con tu identidad
- Avatar circular con tu inicial
- Tu nombre y correo
#### Domicilios
- Lista de tus lugares registrados
- Cada uno con etiqueta, dirección y badge "Principal" si aplica
- **Botón ⭐** para marcar como principal
- **Botón 🗑️** para eliminar
- **Botón "Agregar"** para añadir uno nuevo
#### Agregar domicilio
Modal con:
- Etiqueta (Casa, Trabajo, Familia, Otro)
- Calle y número
- Colonia
- Coordenadas (opcional, para asignar ruta automáticamente)
#### Configuración
Tres opciones (próximamente conectadas):
- 🔔 Notificaciones
- 🛡️ Privacidad
- ❓ Ayuda y soporte
#### Cerrar sesión
Botón rojo al final.
---
## 3. Vista EMPLEADO 👮
Después de iniciar sesión con una cuenta `EMPLEADO`, verás solo **3 pestañas**:
```
[ Inicio ] [ Reportes ] [ Cuenta ]
```
> ❌ Los empleados **NO** pueden ver ni gestionar reportes ciudadanos. Solo pueden levantar reportes operativos sobre el funcionamiento del camión y la ruta.
### 3.1 Pestaña INICIO (Dashboard del empleado)
#### Hero guinda con tu identidad
- Avatar con casco
- "¡Buen turno!" y tu nombre
- Badge con tu desempeño: **EXCELENTE / MUY BUENO / BUENO**
#### Racha de puntualidad
Tarjeta naranja con:
- 🔥 Días consecutivos puntuales (ej. *"19 días seguidos"*)
- Mensaje motivador: *"¡Sigue así! No rompas la racha."*
#### KPIs
Dos tarjetas:
- 🎯 **Puntualidad mensual** (porcentaje)
- 💰 **Bono acumulado** (en pesos)
#### Próximo bono
Tarjeta amarilla destacada:
- *"Te faltan **N días** puntuales para desbloquear **+$500 MXN**"*
- Trofeo dorado
#### Tu horario
Tarjeta con:
- Nombre del turno (Turno Matutino)
- Horario completo (05:30 → 09:00)
- Bloque de ruta destacado (06:00 - 08:00)
- **Descansos preestablecidos**:
- ☕ Descanso técnico: 07:00 · 10 min
- 🧘 Pausa estiramiento: 08:00 · 5 min
- Días de la semana (Lunes a sábado) y descanso (Domingo)
- Nota motivacional sobre puntualidad
#### Mensaje motivacional del día
Tarjeta verde con una frase rotativa:
- *"Tu puntualidad permite que miles de familias planifiquen su día"*
- *"Cada salida a tiempo es un acto de servicio que transforma a Celaya"*
- *"Eres la cara del Gobierno de Celaya en cada colonia"*
#### Por qué importa tu puntualidad
4 cards explicando el impacto cívico:
- Cientos de vecinos planifican su día
- Evita basura en la calle y fauna nociva
- Hace más confiables las predicciones de la app
- Eres la imagen del Gobierno de Celaya
---
### 3.2 Pestaña REPORTES (operativos)
Para reportar incidentes con el camión o la ruta.
#### Pantalla principal
- Hero guinda con mensaje
- Botón "Nuevo reporte operativo"
- Lista de tus reportes anteriores con: folio, categoría, severidad, ruta, fecha y estado
#### Crear un reporte operativo (4 pasos)
**1. ¿Qué pasó?** Grid de 8 categorías con icono:
- 🔧 No arrancó (camión)
- ⚙️ Falla mecánica
- 🚨 Accidente vial
- 🚧 Obstáculo bloqueando ruta
- 🚦 Tráfico intenso
- ⛽ Combustible bajo
- 🌧️ Clima adverso
- ··· Otro
**2. Severidad**:
- 🟢 Baja
- 🟡 Media
- 🔴 Alta
**3. Ruta y unidad** (opcional)
- RUTA-XX
- Número de unidad
**4. Descripción** (500 caracteres)
Al enviar, recibes un **folio operativo** tipo `OP-20260523-CC35B3`.
#### Compartir reporte
Igual que los reportes ciudadanos: botón **"Compartir folio"** abre el menú nativo del sistema.
---
### 3.3 Pestaña CUENTA
Vista de perfil con:
- Tarjeta de identidad con badge "EMPLEADO OPERATIVO"
- **Tus permisos** (lista con ✓ verdes y ✗ grises):
- ✓ Ver dashboard y estadísticas (las tuyas)
- ✓ Gestionar reportes ciudadanos → **✗** (no permitido)
- ✓ Cambiar estado de incidencias → **✗** (no permitido)
- ✓ Gestionar usuarios y roles → **✗**
- **Sistema** (API conectada, BD SQLite, versión)
- Botón cerrar sesión
---
## 4. Vista ADMINISTRADOR 🛡️
Después de iniciar sesión con una cuenta `ADMIN`, verás **4 pestañas**:
```
[ Dashboard ] [ Reportes ] [ Usuarios ] [ Cuenta ]
```
### 4.1 Pestaña DASHBOARD
KPIs en vivo de toda la ciudad:
#### Encabezado
Logo de Celaya, tu nombre, badge "🛡️ Administrador"
#### Resumen general
6 tarjetas con números:
- 👥 Ciudadanos totales
- 🏠 Domicilios registrados
- 📋 Reportes totales
- ⏰ Reportes en últimas 24h
- 🚛 Rutas activas
- ⭐ Calificación promedio
#### Reportes por estado
Gráfica de barras con conteo y porcentaje:
- PENDIENTE (naranja)
- EN_PROCESO (azul)
- RESUELTO (verde)
- CERRADO (gris)
#### Reportes por tipo
Lista con conteo:
- ACUMULACION
- NO_PASO
- OTRO
- RETRASO
---
### 4.2 Pestaña REPORTES
Gestión completa de reportes ciudadanos.
#### Filtros (chips horizontales)
- Todos | Pendientes | En proceso | Resueltos | Cerrados
#### Lista de reportes
Cada tarjeta muestra:
- Folio
- Tipo (ej. "El camión no pasó")
- Badge de estado con color
- 👤 Nombre del ciudadano
- 📍 Domicilio + calle
- 🚛 Ruta asignada
- 💬 Descripción del ciudadano
- 📅 Fecha y hora
- **Botones de acción** (transición de estado):
- PENDIENTE → EN_PROCESO / CERRADO
- EN_PROCESO → RESUELTO / CERRADO
- RESUELTO → CERRADO
Al tocar un botón, el estado cambia inmediatamente y el ciudadano ve la actualización en su app.
---
### 4.3 Pestaña USUARIOS (solo ADMIN)
Para gestionar toda la base de usuarios.
#### Filtros por rol
- Todos | Ciudadanos | Empleados | Administradores
#### Lista de usuarios
Cada tarjeta muestra:
- Avatar con ícono según rol
- Nombre completo
- Email
- Badge con su rol
- 🏠 Total domicilios · 📋 Total reportes
#### Cambiar rol
Al tocar un usuario (que no sea tú mismo), aparece un modal:
- Su nombre y email
- 3 opciones de rol con su ícono y color
- Botón "Confirmar" para aplicar el cambio
- Confirmación con alerta nativa
> ⚠️ No puedes cambiar tu propio rol (medida de seguridad)
---
### 4.4 Pestaña CUENTA
Igual que la del empleado pero con badge **ADMINISTRADOR**:
- Todos los permisos en ✓ verde
- Sistema (API, BD, versión)
- Cerrar sesión
---
## 5. Recomendaciones finales
### Para ciudadanos
- 📅 Saca la basura **máximo 30 min antes** del horario indicado
- 🌱 Separa **orgánicos, reciclables y sanitarios** en bolsas distintas
- 🔋 Lleva pilas, electrónicos y medicamentos a **puntos de acopio**
- 🚫 No persigas al camión ni saques basura fuera del horario
- 📝 Si hay un problema, **levanta un reporte**: ayuda a mejorar el servicio
### Para empleados
- ⏰ Sé puntual: cada minuto cuenta para los vecinos
- 🚧 Reporta cualquier incidencia (mecánica, vial, climática) al instante
- 🤝 Eres la cara del Gobierno de Celaya — trato amable hace la diferencia
### Para administradores
- 📊 Revisa el dashboard diariamente
- ⚡ Atiende los reportes pendientes en máximo 24-48h
- 👥 Gestiona los roles con cuidado, no degradas administradores por error

190
docs/PRESENTACION.md Normal file
View File

@@ -0,0 +1,190 @@
# Guion de presentación — 5 a 7 minutos
> Script para el pitch del hackathon. Cubre los puntos clave que los jueces evalúan.
---
## ⏱️ Distribución del tiempo
| Sección | Duración | Objetivo |
|---------|----------|----------|
| 1. Problema | 30s | Sembrar el dolor del usuario |
| 2. Solución | 30s | Decir qué construimos |
| 3. Demo ciudadano | 2 min | Mostrar el flujo principal |
| 4. Demo empleado | 1 min | RBAC + motivación |
| 5. Demo admin | 1 min | Gestión y métricas |
| 6. Arquitectura técnica | 1 min | Convencer en lo técnico |
| 7. Privacy by Design | 30s | Punto innegociable del reto |
| 8. Cierre | 30s | Llamado a la acción |
---
## 🎬 Guion completo
### 1. Problema (30 segundos)
> **"En Celaya, todos hemos sacado la basura demasiado temprano… o demasiado tarde. La basura termina en la calle, atrae fauna nociva, y los vecinos se molestan. La solución obvia sería ponerle un GPS al camión, pero eso es peligroso: la gente lo persigue, expone información operativa, y rompe la privacidad."**
Mostrar imagen o slide con el problema.
---
### 2. Solución (30 segundos)
> **"Mi Ruta Limpia: una app que te dice cuándo viene el camión a tu casa, sin nunca mostrarte dónde está. Privacy by Design, multiplataforma, con folio oficial en cada reporte, y tres vistas: ciudadano, empleado operativo y administrador."**
Mostrar la pantalla principal en el simulador.
---
### 3. Demo CIUDADANO (2 minutos)
**Login:**
- *"Entro como `demo@celaya.gob.mx`."*
**Inicio:**
- *"Esta es mi pantalla principal. Veo mi domicilio asignado a la **RUTA-01 (Zona Centro)**, y el camión llegará entre las 7:20 y 7:35. Si me faltan menos de 30 minutos, me muestra los minutos exactos."*
- *"Atrás de esta sencilla pantalla hay un cálculo geo-espacial: cuando registro mi domicilio, el backend usa **distancia Haversine** para asignarme automáticamente la ruta más cercana."*
- *"Importante: NO veo dónde está el camión. Solo veo MI ventana horaria. Esto es privacy by design real."*
**Confirmar ya pasó:**
- *"Cuando el camión pasa, confirmo con esta calificación. La tarjeta se pone verde. Si me equivoqué, tengo un **botón de deshacer**."*
**Educación:**
- *"Toco la hojita y entro a la sección educativa: 4 categorías de residuos, datos de impacto, puntos de acopio reales en Celaya y un FAQ extenso. Cada categoría tiene un modal con qué SÍ y qué NO va, y un dato de impacto."*
**Reportes:**
- *"Si el camión no pasó, levanto un reporte. Recibo un **folio oficial** tipo `MRL-20260523-A4B5C6` y puedo **compartirlo por WhatsApp, correo, o lo que sea** — porque es mi comprobante."*
---
### 4. Demo EMPLEADO (1 minuto)
**Logout y login como empleado:**
- *"Cierro sesión y entro como empleado: `empleado@celaya.gob.mx`. **El navegador cambia: ahora solo tengo 3 pestañas**."*
**Dashboard del empleado:**
- *"Esta es la vista del operador del camión. Aquí está el corazón motivacional: **mi racha de puntualidad** — 19 días seguidos. **Mi bono acumulado** — $950 MXN. Me faltan 11 días para desbloquear el siguiente bono de $500."*
- *"También veo mi horario con descansos preestablecidos: 5 min de estiramiento a las 8 AM. Esto es bienestar laboral integrado."*
- *"Y arriba hay una **frase motivacional que rota cada día**: 'Tu puntualidad permite que miles de familias planifiquen su día.'"*
**Reportes operativos:**
- *"Si algo pasa en mi turno — falla mecánica, accidente, tráfico — lo reporto. Tengo **8 categorías** con iconos. Esto le permite al equipo central reaccionar inmediatamente."*
**RBAC en acción:**
- *"Y muy importante: **el empleado NO puede ver los reportes ciudadanos**. Está en su pantalla de Cuenta: hay permisos en gris bloqueados. Si intento llamar a `/admin/reports` desde mi token de empleado, el backend responde **403 Forbidden**."*
---
### 5. Demo ADMIN (1 minuto)
**Login admin:**
- *"Cierro sesión, entro como admin: `admin@celaya.gob.mx`. Ahora tengo 4 pestañas."*
**Dashboard:**
- *"KPIs en vivo de toda la ciudad: 185 ciudadanos activos, 302 domicilios, 280 reportes totales, 8 en las últimas 24h, calificación promedio 4.07/5."*
- *"Gráficas por estado y por tipo de reporte. Veo de un vistazo que hay 84 pendientes."*
**Gestión de reportes:**
- *"Aquí veo TODOS los reportes ciudadanos de la ciudad. Filtro por estado. Puedo cambiar el estado con un botón: **PENDIENTE → EN_PROCESO → RESUELTO**. Esto cierra el loop con el ciudadano, que ve la actualización en su app."*
**Gestión de usuarios:**
- *"Y solo el admin puede ver esta pestaña: 202 usuarios, filtrables por rol. Puedo promover a alguien a empleado o demovrer si es necesario."*
---
### 6. Arquitectura técnica (1 minuto)
> **"Backend en FastAPI con SQLAlchemy. JWT con RBAC de 3 niveles. SQLite para el hackathon, pero migración a PostgreSQL es trivial."**
> **"Frontend en React Native con Expo, multiplataforma — corre en iOS, Android y Web con un solo código."**
> **"La simulación de eventos es elegante: no usamos cron jobs ni websockets. Los timestamps del JSON son templates que se reinterpretan cada día, y el ETA se calcula on-demand con interpolación temporal. Cero infraestructura, máxima escalabilidad."**
> **"Tenemos 28 endpoints REST documentados en OpenAPI."**
(Opcional: mostrar `localhost:8000/docs` con Swagger)
---
### 7. Privacy by Design (30 segundos)
> **"El reto fue tajante: PROHIBIDO el seguidor activo, PROHIBIDO el snooping. Lo cumplimos así:"**
> **"Primero, el endpoint `/eta/address/:id` **nunca** devuelve coordenadas del camión. Solo: status, mensaje, ventana, progreso porcentual."**
> **"Segundo, el backend valida en cada petición que el `address_id` pertenezca al usuario autenticado. Si intento consultar el domicilio de otro, **404**."**
> **"Tercero, la interfaz tiene mensajes preventivos: 'Por tu seguridad, no persigas al camión'."**
> **"Privacy by Design no es un slogan, es nuestra arquitectura."**
---
### 8. Cierre (30 segundos)
> **"Mi Ruta Limpia es: una app que **resuelve el problema real** del ciudadano sin romper la privacidad."**
> **"Tiene **arquitectura productiva** lista para escalar."**
> **"Da **dignidad al trabajador** con motivación y bonos, no solo control."**
> **"Y le da al gobierno **datos accionables** para gestionar la ciudad."**
> **"Es lo que Celaya necesita. Gracias."**
---
## 🎯 Posibles preguntas de los jueces
### "¿Cómo escalan a 50,000 usuarios?"
- Cambiar SQLite por PostgreSQL (solo cambia `DATABASE_URL`)
- Containerizar con Docker, desplegar en AWS ECS / Cloud Run
- Cache con Redis para `/eta/*` (ya está stateless)
- Notificaciones via Firebase Cloud Messaging (esquema `push_token` ya en User)
- CDN para el bundle de Expo Web
### "¿Cómo manejan días festivos?"
- El simulador permite ajustar las rutas por fecha
- Endpoint admin podría exponer `/admin/holidays` para excluir días
- La notificación push avisaría al ciudadano
### "¿Cómo validan que la ruta es la correcta para mi casa?"
- El algoritmo `assign_route` usa Haversine con O(120) waypoints; en producción se usaría PostGIS con polígonos reales de zonas de cobertura
### "¿Y si tengo Android?"
- React Native + Expo compila a iOS, Android y Web con un solo código
- La demo es en iOS pero funciona idéntico en Android
### "¿OAuth real?"
- El endpoint `/auth/oauth` ya recibe datos del provider; falta integrar el SDK del cliente. Es 1-2 horas de trabajo.
### "¿Por qué SQLite y no Postgres?"
- Hackathon: zero-config, portable. Para producción, cambio trivial.
### "¿La simulación es real?"
- Es un mock realista que cumple el requisito del reto. En producción se reemplaza el `eta_service.get_eta()` para leer de una tabla de telemetría real, sin cambiar nada del cliente.
### "¿Mostraron el código?"
- Todo el código está en estructura limpia: 27 archivos backend, 22 archivos frontend src, todo en GitHub (o donde lo tengas).
---
## 📝 Notas para el presentador
### Antes de iniciar:
1. Verifica que backend y frontend estén corriendo
2. Cierra cualquier app que muestre notificaciones en pantalla
3. Limpia las pestañas de Safari/Chrome (que no se vea historial sensible)
4. Pon el simulador en modo de "Demo" si quieres ocultar status bar
### Durante la demo:
- **No tartamudees**: si no sabes algo, di "buena pregunta, eso lo cubrimos en la documentación técnica"
- **Mira a los jueces**, no al simulador
- **Habla en futuro presente**: "esta app permite", no "esta app permitirá"
- **Energía**: convierte el problema en algo emocional ("todos hemos sacado la basura")
### Si algo falla:
- "El simulador se está reiniciando, mientras tanto les muestro el código..."
- Ten capturas de pantalla en backup por si el simulador muere
- Ten el OpenAPI docs (`/docs`) abierto en una pestaña como respaldo

BIN
frontend/.DS_Store vendored Normal file

Binary file not shown.

8
frontend/.expo/README.md Normal file
View File

@@ -0,0 +1,8 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.

View File

@@ -0,0 +1,313 @@
{"_e":"root:init","_t":1779498450873,"format":"v0-jsonl","version":"56.1.10"}
{"_e":"env:mode","_t":1779498450874,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1779498450875,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1779498451064,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1779498451274,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1779498451628,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"root:init","_t":1779498480269,"format":"v0-jsonl","version":"56.1.10"}
{"_e":"env:mode","_t":1779498480269,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1779498480270,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1779498480441,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1779498480644,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1779498480988,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"root:init","_t":1779498571142,"format":"v0-jsonl","version":"56.1.10"}
{"_e":"env:mode","_t":1779498571142,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1779498571144,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1779498571306,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1779498571520,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1779498571831,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"metro:bundling:started","_t":1779498575782,"id":"1","platform":"web","environment":null,"entry":"App.js"}
{"_e":"metro:bundling:progress","_t":1779498575783,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1779498575787,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1779498576163,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:progress","_t":1779498576264,"id":"1","progress":0.04000000000000001,"total":7,"current":2}
{"_e":"metro:bundling:progress","_t":1779498576449,"id":"1","progress":0.09,"total":7,"current":3}
{"_e":"metro:bundling:progress","_t":1779498576549,"id":"1","progress":0.09,"total":37,"current":11}
{"_e":"metro:bundling:progress","_t":1779498576649,"id":"1","progress":0.10053539559785842,"total":82,"current":26}
{"_e":"metro:bundling:progress","_t":1779498576750,"id":"1","progress":0.13959666706919455,"total":91,"current":34}
{"_e":"metro:bundling:progress","_t":1779498576853,"id":"1","progress":0.13959666706919455,"total":169,"current":45}
{"_e":"metro:bundling:progress","_t":1779498576953,"id":"1","progress":0.13959666706919455,"total":187,"current":55}
{"_e":"metro:bundling:progress","_t":1779498577053,"id":"1","progress":0.186082276047674,"total":204,"current":88}
{"_e":"metro:bundling:progress","_t":1779498577153,"id":"1","progress":0.2918483287092086,"total":261,"current":141}
{"_e":"metro:bundling:progress","_t":1779498577253,"id":"1","progress":0.32562672171023854,"total":361,"current":206}
{"_e":"metro:bundling:progress","_t":1779498577355,"id":"1","progress":0.3771404844290658,"total":425,"current":261}
{"_e":"metro:bundling:progress","_t":1779498577455,"id":"1","progress":0.4369434070669348,"total":472,"current":312}
{"_e":"metro:bundling:progress","_t":1779498577555,"id":"1","progress":0.5147127923181032,"total":499,"current":358}
{"_e":"metro:bundling:progress","_t":1779498577655,"id":"1","progress":0.5401697382554129,"total":532,"current":391}
{"_e":"metro:bundling:progress","_t":1779498577755,"id":"1","progress":0.599014417495888,"total":553,"current":428}
{"_e":"metro:bundling:progress","_t":1779498577855,"id":"1","progress":0.6297536940171381,"total":591,"current":469}
{"_e":"metro:bundling:progress","_t":1779498577955,"id":"1","progress":0.6867623460157702,"total":613,"current":508}
{"_e":"metro:bundling:progress","_t":1779498578055,"id":"1","progress":0.8346649599999999,"total":625,"current":571}
{"_e":"metro:bundling:progress","_t":1779498578158,"id":"1","progress":0.8346649599999999,"total":695,"current":607}
{"_e":"metro:bundling:progress","_t":1779498578258,"id":"1","progress":0.8346649599999999,"total":734,"current":654}
{"_e":"metro:bundling:progress","_t":1779498578359,"id":"1","progress":0.9362818464925233,"total":772,"current":747}
{"_e":"metro:bundling:progress","_t":1779498578460,"id":"1","progress":0.9975170711006519,"total":805,"current":804}
{"_e":"metro:bundling:progress","_t":1779498578635,"id":"1","progress":0.999,"total":805,"current":805}
{"_e":"metro:bundling:done","_t":1779498578740,"id":"1","total":805,"ms":2958.067125}
{"_e":"metro:bundling:started","_t":1779498582846,"id":"2","platform":"web","environment":null,"entry":"App.js"}
{"_e":"metro:bundling:progress","_t":1779498582847,"id":"2","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1779498582935,"id":"2","total":1,"ms":88.564792}
{"_e":"root:init","_t":1779498595606,"format":"v0-jsonl","version":"56.1.10"}
{"_e":"env:mode","_t":1779498595606,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1779498595607,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1779498595763,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1779498595954,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1779498596274,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"metro:bundling:started","_t":1779498918363,"id":"1","platform":"ios","environment":null,"entry":"App.js"}
{"_e":"metro:bundling:progress","_t":1779498918364,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1779498918371,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1779498919218,"id":"1","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:progress","_t":1779498919319,"id":"1","progress":0.04000000000000001,"total":9,"current":2}
{"_e":"metro:bundling:progress","_t":1779498919440,"id":"1","progress":0.09,"total":9,"current":3}
{"_e":"metro:bundling:progress","_t":1779498919541,"id":"1","progress":0.16000000000000003,"total":9,"current":4}
{"_e":"metro:bundling:progress","_t":1779498919762,"id":"1","progress":0.25,"total":9,"current":5}
{"_e":"metro:bundling:progress","_t":1779498919869,"id":"1","progress":0.25,"total":46,"current":12}
{"_e":"metro:bundling:progress","_t":1779498919973,"id":"1","progress":0.25,"total":60,"current":19}
{"_e":"metro:bundling:progress","_t":1779498920284,"id":"1","progress":0.25,"total":60,"current":20}
{"_e":"metro:bundling:progress","_t":1779498920384,"id":"1","progress":0.25,"total":67,"current":24}
{"_e":"metro:bundling:progress","_t":1779498920487,"id":"1","progress":0.25,"total":67,"current":25}
{"_e":"metro:bundling:progress","_t":1779498920584,"id":"1","progress":0.25,"total":67,"current":26}
{"_e":"metro:bundling:progress","_t":1779498920683,"id":"1","progress":0.25,"total":67,"current":29}
{"_e":"metro:bundling:progress","_t":1779498920784,"id":"1","progress":0.25,"total":70,"current":33}
{"_e":"metro:bundling:progress","_t":1779498920841,"id":"1","progress":0.25,"total":70,"current":34}
{"_e":"metro:bundling:progress","_t":1779498920944,"id":"1","progress":0.27097016325764683,"total":73,"current":38}
{"_e":"metro:bundling:progress","_t":1779498921049,"id":"1","progress":0.2762984878369494,"total":78,"current":41}
{"_e":"metro:bundling:progress","_t":1779498921150,"id":"1","progress":0.3477975016436555,"total":78,"current":46}
{"_e":"metro:bundling:progress","_t":1779498921258,"id":"1","progress":0.3477975016436555,"total":88,"current":50}
{"_e":"metro:bundling:progress","_t":1779498921358,"id":"1","progress":0.3477975016436555,"total":100,"current":52}
{"_e":"metro:bundling:progress","_t":1779498921483,"id":"1","progress":0.3477975016436555,"total":106,"current":54}
{"_e":"metro:bundling:progress","_t":1779498921585,"id":"1","progress":0.3477975016436555,"total":139,"current":56}
{"_e":"metro:bundling:progress","_t":1779498921720,"id":"1","progress":0.3477975016436555,"total":143,"current":58}
{"_e":"metro:bundling:progress","_t":1779498921821,"id":"1","progress":0.3477975016436555,"total":151,"current":60}
{"_e":"metro:bundling:progress","_t":1779498921983,"id":"1","progress":0.3477975016436555,"total":245,"current":69}
{"_e":"metro:bundling:progress","_t":1779498922083,"id":"1","progress":0.3477975016436555,"total":248,"current":75}
{"_e":"metro:bundling:progress","_t":1779498922183,"id":"1","progress":0.3477975016436555,"total":252,"current":91}
{"_e":"metro:bundling:progress","_t":1779498922284,"id":"1","progress":0.3477975016436555,"total":263,"current":106}
{"_e":"metro:bundling:progress","_t":1779498922390,"id":"1","progress":0.3477975016436555,"total":319,"current":129}
{"_e":"metro:bundling:progress","_t":1779498922490,"id":"1","progress":0.3477975016436555,"total":342,"current":146}
{"_e":"metro:bundling:progress","_t":1779498922589,"id":"1","progress":0.3477975016436555,"total":348,"current":153}
{"_e":"metro:bundling:progress","_t":1779498922690,"id":"1","progress":0.3477975016436555,"total":362,"current":162}
{"_e":"metro:bundling:progress","_t":1779498922793,"id":"1","progress":0.3477975016436555,"total":365,"current":167}
{"_e":"metro:bundling:progress","_t":1779498922895,"id":"1","progress":0.3477975016436555,"total":376,"current":171}
{"_e":"metro:bundling:progress","_t":1779498923065,"id":"1","progress":0.3477975016436555,"total":380,"current":173}
{"_e":"metro:bundling:progress","_t":1779498923082,"id":"1","progress":0.3477975016436555,"total":380,"current":174}
{"_e":"metro:bundling:progress","_t":1779498923183,"id":"1","progress":0.3477975016436555,"total":400,"current":182}
{"_e":"metro:bundling:progress","_t":1779498923284,"id":"1","progress":0.3477975016436555,"total":409,"current":188}
{"_e":"metro:bundling:progress","_t":1779498923385,"id":"1","progress":0.3477975016436555,"total":419,"current":201}
{"_e":"metro:bundling:progress","_t":1779498923491,"id":"1","progress":0.3477975016436555,"total":434,"current":214}
{"_e":"metro:bundling:progress","_t":1779498923592,"id":"1","progress":0.3477975016436555,"total":446,"current":232}
{"_e":"metro:bundling:progress","_t":1779498923692,"id":"1","progress":0.3477975016436555,"total":453,"current":241}
{"_e":"metro:bundling:progress","_t":1779498923792,"id":"1","progress":0.3477975016436555,"total":480,"current":262}
{"_e":"metro:bundling:progress","_t":1779498923892,"id":"1","progress":0.3477975016436555,"total":483,"current":282}
{"_e":"metro:bundling:progress","_t":1779498923993,"id":"1","progress":0.3604919712442891,"total":488,"current":293}
{"_e":"metro:bundling:progress","_t":1779498924095,"id":"1","progress":0.362926817226156,"total":493,"current":297}
{"_e":"metro:bundling:progress","_t":1779498924196,"id":"1","progress":0.362926817226156,"total":509,"current":305}
{"_e":"metro:bundling:progress","_t":1779498924298,"id":"1","progress":0.37741496905234057,"total":516,"current":317}
{"_e":"metro:bundling:progress","_t":1779498924404,"id":"1","progress":0.39148556518811545,"total":545,"current":341}
{"_e":"metro:bundling:progress","_t":1779498924669,"id":"1","progress":0.40286911111183776,"total":553,"current":351}
{"_e":"metro:bundling:progress","_t":1779498924767,"id":"1","progress":0.40286911111183776,"total":571,"current":357}
{"_e":"metro:bundling:progress","_t":1779498924868,"id":"1","progress":0.406731570179092,"total":588,"current":375}
{"_e":"metro:bundling:progress","_t":1779498924989,"id":"1","progress":0.422281173122924,"total":594,"current":386}
{"_e":"metro:bundling:progress","_t":1779498925088,"id":"1","progress":0.4244719926538108,"total":594,"current":387}
{"_e":"metro:bundling:progress","_t":1779498925195,"id":"1","progress":0.4282708242173239,"total":599,"current":392}
{"_e":"metro:bundling:progress","_t":1779498925299,"id":"1","progress":0.42977732619787873,"total":601,"current":394}
{"_e":"metro:bundling:progress","_t":1779498925399,"id":"1","progress":0.42977732619787873,"total":622,"current":403}
{"_e":"metro:bundling:progress","_t":1779498925500,"id":"1","progress":0.42977732619787873,"total":634,"current":412}
{"_e":"metro:bundling:progress","_t":1779498925602,"id":"1","progress":0.4335214961933451,"total":647,"current":426}
{"_e":"metro:bundling:progress","_t":1779498925703,"id":"1","progress":0.434400826446281,"total":660,"current":435}
{"_e":"metro:bundling:progress","_t":1779498925806,"id":"1","progress":0.4379037765447964,"total":677,"current":448}
{"_e":"metro:bundling:progress","_t":1779498925909,"id":"1","progress":0.4411920262123648,"total":682,"current":453}
{"_e":"metro:bundling:progress","_t":1779498926035,"id":"1","progress":0.4450963614004007,"total":682,"current":455}
{"_e":"metro:bundling:progress","_t":1779498926043,"id":"1","progress":0.4470549788873504,"total":682,"current":456}
{"_e":"metro:bundling:progress","_t":1779498926144,"id":"1","progress":0.4685610526911018,"total":691,"current":473}
{"_e":"metro:bundling:progress","_t":1779498926246,"id":"1","progress":0.4876232856374739,"total":706,"current":493}
{"_e":"metro:bundling:progress","_t":1779498926348,"id":"1","progress":0.49294146413027534,"total":715,"current":502}
{"_e":"metro:bundling:progress","_t":1779498926446,"id":"1","progress":0.5067846838476209,"total":715,"current":509}
{"_e":"metro:bundling:progress","_t":1779498926548,"id":"1","progress":0.5223800237812127,"total":725,"current":524}
{"_e":"metro:bundling:progress","_t":1779498926649,"id":"1","progress":0.5511937557392103,"total":726,"current":539}
{"_e":"metro:bundling:progress","_t":1779498926749,"id":"1","progress":0.5543773629489602,"total":736,"current":548}
{"_e":"metro:bundling:progress","_t":1779498926850,"id":"1","progress":0.5579667746884787,"total":743,"current":555}
{"_e":"metro:bundling:progress","_t":1779498926951,"id":"1","progress":0.5791433526856459,"total":749,"current":570}
{"_e":"metro:bundling:progress","_t":1779498927053,"id":"1","progress":0.591400015329741,"total":749,"current":576}
{"_e":"metro:bundling:progress","_t":1779498927161,"id":"1","progress":0.5942265690101312,"total":755,"current":582}
{"_e":"metro:bundling:progress","_t":1779498927265,"id":"1","progress":0.6145403163791203,"total":759,"current":595}
{"_e":"metro:bundling:progress","_t":1779498927366,"id":"1","progress":0.6274307479224377,"total":760,"current":602}
{"_e":"metro:bundling:progress","_t":1779498927478,"id":"1","progress":0.6280202745100018,"total":771,"current":611}
{"_e":"metro:bundling:progress","_t":1779498927578,"id":"1","progress":0.6728632376520373,"total":779,"current":639}
{"_e":"metro:bundling:progress","_t":1779498927678,"id":"1","progress":0.6972890679678966,"total":782,"current":653}
{"_e":"metro:bundling:progress","_t":1779498927778,"id":"1","progress":0.7325472201807778,"total":798,"current":683}
{"_e":"metro:bundling:progress","_t":1779498927879,"id":"1","progress":0.762122541320712,"total":811,"current":708}
{"_e":"metro:bundling:progress","_t":1779498927978,"id":"1","progress":0.803654377107624,"total":821,"current":736}
{"_e":"metro:bundling:progress","_t":1779498928080,"id":"1","progress":0.8232668801163112,"total":831,"current":754}
{"_e":"metro:bundling:progress","_t":1779498928180,"id":"1","progress":0.8559546041830051,"total":842,"current":779}
{"_e":"metro:bundling:progress","_t":1779498928281,"id":"1","progress":0.8559546041830051,"total":876,"current":805}
{"_e":"metro:bundling:progress","_t":1779498928380,"id":"1","progress":0.8559546041830051,"total":884,"current":816}
{"_e":"metro:bundling:progress","_t":1779498928480,"id":"1","progress":0.8655270799141522,"total":890,"current":828}
{"_e":"metro:bundling:progress","_t":1779498928580,"id":"1","progress":0.8691785302062944,"total":901,"current":840}
{"_e":"metro:bundling:progress","_t":1779498928687,"id":"1","progress":0.8767207963167578,"total":911,"current":853}
{"_e":"metro:bundling:progress","_t":1779498928787,"id":"1","progress":0.9048688329661275,"total":923,"current":878}
{"_e":"metro:bundling:progress","_t":1779498928911,"id":"1","progress":0.9136808100658022,"total":929,"current":888}
{"_e":"metro:bundling:progress","_t":1779498929011,"id":"1","progress":0.930875246066348,"total":938,"current":905}
{"_e":"metro:bundling:progress","_t":1779498929111,"id":"1","progress":0.9733208541083258,"total":968,"current":955}
{"_e":"metro:bundling:progress","_t":1779498929211,"id":"1","progress":0.9733208541083258,"total":1019,"current":992}
{"_e":"metro:bundling:progress","_t":1779498929317,"id":"1","progress":0.9733208541083258,"total":1042,"current":1007}
{"_e":"metro:bundling:progress","_t":1779498929713,"id":"1","progress":0.9733208541083258,"total":1055,"current":1025}
{"_e":"metro:bundling:progress","_t":1779498929778,"id":"1","progress":0.9733208541083258,"total":1055,"current":1026}
{"_e":"metro:bundling:progress","_t":1779498929887,"id":"1","progress":0.9733208541083258,"total":1057,"current":1029}
{"_e":"metro:bundling:progress","_t":1779498929987,"id":"1","progress":0.9733208541083258,"total":1074,"current":1043}
{"_e":"metro:bundling:progress","_t":1779498930089,"id":"1","progress":0.9733208541083258,"total":1076,"current":1050}
{"_e":"metro:bundling:progress","_t":1779498930189,"id":"1","progress":0.9733208541083258,"total":1083,"current":1061}
{"_e":"metro:bundling:progress","_t":1779498930289,"id":"1","progress":0.9798262863938499,"total":1085,"current":1074}
{"_e":"metro:bundling:progress","_t":1779498930393,"id":"1","progress":0.9945180550940067,"total":1093,"current":1090}
{"_e":"metro:bundling:progress","_t":1779498930491,"id":"1","progress":0.9981726819714648,"total":1094,"current":1093}
{"_e":"metro:bundling:progress","_t":1779498931232,"id":"1","progress":0.999,"total":1094,"current":1094}
{"_e":"metro:bundling:progress","_t":1779498931328,"id":"1","progress":0.999,"total":1102,"current":1096}
{"_e":"metro:bundling:progress","_t":1779498931481,"id":"1","progress":0.999,"total":1102,"current":1098}
{"_e":"metro:bundling:progress","_t":1779498931581,"id":"1","progress":0.999,"total":1104,"current":1104}
{"_e":"metro:bundling:done","_t":1779498931899,"id":"1","total":1104,"ms":13535.336208}
{"_e":"metro:client_log","_t":1779498933266,"level":"error","data":["[runtime not ready]: TypeError: Cannot read property 'S' of undefined"]}
{"_e":"metro:client_log","_t":1779498933272,"level":"error","data":["[runtime not ready]: Invariant Violation: \"main\" has not been registered. This can happen if:\n* Metro (the local dev server) is run from the wrong folder. Check if Metro is running, stop it and restart it in the current project.\n* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called."]}
{"_e":"metro:client_log","_t":1779499048412,"level":"error","data":["[runtime not ready]: Error: Non-js exception: AppRegistryBinding::stopSurface failed. Global was not installed."]}
{"_e":"metro:bundling:started","_t":1779499048799,"id":"2","platform":"ios","environment":null,"entry":"App.js"}
{"_e":"metro:bundling:progress","_t":1779499048799,"id":"2","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1779499048856,"id":"2","total":1,"ms":56.614916}
{"_e":"metro:client_log","_t":1779499049066,"level":"error","data":["[runtime not ready]: TypeError: Cannot read property 'S' of undefined"]}
{"_e":"metro:client_log","_t":1779499049066,"level":"error","data":["[runtime not ready]: Invariant Violation: \"main\" has not been registered. This can happen if:\n* Metro (the local dev server) is run from the wrong folder. Check if Metro is running, stop it and restart it in the current project.\n* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called."]}
{"_e":"metro:bundling:failed","_t":1779499106654,"id":null,"message":"Got unexpected undefined","importStack":null,"filename":null,"targetModuleName":null,"originModulePath":null}
{"_e":"metro:bundling:failed","_t":1779499107159,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499107552,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:client_log","_t":1779499107814,"level":"error","data":["[runtime not ready]: TypeError: Cannot read property 'S' of undefined"]}
{"_e":"metro:client_log","_t":1779499107815,"level":"error","data":["[runtime not ready]: TypeError: Cannot read property 'default' of undefined"]}
{"_e":"metro:bundling:failed","_t":1779499107871,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499108099,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499108366,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499108574,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499108797,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499109043,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499109301,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499109505,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499109732,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499109942,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499110131,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499110341,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499110542,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499110760,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499111008,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499111229,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"metro:bundling:failed","_t":1779499111437,"id":null,"message":"Unable to resolve \"react-native-gesture-handler\" from \"App.js\"\n> 1 | import 'react-native-gesture-handler';\n | ^\n 2 | import React from 'react';\n 3 | import { StatusBar } from 'expo-status-bar';\n 4 | import { GestureHandlerRootView } from 'react-native-gesture-handler';","importStack":"\u001b[1mImport stack:\u001b[22m\n\n\u001b[90m \u001b[39mApp.js\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"react-native-gesture-handler\"\u001b[39m\n\n\u001b[90m \u001b[39m\n\u001b[90m |\u001b[39m \u001b[36mimport\u001b[39m \u001b[32m\"./App\"\u001b[39m\n","filename":null,"targetModuleName":"react-native-gesture-handler","originModulePath":"App.js"}
{"_e":"root:init","_t":1779499145128,"format":"v0-jsonl","version":"56.1.10"}
{"_e":"env:mode","_t":1779499145129,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1779499145130,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1779499145390,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1779499145743,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1779499146247,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"metro:bundling:started","_t":1779499159106,"id":"2","platform":"ios","environment":null,"entry":"App.js"}
{"_e":"metro:bundling:progress","_t":1779499159107,"id":"2","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1779499159113,"id":"2","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1779499159475,"id":"2","progress":0.010000000000000002,"total":1,"current":1}
{"_e":"metro:bundling:progress","_t":1779499159579,"id":"2","progress":0.09,"total":9,"current":3}
{"_e":"metro:bundling:progress","_t":1779499159656,"id":"2","progress":0.16000000000000003,"total":9,"current":4}
{"_e":"metro:bundling:progress","_t":1779499159803,"id":"2","progress":0.25,"total":9,"current":5}
{"_e":"metro:bundling:progress","_t":1779499159903,"id":"2","progress":0.25,"total":26,"current":11}
{"_e":"metro:bundling:progress","_t":1779499160004,"id":"2","progress":0.25,"total":180,"current":18}
{"_e":"metro:bundling:progress","_t":1779499160079,"id":"2","progress":0.25,"total":180,"current":19}
{"_e":"metro:bundling:progress","_t":1779499160189,"id":"2","progress":0.25,"total":199,"current":40}
{"_e":"metro:bundling:progress","_t":1779499160292,"id":"2","progress":0.25,"total":219,"current":57}
{"_e":"metro:bundling:progress","_t":1779499160395,"id":"2","progress":0.25,"total":239,"current":70}
{"_e":"metro:bundling:progress","_t":1779499160496,"id":"2","progress":0.25,"total":250,"current":76}
{"_e":"metro:bundling:progress","_t":1779499160597,"id":"2","progress":0.25,"total":274,"current":93}
{"_e":"metro:bundling:progress","_t":1779499160698,"id":"2","progress":0.25,"total":300,"current":119}
{"_e":"metro:bundling:progress","_t":1779499160798,"id":"2","progress":0.25,"total":327,"current":153}
{"_e":"metro:bundling:progress","_t":1779499160903,"id":"2","progress":0.25,"total":401,"current":187}
{"_e":"metro:bundling:progress","_t":1779499161003,"id":"2","progress":0.2935046802786257,"total":443,"current":240}
{"_e":"metro:bundling:progress","_t":1779499161103,"id":"2","progress":0.33310709595543797,"total":499,"current":288}
{"_e":"metro:bundling:progress","_t":1779499161203,"id":"2","progress":0.3781801108210347,"total":548,"current":337}
{"_e":"metro:bundling:progress","_t":1779499161303,"id":"2","progress":0.3781801108210347,"total":644,"current":386}
{"_e":"metro:bundling:progress","_t":1779499161403,"id":"2","progress":0.41970375047744796,"total":673,"current":436}
{"_e":"metro:bundling:progress","_t":1779499161503,"id":"2","progress":0.46929459619439634,"total":689,"current":472}
{"_e":"metro:bundling:progress","_t":1779499161604,"id":"2","progress":0.5276208967516178,"total":709,"current":515}
{"_e":"metro:bundling:progress","_t":1779499161704,"id":"2","progress":0.5598659600689191,"total":711,"current":532}
{"_e":"metro:bundling:progress","_t":1779499161806,"id":"2","progress":0.6171916997143835,"total":723,"current":568}
{"_e":"metro:bundling:progress","_t":1779499161906,"id":"2","progress":0.7117719284224905,"total":742,"current":626}
{"_e":"metro:bundling:progress","_t":1779499162006,"id":"2","progress":0.7437689155746684,"total":785,"current":677}
{"_e":"metro:bundling:progress","_t":1779499162106,"id":"2","progress":0.7803508426257165,"total":806,"current":712}
{"_e":"metro:bundling:progress","_t":1779499162206,"id":"2","progress":0.7944707578407213,"total":819,"current":730}
{"_e":"metro:bundling:progress","_t":1779499162307,"id":"2","progress":0.7944707578407213,"total":839,"current":747}
{"_e":"metro:bundling:progress","_t":1779499162407,"id":"2","progress":0.8139862660781345,"total":859,"current":775}
{"_e":"metro:bundling:progress","_t":1779499162508,"id":"2","progress":0.8256821143372648,"total":865,"current":786}
{"_e":"metro:bundling:progress","_t":1779499162610,"id":"2","progress":0.831744,"total":875,"current":798}
{"_e":"metro:bundling:progress","_t":1779499162716,"id":"2","progress":0.8498208901241393,"total":883,"current":814}
{"_e":"metro:bundling:progress","_t":1779499162811,"id":"2","progress":0.8820763330898466,"total":888,"current":834}
{"_e":"metro:bundling:progress","_t":1779499162918,"id":"2","progress":0.9231486903864699,"total":893,"current":858}
{"_e":"metro:bundling:progress","_t":1779499163021,"id":"2","progress":0.9519196883076201,"total":904,"current":882}
{"_e":"metro:bundling:progress","_t":1779499163122,"id":"2","progress":0.9806560183608637,"total":926,"current":917}
{"_e":"metro:bundling:progress","_t":1779499163222,"id":"2","progress":0.9956989297148108,"total":929,"current":927}
{"_e":"metro:bundling:progress","_t":1779499163388,"id":"2","progress":0.9978483061639019,"total":929,"current":928}
{"_e":"metro:bundling:progress","_t":1779499163491,"id":"2","progress":0.9978483061639019,"total":951,"current":934}
{"_e":"metro:bundling:progress","_t":1779499163598,"id":"2","progress":0.9978483061639019,"total":990,"current":956}
{"_e":"metro:bundling:progress","_t":1779499163703,"id":"2","progress":0.9978483061639019,"total":1008,"current":977}
{"_e":"metro:bundling:progress","_t":1779499163809,"id":"2","progress":0.9978483061639019,"total":1022,"current":998}
{"_e":"metro:bundling:progress","_t":1779499163906,"id":"2","progress":0.9978483061639019,"total":1039,"current":1026}
{"_e":"metro:bundling:progress","_t":1779499164007,"id":"2","progress":0.9980961451247166,"total":1050,"current":1049}
{"_e":"metro:bundling:progress","_t":1779499164412,"id":"2","progress":0.999,"total":1050,"current":1050}
{"_e":"metro:bundling:progress","_t":1779499164512,"id":"2","progress":0.999,"total":1060,"current":1060}
{"_e":"metro:bundling:done","_t":1779499164771,"id":"2","total":1060,"ms":5664.647084}
{"_e":"metro:client_log","_t":1779499166873,"level":"error","data":["[runtime not ready]: TypeError: Cannot assign to property 'protocol' which has only a getter"]}
{"_e":"metro:client_log","_t":1779499166875,"level":"error","data":["[runtime not ready]: Invariant Violation: \"main\" has not been registered. This can happen if:\n* Metro (the local dev server) is run from the wrong folder. Check if Metro is running, stop it and restart it in the current project.\n* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called."]}
{"_e":"metro:client_log","_t":1779499201358,"level":"error","data":["[runtime not ready]: Error: Non-js exception: AppRegistryBinding::stopSurface failed. Global was not installed."]}
{"_e":"metro:bundling:started","_t":1779499201714,"id":"3","platform":"ios","environment":null,"entry":"App.js"}
{"_e":"metro:bundling:progress","_t":1779499201716,"id":"3","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:done","_t":1779499201836,"id":"3","total":1,"ms":122.120209}
{"_e":"metro:client_log","_t":1779499202480,"level":"error","data":["[runtime not ready]: Error: Cannot find native module 'ExpoFontLoader'"]}
{"_e":"metro:client_log","_t":1779499202481,"level":"error","data":["[runtime not ready]: Invariant Violation: \"main\" has not been registered. This can happen if:\n* Metro (the local dev server) is run from the wrong folder. Check if Metro is running, stop it and restart it in the current project.\n* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called."]}
{"_e":"root:init","_t":1779499339890,"format":"v0-jsonl","version":"56.1.10"}
{"_e":"env:mode","_t":1779499339890,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1779499339892,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1779499340081,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1779499340450,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1779499340900,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"metro:bundling:started","_t":1779499355956,"id":"1","platform":"ios","environment":null,"entry":"App.js"}
{"_e":"metro:bundling:progress","_t":1779499355957,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1779499355962,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1779499356062,"id":"1","progress":0.16987764791818846,"total":296,"current":122}
{"_e":"metro:bundling:progress","_t":1779499356162,"id":"1","progress":0.4097630735526796,"total":628,"current":402}
{"_e":"metro:bundling:progress","_t":1779499356268,"id":"1","progress":0.8318115569272978,"total":864,"current":788}
{"_e":"metro:bundling:done","_t":1779499356387,"id":"1","total":864,"ms":430.389541}
{"_e":"metro:client_log","_t":1779499356895,"level":"error","data":["[runtime not ready]: TypeError: Cannot assign to property 'protocol' which has only a getter"]}
{"_e":"metro:client_log","_t":1779499356896,"level":"error","data":["[runtime not ready]: Invariant Violation: \"main\" has not been registered. This can happen if:\n* Metro (the local dev server) is run from the wrong folder. Check if Metro is running, stop it and restart it in the current project.\n* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called."]}
{"_e":"root:init","_t":1779499367591,"format":"v0-jsonl","version":"56.1.10"}
{"_e":"env:mode","_t":1779499367591,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1779499367592,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1779499367760,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1779499367957,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1779499368380,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"root:init","_t":1779499832528,"format":"v0-jsonl","version":"56.1.10"}
{"_e":"env:mode","_t":1779499832528,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1779499832530,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1779499832714,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1779499832901,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1779499833326,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"root:init","_t":1779499852573,"format":"v0-jsonl","version":"56.1.10"}
{"_e":"env:mode","_t":1779499852574,"nodeEnv":"development","babelEnv":"development","mode":"development"}
{"_e":"env:load","_t":1779499852575,"mode":"development","files":[],"env":{}}
{"_e":"devserver:start","_t":1779499852753,"mode":"development","web":true,"baseUrl":"","asyncRoutes":false,"routerRoot":"../../../app","serverComponents":false,"serverActions":false,"serverRendering":false,"apiRoutes":false,"exporting":false}
{"_e":"metro:config","_t":1779499852940,"serverRoot":"../../..","projectRoot":"../../..","exporting":false,"flags":{"autolinkingModuleResolution":false,"serverActions":false,"serverComponents":false,"reactCompiler":false,"optimizeGraph":false,"treeshaking":false,"logbox":false}}
{"_e":"metro:instantiate","_t":1779499853324,"atlas":false,"workers":6,"host":"::","port":8081}
{"_e":"metro:bundling:started","_t":1779499895139,"id":"1","platform":"ios","environment":null,"entry":"App.js"}
{"_e":"metro:bundling:progress","_t":1779499895140,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1779499895149,"id":"1","progress":0,"total":1,"current":0}
{"_e":"metro:bundling:progress","_t":1779499895250,"id":"1","progress":0.13109393579072534,"total":116,"current":42}
{"_e":"metro:bundling:progress","_t":1779499895350,"id":"1","progress":0.13109393579072534,"total":211,"current":47}
{"_e":"metro:bundling:progress","_t":1779499895453,"id":"1","progress":0.13109393579072534,"total":240,"current":57}
{"_e":"metro:bundling:progress","_t":1779499895553,"id":"1","progress":0.19938373988389718,"total":374,"current":167}
{"_e":"metro:bundling:progress","_t":1779499895653,"id":"1","progress":0.34515625000000005,"total":480,"current":282}
{"_e":"metro:bundling:progress","_t":1779499895753,"id":"1","progress":0.3748438150770512,"total":637,"current":390}
{"_e":"metro:bundling:progress","_t":1779499895853,"id":"1","progress":0.5723432394438148,"total":727,"current":550}
{"_e":"metro:bundling:progress","_t":1779499895953,"id":"1","progress":0.8278375208025148,"total":832,"current":757}
{"_e":"metro:bundling:progress","_t":1779499896053,"id":"1","progress":0.8814296469489759,"total":883,"current":829}
{"_e":"metro:bundling:progress","_t":1779499896153,"id":"1","progress":0.9435638769052662,"total":1013,"current":984}
{"_e":"metro:bundling:progress","_t":1779499896254,"id":"1","progress":0.9981051638552593,"total":1055,"current":1054}
{"_e":"metro:bundling:progress","_t":1779499896610,"id":"1","progress":0.999,"total":1055,"current":1055}
{"_e":"metro:bundling:done","_t":1779499896688,"id":"1","total":1055,"ms":1548.241125}
{"_e":"metro:client_log","_t":1779499898247,"level":"error","data":["[runtime not ready]: TypeError: Cannot assign to property 'protocol' which has only a getter"]}
{"_e":"metro:client_log","_t":1779499898261,"level":"error","data":["[runtime not ready]: Invariant Violation: \"main\" has not been registered. This can happen if:\n* Metro (the local dev server) is run from the wrong folder. Check if Metro is running, stop it and restart it in the current project.\n* A module failed to load due to an error and `AppRegistry.registerComponent` wasn't called."]}

View File

@@ -0,0 +1,3 @@
{
"devices": []
}

17
frontend/App.full.js.bak Normal file
View File

@@ -0,0 +1,17 @@
import 'react-native-gesture-handler';
import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { AuthProvider } from './src/context/AuthContext';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<AuthProvider>
<StatusBar style="light" backgroundColor="#5A1020" />
<AppNavigator />
</AuthProvider>
</GestureHandlerRootView>
);
}

17
frontend/App.js Normal file
View File

@@ -0,0 +1,17 @@
import 'react-native-gesture-handler';
import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { AuthProvider } from './src/context/AuthContext';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<AuthProvider>
<StatusBar style="light" backgroundColor="#5A1020" />
<AppNavigator />
</AuthProvider>
</GestureHandlerRootView>
);
}

30
frontend/app.json Normal file
View File

@@ -0,0 +1,30 @@
{
"expo": {
"name": "Mi Ruta Limpia",
"slug": "mi-ruta-limpia",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#7B1A2E"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "mx.celaya.mirutalimpia"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#7B1A2E"
},
"package": "mx.celaya.mirutalimpia"
},
"web": {
"favicon": "./assets/favicon.png",
"bundler": "metro"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
frontend/assets/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
frontend/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 B

BIN
frontend/assets/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

6
frontend/babel.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

5
frontend/index.js Normal file
View File

@@ -0,0 +1,5 @@
// index.js — entry point que carga polyfills ANTES de inicializar Expo
// Esto es crítico porque expo/build/Expo.fx usa URL.protocol al cargarse,
// y necesitamos el polyfill completo aplicado antes que cualquier otra cosa.
import 'react-native-url-polyfill/auto';
import 'expo/AppEntry';

BIN
frontend/node_modules/.DS_Store generated vendored Normal file

Binary file not shown.

1
frontend/node_modules/.bin/acorn generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../acorn/bin/acorn

1
frontend/node_modules/.bin/baseline-browser-mapping generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../baseline-browser-mapping/dist/cli.cjs

1
frontend/node_modules/.bin/browserslist generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../browserslist/cli.js

1
frontend/node_modules/.bin/detect-libc generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../detect-libc/bin/detect-libc.js

1
frontend/node_modules/.bin/envinfo generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../envinfo/dist/cli.js

1
frontend/node_modules/.bin/esparse generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../esprima/bin/esparse.js

1
frontend/node_modules/.bin/esvalidate generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../esprima/bin/esvalidate.js

1
frontend/node_modules/.bin/excpretty generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../@expo/xcpretty/build/cli.js

1
frontend/node_modules/.bin/expo generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../expo/bin/cli

1
frontend/node_modules/.bin/expo-internal generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../@expo/cli/build/bin/cli

1
frontend/node_modules/.bin/expo-modules-autolinking generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../expo-modules-autolinking/bin/expo-modules-autolinking.js

1
frontend/node_modules/.bin/fxparser generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../fast-xml-parser/src/cli/cli.js

1
frontend/node_modules/.bin/image-size generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../image-size/bin/image-size.js

1
frontend/node_modules/.bin/is-docker generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../is-docker/cli.js

1
frontend/node_modules/.bin/js-yaml generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../js-yaml/bin/js-yaml.js

Some files were not shown because too many files have changed in this diff Show More