Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled

Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos,
inspiraciones, watchlist, tracking de precios y mapa interactivo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Martinez
2026-04-10 23:37:06 +02:00
commit b8906efc80
122 changed files with 37809 additions and 0 deletions

View File

@@ -0,0 +1,130 @@
# Plan: Sistema de cola de busquedas y seguimiento de precios
## Contexto
Vuelato tiene watchlist manual (check precio uno a uno) y busquedas recientes, pero no hay:
- Busquedas automaticas en segundo plano
- Historico de precios con graficos
- Cache de resultados entre usuarios
El usuario quiere un sistema completo: definir busquedas recurrentes desde la UI, ver fluctuaciones de precio en tablas/graficos, y ademas una capa de cache que evite llamadas duplicadas a Flightics (TTL 1h) reutilizable tanto por el worker como por busquedas manuales.
---
## 1. Esquema de base de datos
**Archivo: `supabase/migrations/00002_search_queue.sql`**
### Tabla `search_cache` — Cache de resultados (publica, sin RLS)
- `id` SERIAL PRIMARY KEY
- `params_hash` TEXT UNIQUE — SHA-256 del JSON de SearchParams normalizado
- `search_params` JSONB — el payload completo
- `trips` JSONB — array completo de trips devueltos
- `cheapest_price` NUMERIC(10,2)
- `total_results` INTEGER
- `fetched_at` TIMESTAMPTZ DEFAULT now()
### Tabla `tracked_searches` — Busquedas recurrentes (RLS por user_id)
- `id` UUID PK DEFAULT gen_random_uuid()
- `user_id` UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE
- `name` TEXT NOT NULL
- `search_params` JSONB NOT NULL
- `route_summary` TEXT NOT NULL
- `interval_hours` INTEGER NOT NULL DEFAULT 24
- `is_active` BOOLEAN DEFAULT true
- `next_run_at` TIMESTAMPTZ DEFAULT now()
- `last_run_at` TIMESTAMPTZ
- `run_count` INTEGER DEFAULT 0
- `last_error` TEXT
- `expires_at` TIMESTAMPTZ
- `created_at` TIMESTAMPTZ DEFAULT now()
### Tabla `search_runs` — Log de ejecuciones (RLS via tracked_search_id)
- `id` UUID PK
- `tracked_search_id` UUID NOT NULL REFERENCES tracked_searches(id) ON DELETE CASCADE
- `status` TEXT DEFAULT 'pending'
- `cheapest_price` NUMERIC(10,2)
- `total_trips_found` INTEGER DEFAULT 0
- `top_trips` JSONB
- `from_cache` BOOLEAN DEFAULT false
- `error_message` TEXT
- `started_at` TIMESTAMPTZ
- `completed_at` TIMESTAMPTZ
- `created_at` TIMESTAMPTZ DEFAULT now()
### Tabla `price_snapshots` — Datos para graficos (RLS via tracked_search_id)
- `id` UUID PK
- `tracked_search_id` UUID NOT NULL REFERENCES tracked_searches(id) ON DELETE CASCADE
- `search_run_id` UUID NOT NULL REFERENCES search_runs(id) ON DELETE CASCADE
- `cheapest_price` NUMERIC(10,2) NOT NULL
- `avg_price` NUMERIC(10,2)
- `median_price` NUMERIC(10,2)
- `total_results` INTEGER DEFAULT 0
- `recorded_at` TIMESTAMPTZ DEFAULT now()
---
## 2. Cache de busquedas — Flujo integrado
### Hash de parametros (`server/utils/search-hash.ts`)
- Normaliza SearchParams (ordena keys, elimina `_poll`), genera SHA-256
- Compartido entre `/api/search` y el worker
### Endpoint `/api/search` modificado (cache-aware)
1. Calcular params_hash
2. Buscar en search_cache WHERE params_hash = hash AND fetched_at > now() - 1 hour
3. Si hay cache fresco → devolver trips del cache
4. Si no → llamar a Flightics, guardar en search_cache, devolver
### Worker usa el mismo cache
- Lee cache antes de llamar a Flightics
- Escribe al cache tras cada busqueda nueva
---
## 3. Worker (`server/plugins/search-worker.ts`)
- Nitro plugin con `setInterval(processQueue, 60_000)`
- Mutex para evitar solapamiento
- Procesa hasta 5 jobs por ciclo con 10s entre cada uno
- Usa `server/utils/supabase-admin.ts` (cliente Supabase con service_role sin H3 event)
---
## 4. API Endpoints (`server/api/tracking/`)
| Archivo | Metodo | Funcion |
|---------|--------|---------|
| `index.get.ts` | GET | Listar tracked_searches con ultimo snapshot |
| `index.post.ts` | POST | Crear tracked_search |
| `[id].patch.ts` | PATCH | Editar configuracion |
| `[id].delete.ts` | DELETE | Eliminar + cascade |
| `[id]/history.get.ts` | GET | Price snapshots para graficos |
| `[id]/runs.get.ts` | GET | Log de ejecuciones |
---
## 5. Composable (`app/composables/useTrackedSearches.ts`)
Patron identico a useWatchlist.ts con watch(user) para auto-load/cleanup.
---
## 6. UI: vue-chartjs + chart.js
## 7. Componentes: tracking/PriceChart, TrackedSearchCard, CreateTrackingForm, RunHistory, TrackingConfig
## 8. Paginas: /tracking (dashboard) + /tracking/[id] (detalle con grafico)
## 9. Integracion: boton en /results, icono en recientes, link en nav
---
## Orden de implementacion
### Fase 1: DB + Cache
### Fase 2: Worker
### Fase 3: API tracking
### Fase 4: Composable + UI basica
### Fase 5: Graficos y detalle
### Fase 6: Integracion

