BikeControl (SwiftControl)
Use Zwift Click, Zwift Play, Shimano Di2, or any Bluetooth controller with MyWhoosh, Rouvy, Zwift, and more. Free to try on iOS, Android, Windows, and macOS.
Convierte el viento, el oleaje y el tiempo en una decisión clara: qué actividad acuática hacer, dónde y cuándo. 20 deportes, toda la costa de España.
Translation pending
Sports & fitnessTypeScript · JavaScriptGitHub @carrascosaaiOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Loading the full README. The saved excerpt is shown below.
Capa de decisión sobre datos meteorológicos y marítimos: responde a "¿qué actividad acuática puedo hacer ahora, dónde y en qué condiciones?" con una recomendación clara (5 niveles 🟢🟢🟡🟠🔴 + score 0-100 + por qué), no con un dashboard de datos crudos. Incluye geolocalización ("cerca de mí"), páginas por actividad+ciudad (/surf/malaga) y caché real de previsión con cron diario.Read the full README ↗
🔴 En vivo: https://sea-activity-intelligence-deploy.vercel.app
Capa de decisión sobre datos meteorológicos y marítimos: responde a "¿qué actividad
acuática puedo hacer ahora, dónde y en qué condiciones?" con una recomendación clara
(5 niveles 🟢🟢🟡🟠🔴 + score 0-100 + por qué), no con un dashboard de datos crudos.
Incluye geolocalización ("cerca de mí"), páginas por actividad+ciudad
(/surf/malaga) y caché real de previsión con cron diario.
Cobertura: toda la costa de España (~3.600 playas) y 20 deportes/actividades
acuáticas. Ver VALIDATION.md para el problema, hipótesis,
competencia y criterios de éxito/abandono, y TODO.md para el estado
detallado del alcance.
Nota sobre el alcance: el plan original (
AGENTS.md/brief inicial) proponía validar primero con 3 actividades y la Costa del Sol antes de escalar. La ampliación a cobertura nacional y 20 actividades se hizo por petición explícita del usuario, saltándose ese orden — ver la sección "Decisiones de arquitectura" más abajo para lo que eso implica en la práctica.
Tabla/deslizamiento: Surf, Paddle Surf, Bodyboard, Kitesurf, Windsurf, Wingfoil. Remo y vela: Kayak, Remo, Vela/Navegación. Submarinismo: Buceo, Snorkel, Apnea. Motor: Esquí acuático, Wakeboard, Moto de agua, Flyboard. Otros: Pesca, Coasteering, Baño en la playa (no es deporte, pero es la pregunta más común), Natación en aguas abiertas.
Deliberadamente FUERA: deportes de piscina/competición (natación en piscina, waterpolo,
saltos) y deportes de río (rafting, barranquismo de agua dulce) — el modelo de datos es
100% costero (MarineProvider = oleaje de mar), así que cualquier actividad sin
relación real con el mar/viento en costa queda fuera por diseño. Ver
src/lib/activities.ts.
src/
lib/
types.ts, activities.ts, time.ts, forecast.ts, bandLabels.ts
locations.ts Acceso a src/data/beaches.json (SOLO server-side)
providers/ ── Capa de abstracción de datos ──
types.ts, openMeteoWeather.ts, openMeteoMarine.ts, tide.ts (NullTideProvider)
noaaVisibility.ts Claridad del agua (NOAA ERDDAP, ver sección propia abajo)
scoring/ ── Motor de decisión ──
ruleTypes.ts Tipos de reglas (viento/oleaje: lineal o por rango)
profiles.ts Perfil base (nivel principiante) de cada actividad
generateRules.ts Deriva intermedio/avanzado escalando el perfil base
config.ts Tabla completa generada (20 actividades × 3 niveles)
engine.ts scoreCondition(): snapshot → { score, band, reasons }
dayScores.ts Scores por hora + "mejor ventana"
components/ UI (ScoreBadge, HourlyComparison, LocationSearch, MapClient...)
app/
page.tsx Landing + wizard
resultado/page.tsx Resultado
hoy/page.tsx "¿Qué puedo hacer hoy?"
mapa/page.tsx Mapa: TODAS las playas seleccionables (clustering), color
real solo para las que están en pantalla (carga por viewport)
api/
analytics/route.ts Eventos de analítica
locations/search/ Búsqueda de playas (server-side)
map-scores/ Scores para el mapa, acotados al viewport visible
data/
beaches.json ~3.600 playas de España (generado, ver scripts/)
scripts/
provinces.mjs Tabla de provincias/CCAA + heurística de "capital más cercana"
generate-beaches.mjs Genera src/data/beaches.json desde OpenStreetMap (Overpass)
seed-supabase.mjs Carga beaches.json en Supabase (opcional, P1)
db/
schema.sql Esquema Supabase/Postgres
Capa de proveedores: ninguna pantalla ni el motor de scoring llaman directamente a
Open-Meteo. Todo pasa por WeatherProvider / MarineProvider / TideProvider. Cambiar
de proveedor es sustituir un archivo en lib/providers/, no reescribir la app.
Motor de scoring generado, no copiado 20 veces: lib/scoring/profiles.ts define un
perfil base por actividad (referencia: nivel principiante) — viento, oleaje, periodo,
comodidad. generateRules.ts deriva automáticamente intermedio/avanzado escalando esos
números. Así se evita mantener 20 actividades × 3 niveles × 5 factores a mano. El viento
y el oleaje pueden modelarse como lineal ("cuanto menos, mejor" — kayak, buceo...) o
como rango ("necesita un mínimo, y también penaliza el exceso" — kitesurf, windsurf,
vela: sin viento suficiente, literalmente no se puede practicar). Verificado con datos
reales en Tarifa (Playa de Valdevaqueros): el kitesurf puntúa mal de madrugada (viento
flojo) y bien por la tarde (viento térmico), reflejando el patrón real de la zona.
Umbrales contrastados, no solo "a ojo": kitesurf, windsurf, wingfoil, vela, surf,
buceo, snorkel y esquí acuático/wakeboard tienen sus rangos de viento/oleaje contrastados
contra guías publicadas del propio deporte (federaciones, escuelas, comunidad), no solo
estimados — cada perfil en profiles.ts cita su fuente y fecha de consulta. El resto
(pesca, coasteering, baño, remo, moto de agua, flyboard...) sigue siendo una estimación
razonada por extrapolación, sin una guía tan directa que contrastar. En todos los casos
siguen siendo un punto de partida: la pieza que falta para una validación de verdad es
uso real, y para eso existe el widget de feedback (👍/👎) en cada resultado — evento
recommendation_feedback en lib/analytics.ts, probado de extremo a extremo.
Investigado (sep. 2026) a raíz de una duda directa del usuario sobre si Open-Meteo está bien calibrado. Dos alternativas evaluadas y descartadas para uso en vivo, y una validación real hecha contra datos medidos:
movil.puertos.es/simo/...), pero es privada y no documentada
como servicio público — depender de ella en producción sería una integración frágil,
igual que la fricción que ya rechazamos con la primera versión de Bubblewrap/TWA no
documentada. Se ha usado solo para el ejercicio de validación de abajo, no está
integrada en el código.Validación real hecha: se comparó Open-Meteo con lecturas reales de boyas de Puertos del Estado (vía su API interna, solo para este ejercicio puntual) en las coordenadas exactas de la boya, mismo día y hora:
| Boya real | Oleaje (altura) | Periodo | Temp. agua | Viento |
|---|---|---|---|---|
| Bilbao-Vizcaya (Ría de Bilbao) | 0.63 m real vs 0.68 m Open-Meteo (8% de diferencia) | 6.4 s medio / 10.0 s de pico real vs 8.8 s Open-Meteo (coherente con periodo de pico) | 22.4 vs 22.5 ºC | 11.8 km/h real vs 21.3 km/h Open-Meteo (casi el doble) |
| Las Palmas / Gran Canaria (costa abierta) | 1.14 vs 1.18 m (3% de diferencia) | 4.9 s medio / 7.4 s de pico real vs 6.85 s Open-Meteo | 23.9 vs 24.1 ºC | 24.5 vs 23.8 km/h (3% de diferencia) |
Conclusión honesta: en costa abierta (Canarias) Open-Meteo acierta con un margen de
error de un pocos puntos porcentuales — nada que objetar. En una ría/bahía resguardada
(Bilbao) el viento del modelo se desvía mucho del real — un modelo regional (grid de
varios km) no resuelve bien los efectos de viento muy locales de una geografía costera
compleja (brisas de tierra/mar, canalización por la ría), algo que sí capta una boya
puntual. Esto no es un fallo del proveedor ni algo que un cambio de fuente arregle
fácilmente (el mismo problema de resolución afecta a cualquier modelo regional,
incluidos los que usan Windguru/Windy — ambos se apoyan en los mismos modelos públicos
de fondo, GFS/ICON/ECMWF); es una limitación real y conocida de la meteorología por
modelo en general en bahías, rías y puertos, frente a costa abierta. Queda documentado
aquí en vez de escondido, y hay una nota breve sobre esto en la sección de datos
técnicos de cada resultado (TechnicalDetails.tsx).
Sobre el periodo de ola: Open-Meteo no documenta si su campo wave_period es periodo
medio o de pico — en ambas boyas comparadas su valor cae más cerca del periodo de pico
real que del medio, así que los umbrales de profiles.ts (p. ej. 10-14 s ideal en
surf) se han calibrado asumiendo que es periodo de pico/dominante, la convención
habitual en previsión de surf (Surfline, SurfSpotGuide).
Mejora encontrada durante esta investigación (sep. 2026): buscando un modelo
regional de mayor resolución para España (AROME/ICON-D2 no cubren la península, solo
Europa central), until que Open-Meteo Marine sí separa el estado del mar combinado en
sus dos componentes físicos reales: swell_wave_* (mar de fondo — olas organizadas de
origen lejano, con swell_wave_peak_period, el periodo de PICO real, no la media) y
wind_wave_* (mar de viento/chop local). Es justo la distinción oceanográfica en la
que se basó la recalibración del scoring, pero el motor solo usaba el dato combinado.
Ahora surf y bodyboard (useSwellData: true en profiles.ts) puntúan sobre el swell,
no sobre la mezcla — verificado en directo en Zurriola (San Sebastián): el mismo
instante daba periodo combinado 9,0 s ("periodo corto", oleaje de viento) pero periodo
de pico del swell real 11,15 s (dentro del rango ideal 10-14 s) — el score pasaba de 92
a 100 con el motivo correcto. Si el swell no está diferenciado en ese instante (mar
totalmente plano), cae al dato combinado, nunca se inventa. El indicador de corrientes
de retorno (ripCurrentRisk.ts) usa el mismo swell por el mismo motivo — es lo que
dice la propia NOAA. ConditionsGrid muestra "(swell)" junto a oleaje/periodo cuando
la actividad usa este dato, para que la cabecera y el "¿por qué?" nunca muestren
números distintos.
src/data/beaches.json se genera con node scripts/generate-beaches.mjs a partir de
datos reales de OpenStreetMap (Overpass API, natural=beach en España): ~3.630 playas
con nombre, municipio más cercano, provincia, comunidad autónoma y coordenadas. No se
genera en cada build automáticamente (depende de una API externa que puede tardar o
fallar) — es un paso manual cuando se quiera refrescar el dataset. El archivo generado sí
se versiona en el repo.
La provincia/CCAA de cada playa se asigna por el municipio conocido más cercano
(~950 municipios con código INE oficial extraído de ref:ine en OpenStreetMap — dato
administrativo real, no adivinado) en vez de la distancia a una única capital de
provincia. Fallback: si no hay ningún municipio con código INE a menos de 60 km (islas
remotas, zonas poco pobladas), se usa una heurística de "capital más cercana en línea
recta" (scripts/provinces.mjs) con una excepción explícita para Ceuta/Melilla — sin
ella, playas de la costa gaditana como Tarifa se asignaban erróneamente al otro lado del
Estrecho por pura cercanía en línea recta (bug real detectado y corregido durante el
desarrollo). Verificado manualmente en varias fronteras provinciales reales (Sotogrande,
Ayamonte, Ribadeo, La Manga) sin anomalías.
El dataset de playas NUNCA se importa desde un componente cliente ("use client"):
la búsqueda de ubicaciones pasa por /api/locations/search y los scores del mapa por
/api/map-scores, ambos server-side. Así el bundle del navegador no crece con las
~3.600 playas — ver "Escalabilidad" más abajo.
92 "playas" de interior excluidas del buscador (sep. 2026): natural=beach en
OpenStreetMap también etiqueta playas fluviales y de pantano en zonas sin costa —
Open-Meteo Marine nunca tiene oleaje ahí (siempre null, nunca lo inventa), así que
antes de este filtro llevaban a un callejón sin salida. lib/locations.ts filtra por
las 22 provincias españolas sin costa real (Madrid, Zaragoza, Toledo, Zamora...) —
por PROVINCIA y no por comunidad autónoma, porque comunidades como País Vasco,
Cataluña o Andalucía mezclan provincias de costa y de interior (Álava, Lleida,
Córdoba). Siguen en beaches.json (el archivo generado no se toca) pero nunca llegan
a un usuario de esta web.
Durante mucho tiempo la web dijo honestamente "no disponible — sin proveedor abierto para España", porque Open-Meteo no tiene mareas y tanto Puertos del Estado como AEMET solo ofrecen acceso programático vía OPeNDAP/NetCDF (mismo problema de encaje que Copernicus Marine, ver más abajo) o una API interna no documentada de su app de consulta. Nada de eso encajaba sin fricción.
Lo que sí encaja, y es real: la marea NO se puede calcular con astronomía genérica
(posición Sol/Luna sin más) — la misma fuerza astronómica da mareas de 4 m en Cádiz y
casi nulas en el Mediterráneo, hace falta el dato empírico de cada punto de la costa
(constituyentes armónicos: amplitud y fase por componente, medidos con años de datos
reales). @neaps/tide-database (MIT, npm)
publica esos constituyentes reales de miles de estaciones mundiales — TICON-4, basada
en registros históricos GESLA-4 — incluidas 86 en España. Combinado con
@neaps/tide-predictor (MIT, sin
dependencias) se calcula la predicción real sin ninguna llamada de red — pura
astronomía + los constituyentes de la estación, igual que hace cualquier software de
mareas profesional.
Solo licencia comercial, sin excepción: de esas 86 estaciones españolas, la mitad
son cc-by-nc-4.0 (no comercial) — legalmente no se pueden usar en un producto real,
por gratis que sea hoy. scripts/generate-tide-stations.mjs filtra en build-time a
las 20 con licencia cc-by-4.0 (uso comercial explícitamente permitido) y las vuelca
a src/data/tideStations.json (~120 KB) — el paquete de 56 MB de la base de datos es
devDependency, nunca se despliega (mismo patrón que generate-beaches.mjs).
Cobertura real, no fingida: con solo 20 estaciones frente a ~3.500 playas,
la mayoría se queda sin dato — lib/providers/tide.ts busca la estación real más
cercana y, si está a más de 70 km, dice honestamente "no disponible" en vez de dar una
lectura de una zona que ya no es representativa (la amplitud de marea cambia bastante
en tramos largos de costa). El límite de 70 km es conservador: verificado en
directo comparando con la predicción oficial real de Puertos del Estado para
Bilbao (capturada en vivo desde su propia app, ver sección de arriba) — la estación de
Santander, a ~100 km, acertó la hora de las 4 pleamares/bajamares del día con un
margen de 1 a 5 minutos, y las alturas a pocos centímetros.
No entra en el score 0-100 (como la claridad del agua): se muestra como dato técnico adicional en cada resultado, con la estación real usada y la distancia a la playa siempre visibles, para que quien lo lea sepa exactamente de dónde sale el número.
No existe una API pública real de "visibilidad de buceo" en España. Investigado y descartado antes de construir nada:
Lo que sí se usa, y es real: NOAA CoastWatch (ERDDAP), servicio público del gobierno
de EE.UU., gratuito y sin API key, con el coeficiente de atenuación de luz Kd490 por
satélite (Kd490 Gap-filled DINEOF, Global 2km, Daily) — ver
src/lib/providers/noaaVisibility.ts. Se convierte
a un rango orientativo de metros con la fórmula clásica profundidad Secchi ≈ 1.7 / Kd490.
Limitaciones reales, visibles en la propia tarjeta de la app, no escondidas:
Nota de depuración real: la primera versión devolvía "no disponible" en todas las playas
porque NOAA bloquea (403) peticiones sin cabecera User-Agent, y fetch de Node no
manda una por defecto — quedó corregido y es la causa por la que el proveedor fija
explícitamente ese header.
Segunda ronda (sep. 2026): con cobertura nacional real se detectó que un número no
trivial de playas (sobre todo calas estrechas y playas muy pegadas a tierra) devolvía "sin
lectura" — no por nubes, sino porque el grid de 2km cae justo en un píxel contaminado por
tierra en ese punto exacto, aunque el píxel de al lado (2-4km) tenga una lectura de mar
abierto perfectamente válida. Verificado en directo con Playa Charco del Musgo (Tenerife):
el píxel exacto da null, un cuadro de ~6x11km alrededor tiene varias lecturas reales a
2-5km. Se reevaluó de nuevo Copernicus Marine (100m de resolución) como alternativa — sigue
sin encajar sin infraestructura adicional (API pensada para clientes Python con cuenta
registrada, sirve NetCDF/Zarr, no un endpoint HTTP de "dame el valor en este punto") — y los
productos de mayor resolución de NOAA CoastWatch ("Sector" a 750m) son regionales de EE. UU.,
no cubren España. La fuente en sí no era el problema: el problema era pedir un único píxel
exacto. Arreglado pidiendo un pequeño cuadro alrededor del punto y usando el píxel válido
más cercano (máx. 12km) en vez de solo el más próximo exacto, esté vacío o no — misma
fuente, mismo endpoint gratuito, sin dependencia nueva. Verificado con 41 playas reales
repartidas por toda España: pasó de fallar en la que se sabía problemática a acertar
40/41 (la única que sigue sin dato es una playa fluvial de Castilla-La Mancha — de las
~85 "playas" de interior que trae el dataset de OpenStreetMap en regiones sin costa,
para las que Open-Meteo Marine tampoco tiene oleaje; no es un fallo, es que
correctamente no hay mar cerca).
Notificaciones push del navegador, sin cuenta ni email — la propia suscripción push
(endpoint + claves de cifrado) es el identificador, tan anónima como el session_id
de favorites. Botón en /resultado (AlertButton.tsx): pide permiso de
notificaciones, registra public/sw.js (service worker mínimo, solo para recibir el
evento push en segundo plano — no cachea nada ni hace la app funcionar offline) y
suscribe con la clave pública VAPID. Se guarda en alert_subscriptions (Supabase).
Aviso "al entrar", no "cada vez que se revisa": cada suscripción guarda
was_good — solo se notifica cuando las condiciones pasan de no-buenas a
ideal/buena, no en cada chequeo mientras se mantienen (si no, spam cada 20 min
durante toda una buena tarde de viento).
El obstáculo real — Vercel Hobby solo permite cron una vez al día (ver
"Escalabilidad" más abajo): inútil para "avísame en cuanto se abra la ventana", que
necesita revisarse a menudo. En vez de forzar un plan de pago para esto,
.github/workflows/check-alerts.yml dispara /api/cron/check-alerts cada 20 minutos
gratis vía GitHub Actions — es solo una petición HTTP periódica con autenticación
(CRON_SECRET, la misma variable que ya protegía /api/cron/refresh-cache), no hace
falta ningún plan de pago para eso.
Setup manual necesario (no lo puede hacer el asistente):
alert_subscriptions de db/schema.sql en el SQL editor de
Supabase (el resto del esquema ya estaba aplicado).node -e "console.log(require('web-push').generateVAPIDKeys())")
y añadir NEXT_PUBLIC_VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY como variables de
entorno en Vercel (Production).SITE_URL (la URL de producción) y CRON_SECRET (el mismo valor que en Vercel)
para que el workflow programado pueda llamar a la ruta protegida.Sin esos tres pasos, el botón de alertas no aparece (sin claves VAPID) o no llega a guardar la suscripción (sin la tabla) — degrada con honestidad, no rompe el resto de la web.
Investigado quién más hace algo parecido: 4Shor (Reino Unido/Europa, de pago) es el competidor real más cercano — puntúa condiciones por deporte y nivel, 2.500+ localizaciones europeas. Le gana a esta web en una cosa que no es realista igualar sin infraestructura oceanográfica seria: modela la propagación de la ola desde el fondo marino real (batimetría de satélite a 10m), en vez de leer un punto de un modelo meteorológico genérico como hace esta web (y casi cualquier otra fuente gratuita).
En vez de perseguir eso, se ha reforzado lo que sí es una ventaja real y sostenible:
/como-funciona: página pública que convierte todo el trabajo de validación
(comparación contra boyas reales, verificación de mareas contra la predicción
oficial) en algo que cualquier visitante puede leer, no solo quien mira el código —
4Shor dice "physics, not vibes" pero no enseña el trabajo real detrás.DayTrend.tsx, en cada resultado) — mismo cálculo que ya
existía para "mejor momento hoy" (computeBestWindow), reutilizado para hoy/mañana/
pasado mañana, para ver de un vistazo si merece esperar sin tener que cambiar de
página tres veces.scripts/generate-beach-photos.mjs, Wikimedia Commons):
buscar por proximidad geográfica sin más devuelve basura real — se comprobó en
directo que sin filtrar, "Platja del Somorrostro" empareja con una foto de fuegos
artificiales en la Barceloneta (por la palabra catalana "platja" en el título, no por
el sitio). Se filtra por coincidencia de palabra completa con el nombre de la playa
o el municipio — mejor sin foto que con la equivocada — y solo se aceptan licencias
de reutilización real (CC0/CC-BY/CC-BY-SA/dominio público), con autor y enlace a la
fuente siempre visibles. De momento cubre las playas destacadas (popular: true,
22 en total, 10 con foto encontrada) — ampliar la cobertura es cambiar una lista en
el script, no una limitación de diseño.Esto es lo más importante que hay que entender antes de anunciar "millones de clientes":
Preparado en código y verificado en producción:
locationsInBounds, tope de 80), y agrupa
marcadores con clustering (react-leaflet-cluster) para que el DOM no se sature.forecast_cache, ver lib/cache/forecastCache.ts):
un cron diario de Vercel (vercel.json, /api/cron/refresh-cache, protegido con
CRON_SECRET) precalienta las 22 playas destacadas; cualquier otra playa que se
consulte en vivo se guarda en caché con escritura diferida (after() de Next.js) sin
retrasar la respuesta al usuario. Verificado: una petición servida desde caché no
cambia fetched_at, una a una playa nueva sí la crea.lib/rateLimit.ts): 60 peticiones/min
en /api/locations/search, 20/min en /api/map-scores (más cara, hasta 80 llamadas
externas por petición), 120/min en /api/analytics. Probado disparando 65 peticiones
seguidas: las primeras 60 pasan, el resto devuelve 429.Límite real de la plataforma (no es cuestión de código): el plan Hobby de Vercel
solo permite cron diario — */30 * * * * falla al desplegar. La caché de las playas
destacadas se precalienta una vez al día, no cada 30 min; la protección real contra
picos de tráfico viene de la escritura diferida en cada petición, no del cron. Para
refresco más frecuente hace falta plan Pro.
NO está hecho todavía:
WeatherProvider/MarineProvider) existe precisamente para que ese
cambio no obligue a tocar el resto de la app.Desplegado con vercel deploy --temporary --yes desde una copia del proyecto en una
carpeta con nombre válido para Vercel (minúsculas, sin espacios) — la carpeta original
del proyecto en Windows sí puede tener espacios/mayúsculas sin problema, solo hace falta
para el propio comando de deploy.
Nota para Windows: el primer intento de despliegue falló con
EPERM: operation not permitted, symlink — Vercel deduplica funciones serverless
idénticas con un symlink al empaquetar, y Windows no deja crear symlinks sin Modo de
Desarrollador activado o una terminal como Administrador. Se evitó sin tocar ningún
ajuste del sistema: app/hoy/page.tsx y app/mapa/page.tsx llevan un export inerte
(__routeId) que hace que sus bundles compilados ya no sean idénticos, así Vercel no
intenta deduplicarlos. Si en el futuro aparece el mismo error en otra ruta, el mismo
truco sirve.
Next.js 16 (App Router) · TypeScript · Tailwind CSS v4 · Leaflet + react-leaflet + react-leaflet-cluster (mapa, capa gratuita de OpenStreetMap) · Supabase/Postgres (opcional en MVP) · Vercel (deploy recomendado).
npm install
npm run dev
Abre http://localhost:3000. No hace falta ninguna variable de entorno para que funcione
el flujo completo — los datos meteorológicos y marítimos son reales desde el primer
arranque, y las ~3.600 playas ya están generadas en src/data/beaches.json.
node scripts/generate-beaches.mjs
Tarda varios minutos y depende de la disponibilidad de la API pública de Overpass (puede
necesitar reintentos — el script ya reintenta solo). Usa --use-cache para reprocesar
sin volver a descargar si scripts/tmp/*.json ya existe.
Ya conectado en producción — la analítica y el feedback (👍/👎 en cada resultado) se
guardan de verdad, verificado con clics reales tanto en local como en la web pública.
Sin las variables de entorno, la app degrada sola a console.log (nunca rompe nada).
Para conectar tu propio proyecto desde cero:
db/schema.sql en el SQL editor del proyecto..env.example a .env.local y rellena SUPABASE_URL y
SUPABASE_SERVICE_ROLE_KEY (la service_role, no la anon — da acceso total, nunca
se sube al repo, .env.local está en .gitignore).node scripts/seed-supabase.mjs para cargar las ~3.600 playas.vercel env add SUPABASE_URL production y lo mismo para
SUPABASE_SERVICE_ROLE_KEY, luego redeploy.Eventos: location_selected, activity_selected, skill_selected, forecast_viewed,
recommendation_viewed, location_clicked, business_clicked, booking_clicked,
favorite_created. Cliente: src/lib/analytics.ts (best-effort, nunca bloquea la UI).
Servidor: src/app/api/analytics/route.ts.
El esquema (db/schema.sql) incluye businesses y booking_links para leads de
afiliación hacia escuelas/alquileres locales. El MVP no muestra enlaces de reserva ni
cobra nada — ver VALIDATION.md.
El producto nunca afirma que una actividad "es segura". Toda pantalla de resultado
incluye un aviso explícito (src/components/SafetyNotice.tsx).
Cada resultado enseña una imagen reciente de una webcam real para contrastar los datos con lo que se ve.
Niveles, del más al menos fiable (src/app/resultado/page.tsx):
src/lib/webcams.ts): 8 playas. Comprobadas con
npm run verify:webcams (en directo + inserción permitida). Auditoría sep. 2026: de las 7 originales,
4 estaban rotas sin que nadie lo detectara (directo caído, vídeo borrado, inserción desactivada por el autor);
se retiraron. Un directo puede morir en cualquier momento: repetir el script de vez en cuando.src/lib/providers/windyWebcams.ts, plan gratuito): cámara que Windy
clasifica como beach a ≤ 1,5 km, como coast a ≤ 1 km, o hasta 5 km si su título nombra la playa (playas
largas como Los Lances tienen el punto de referencia lejos de la cámara).Cobertura real (3.630 playas): 7,4 % con cámara de esa playa (niveles 1-2), 16,6 % con alguna imagen (incluye la de la zona), 13 de las 22 playas marcadas como populares. Windy solo tiene ~204 cámaras útiles en toda España (114 de playa, 179 de costa, 32 de puerto): ese es el techo de esa fuente, no un fallo de la integración.
Requisitos del plan gratuito de Windy que se cumplen: atribución "Webcams provided by windy.com" y enlace de la imagen a su página; las URLs de imagen caducan a los 10 min, así que se piden desde el servidor (caché de 4 min) y no se guardan; una sola petición por página. No se analizan las imágenes automáticamente (sus términos no lo aclaran); las cámaras sirven para contrastar visualmente, no para calcular el score.
Otras fuentes evaluadas y descartadas: Skyline Webcams (sus términos prohíben copiar/insertar sus imágenes; solo el dueño de la cámara puede insertarla), Surfline (de pago), Hispacams (55+ cámaras de playa, pero no publica cómo insertarlas y parece de pago — habría que escribirles a [email protected]). Las cámaras de YouTube se buscan a mano: de ~45 candidatas solo 12 estaban en directo y eran insertables, y 5 correspondían a playas de nuestra base.
Variable de entorno: WINDY_WEBCAMS_API_KEY (clave gratuita en https://api.windy.com/keys, servicio "Webcams API";
secreto de servidor, sin prefijo NEXT_PUBLIC_). Sin ella la app funciona igual y solo se ven los niveles 1 y 4.