393
plans/vuelato-plan.md Normal file
View File

@@ -0,0 +1,393 @@
# Vuelato - Frontend + Backend Supabase
## Contexto
Tenemos un buscador de vuelos con un cliente API completo de Flightics (9 endpoints REST funcionando). El frontend actual tiene 3 paginas basicas. Queremos:
1. Un backend Supabase/PostgreSQL que wrapee Flightics y persista datos
2. Un frontend ambicioso que explote toda la API
## Por que Supabase
- **Persistencia**: watchlists, preferencias, busquedas recientes -> entre dispositivos
- **Cache inteligente**: airports, countries, inspirations en Postgres (no llamar a Flightics cada vez)
- **Auth**: usuarios con sus datos
- **Cron/Edge Functions**: verificar precios de watchlist automaticamente
- **Realtime**: notificar bajadas de precio
- **Row Level Security**: cada usuario ve solo sus datos
---
## Stack
- **Nuxt 4** + **@nuxt/ui v4** + **Tailwind CSS v4** (ya instalado)
- **@nuxtjs/supabase v2** - modulo oficial, composables auto-importados
- **Supabase PostgreSQL** - tablas, RLS, funciones
- **Leaflet** - mapa explorador
- Server routes Nitro wrapeando Flightics -> Supabase cache
---
## Esquema de base de datos (Supabase/PostgreSQL)
### Tablas de cache (datos de Flightics, publicas, sin RLS)
```sql
-- Aeropuertos, ciudades, paises, regiones (de getLocations + getCountries)
-- Se sincroniza via cron o al primer request, TTL 24h
CREATE TABLE airports (
iata TEXT PRIMARY KEY,
icao TEXT,
name TEXT NOT NULL,
lat DOUBLE PRECISION,
lon DOUBLE PRECISION,
city_id TEXT,
city_name TEXT,
country_code TEXT,
country_name TEXT,
region_slug TEXT,
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE countries (
iso_code2 TEXT PRIMARY KEY,
iso_code3 TEXT,
name_eng TEXT,
name_native TEXT,
phone_prefix TEXT,
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE regions (
slug TEXT PRIMARY KEY,
name TEXT,
name_localized TEXT,
airport_codes TEXT[], -- array de IATA codes
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Cache de inspiraciones (por aeropuerto origen, TTL 1h)
CREATE TABLE inspirations_cache (
id SERIAL PRIMARY KEY,
from_airport TEXT NOT NULL,
to_airports TEXT[] NOT NULL,
min_price NUMERIC(10,2),
min_stops INTEGER,
fetched_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(from_airport, to_airports)
);
-- Cache de multi-city inspirations (TTL 1h)
CREATE TABLE multi_city_cache (
id SERIAL PRIMARY KEY,
origin_codes TEXT[] NOT NULL,
from_airport TEXT,
stops TEXT[] NOT NULL,
min_price NUMERIC(10,2),
fetched_at TIMESTAMPTZ DEFAULT now()
);
```
### Tablas de usuario (con RLS)
```sql
-- Perfiles de usuario (extends auth.users)
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
home_airports TEXT[] DEFAULT '{}',
default_adults INTEGER DEFAULT 1,
default_children INTEGER DEFAULT 0,
default_infants INTEGER DEFAULT 0,
locale TEXT DEFAULT 'es',
created_at TIMESTAMPTZ DEFAULT now()
);
-- Watchlist: vuelos guardados para monitorizar precio
CREATE TABLE watchlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
booking_token TEXT NOT NULL,
route_summary TEXT NOT NULL, -- "MAD > NTE > MAD"
departure_code TEXT,
arrival_code TEXT,
departure_date TEXT,
original_price NUMERIC(10,2) NOT NULL,
current_price NUMERIC(10,2),
price_status TEXT DEFAULT 'saved', -- saved, available, price_up, price_down, unavailable
passengers_adult INTEGER DEFAULT 1,
passengers_child INTEGER DEFAULT 0,
passengers_infant INTEGER DEFAULT 0,
last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Busquedas recientes
CREATE TABLE recent_searches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
search_params JSONB NOT NULL, -- el payload completo de busqueda
route_summary TEXT, -- "AGP,GRX > NTE"
search_mode TEXT DEFAULT 'roundtrip',
created_at TIMESTAMPTZ DEFAULT now()
);
-- Alertas de precio (futuro: cron verifica y notifica)
CREATE TABLE price_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
watchlist_id UUID REFERENCES watchlist(id) ON DELETE CASCADE,
target_price NUMERIC(10,2), -- notificar si baja de este precio
is_active BOOLEAN DEFAULT true,
last_notified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- RLS policies
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE watchlist ENABLE ROW LEVEL SECURITY;
ALTER TABLE recent_searches ENABLE ROW LEVEL SECURITY;
ALTER TABLE price_alerts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users see own profile" ON profiles FOR ALL USING (auth.uid() = id);
CREATE POLICY "Users see own watchlist" ON watchlist FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users see own searches" ON recent_searches FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users see own alerts" ON price_alerts FOR ALL USING (auth.uid() = user_id);
```
---
## Arquitectura server routes (Nitro)
Los server routes actuan como **proxy inteligente**: Flightics API -> cache en Supabase -> respuesta al frontend.
### Rutas existentes (refactored con cache)
| Ruta | Logica |
|---|---|
| `GET /api/locations` | Lee de tabla `airports`. Si vacia o >24h, llama a Flightics `getLocations()`, parsea y guarda. |
| `GET /api/countries` | Lee de tabla `countries`. Si vacia o >24h, sincroniza. |
| `GET /api/inspirations` | Lee de `inspirations_cache` si <1h. Si no, llama a Flightics, guarda en cache, retorna. |
| `POST /api/search` | Proxy directo a Flightics `searchTrips`. Sin cache (resultados son efimeros). |
| `POST /api/detail` | Proxy directo a Flightics. |
| `POST /api/check` | Proxy directo a Flightics. |
| `POST /api/weekend-search` | Proxy directo. |
| `POST /api/route-flights` | Proxy directo (datos cambian frecuentemente). |
| `POST /api/multi-city-inspirations` | Cache 1h en `multi_city_cache`, sino Flightics. |
### Rutas nuevas (usuario/Supabase)
| Ruta | Logica |
|---|---|
| `GET /api/user/profile` | Lee profile del usuario autenticado |
| `PUT /api/user/profile` | Actualiza home_airports, passengers, locale |
| `GET /api/user/watchlist` | Lista watchlist del usuario |
| `POST /api/user/watchlist` | Anadir vuelo a watchlist |
| `DELETE /api/user/watchlist/[id]` | Eliminar de watchlist |
| `POST /api/user/watchlist/[id]/check` | Verificar precio: llama `checkTrip`, actualiza en DB |
| `POST /api/user/watchlist/check-all` | Verificar todos los precios del usuario |
| `GET /api/user/recent-searches` | Ultimas 10 busquedas |
| `POST /api/user/recent-searches` | Guardar busqueda reciente |
| `POST /api/sync/locations` | Fuerza sync de airports/countries/regions desde Flightics a Supabase |
---
## Paginas (9)
### `/` - Home (discovery hub)
- Hero con SearchBar compacto inline
- Tabs rapidos: Ida y vuelta | Solo ida | Multi-ciudad | Finde | Explorar
- Inspiraciones desde home airports del usuario (profile o localStorage si no logueado)
- Carrusel multi-city inspirations
- Widget "Donde por X€?" con slider presupuesto
- Busquedas recientes del usuario (si logueado)
### `/auth` - Login/Register
- Login con email/password (Supabase Auth)
- OAuth con Google
- Registro
- Redirect post-login
### `/search` - Busqueda completa
- 5 modos: standard, solo ida, multi-city, finde, explorar
- AirportInput con autocomplete (datos de Supabase `airports` table)
- Al buscar, guarda en `recent_searches` si logueado
### `/results` - Resultados
- Sort: precio, duracion, salida, escalas
- Filtros: precio max, escalas, hora, aerolineas
- Polling progresivo
- Vista lista + compacta
- Estrella watchlist (requiere login, o prompt to login)
### `/detail/[token]` - Detalle
- Timeline itinerario
- Verificar precio
- Vuelos relacionados (getRouteFlights)
- Compartir
- Watchlist toggle
- CTA booking
### `/explore` - Mapa explorador
- Mapa Leaflet con aeropuertos de tabla `airports` (lat/lon)
- Seleccionar origen -> inspiraciones como arcos
- Slider presupuesto
- Click destino -> popup
- Toggle directos
### `/route/[from]-[to]` - Explorador de ruta
- getRouteFlights sin fechas
- Todas las opciones de vuelo
- Comparativa aerolineas
### `/multi-city` - Inspiracion multi-ciudad
- Input origenes -> getMultiCityInspirations
- Tarjetas de itinerarios
- Click -> busqueda multi-city
### `/watchlist` - Lista seguimiento (requiere auth)
- Tabla desde Supabase `watchlist`
- Verificar precios individual / bulk
- Badges de estado: precio bajo (verde), subio (rojo), no disponible (gris)
- Alertas de precio (target price)
- Busquedas recientes
---
## Componentes (mismos del plan anterior + nuevos auth)
### Auth
| Componente | Proposito |
|---|---|
| `auth/LoginForm.vue` | Email/password + Google OAuth |
| `auth/UserMenu.vue` | Avatar + dropdown: perfil, watchlist, logout. O boton login si no auth. |
| `auth/AuthGuard.vue` | Wrapper que redirige a /auth si no logueado |
### Busqueda (sin cambios del plan anterior)
`search/SearchBar`, `search/AirportInput`, `search/SearchModeTabs`, `search/StopsConfigurator`, `search/MaxStopsFilter`, `search/BudgetSlider`, `search/DateRangePicker`, `search/StayDurationPicker`, `search/PassengerPicker` (en popover)
### Resultados
`results/TripCard`, `results/TripCardCompact`, `results/ResultsToolbar`, `results/ResultsFilters`, `results/LoadingMore`
### Detalle
`detail/ItineraryTimeline`, `detail/SegmentCard`, `detail/PriceVerifier`, `detail/RelatedFlights`, `detail/ShareButton`
### Mapa
`map/FlightMap`, `map/MapControls`, `map/MapDestinationPopup`
### Inspiracion
`inspiration/InspirationGrid`, `inspiration/MultiCityCard`, `inspiration/BudgetExplorer`
### Watchlist
`watchlist/WatchlistItem`, `watchlist/PriceAlertSetter`
---
## Composables
### Existentes (refactored)
- **`useFlightSearch`** - search con polling, sort, filter, weekend
- **`useLocations`** - ahora lee de Supabase (tabla airports), con fallback a API
- **`useInspirations`** - lee de cache Supabase, refresh si stale
### Nuevos
- **`useAuth`** - wrapper de `useSupabaseUser()` + `useSupabaseClient()`. Login, logout, register, profile CRUD.
- **`useWatchlist`** - CRUD contra Supabase `watchlist` table. `add`, `remove`, `checkPrice`, `checkAll`, `isWatched`.
- **`useRecentSearches`** - ultimas busquedas del usuario desde Supabase
- **`useUserPreferences`** - lee/escribe `profiles` table. Home airports, default passengers.
- **`useRouteFlights`** - fetch route flights
- **`useResultFilters`** - sort/filter computeds client-side
---
## Dependencias a instalar
```
pnpm add @nuxtjs/supabase leaflet @vue-leaflet/vue-leaflet
```
---
## Orden de implementacion
### Fase 0: Setup Supabase
1. Crear proyecto Supabase (o usar existente)
2. `pnpm add @nuxtjs/supabase`
3. Configurar modulo en nuxt.config.ts + env vars
4. Ejecutar SQL para crear tablas + RLS policies
5. Crear server route `POST /api/sync/locations` para poblar airports/countries/regions
### Fase 1: Auth + Infraestructura
6. `auth/LoginForm.vue` + `/auth` page
7. `auth/UserMenu.vue` en app.vue header
8. `useAuth` composable
9. `useUserPreferences` composable (lee/escribe profiles)
10. `useLocations` composable (lee de Supabase airports table)
11. `search/AirportInput.vue` con autocomplete
### Fase 2: Busqueda completa
12. `SearchModeTabs` + refactor SearchForm para 5 modos
13. `StopsConfigurator`, `MaxStopsFilter`, `BudgetSlider`, `DateRangePicker`, `StayDurationPicker`
14. `PassengerPicker` en popover
15. `/search` page
16. `useRecentSearches` + guardar busquedas en Supabase
### Fase 3: Resultados mejorados
17. `useResultFilters` composable
18. Refactor `useFlightSearch` con polling + sort/filter
19. `ResultsToolbar` + `ResultsFilters`
20. `TripCard` mejorado + `TripCardCompact`
21. `LoadingMore` polling indicator
### Fase 4: Detalle + Watchlist
22. `/detail/[token]` ruta dinamica
23. `ItineraryTimeline` + `SegmentCard`
24. `PriceVerifier`
25. `useWatchlist` composable (Supabase CRUD)
26. Watchlist toggle en TripCard y detail
27. `/watchlist` page con verificacion de precios
28. Server routes: `/api/user/watchlist/*`
### Fase 5: Discovery
29. `pnpm add leaflet @vue-leaflet/vue-leaflet`
30. `/explore` + `FlightMap` + controles
31. `/route/[from]-[to]` + `useRouteFlights`
32. `/multi-city` + `MultiCityCard`
33. `RelatedFlights` en detail page
### Fase 6: Home + Polish
34. Home refactored: SearchBar compacto, inspiraciones, multi-city carousel, BudgetExplorer, busquedas recientes
35. Navegacion actualizada en app.vue (Buscar, Explorar, Multi-city, Watchlist)
36. Cache de inspiraciones en Supabase
37. Responsive polish
---
## Configuracion Nuxt
```ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/eslint', '@nuxt/ui', '@nuxtjs/supabase'],
supabase: {
redirectOptions: {
login: '/auth',
callback: '/auth/confirm',
exclude: ['/', '/search', '/results', '/explore', '/route/*', '/multi-city'],
}
},
// ...
})
```
Las paginas publicas (home, search, results, explore, route, multi-city) funcionan sin auth. Watchlist y profile requieren login.
---
## Verificacion
- `pnpm dev` compila sin errores
- Supabase: tablas creadas, sync de locations funciona
- Auth: registro, login, logout, profile update
- Home: inspiraciones, multi-city carousel, budget explorer, busquedas recientes
- `/search`: 5 modos, autocomplete de aeropuerto lee de Supabase
- `/results`: polling, sort/filter, watchlist star (pide login si no auth)
- `/detail/[token]`: timeline, verificar precio, related flights, compartir
- `/explore`: mapa con aeropuertos, arcos de precio, slider presupuesto
- `/route/MAD-NTE`: todos los vuelos sin fechas
- `/multi-city`: itinerarios sugeridos
- `/watchlist`: CRUD, verificar precios, badges estado
- Mobile responsive en todas las paginas