Tu diario futbolero.
Guarda y revive todos los partidos que has visto a lo largo de tu vida.
🌐 getninety.app • Demo Railway • API • Concepto • Stack • Inicio rápido • Roadmap
Ninety no es otra app de resultados como Sofascore o Flashscore. Es el lugar donde cada aficionado construye su propia historia futbolera.
Inspirada en Letterboxd, Strava y Spotify Wrapped, pero aplicada al fútbol.
Cada usuario puede:
- Registrar los partidos que ha visto
- Guardar recuerdos, valoraciones, fotos y comentarios
- Generar estadísticas personales
- Compartir su historia con amigos
"Dentro de diez años, revivir exactamente cómo viviste cada partido importante de tu vida."
Frontend (React + Vite) → Backend (Express) → Supabase → Football API
El proyecto está separado en dos carpetas independientes que conviven en el mismo repositorio:
| Carpeta | Descripción | Puerto |
|---|---|---|
frontend/ |
React + TypeScript + Vite + Tailwind | 5173 |
backend/ |
Node.js + Express + TypeScript | 3001 |
- React 19 + TypeScript + Vite
- TailwindCSS 4 + shadcn/ui + Radix UI
- React Router 7
- TanStack Query
- Zustand
- React Hook Form + Zod
- Lucide React
- Node.js + Express 5
- TypeScript + Zod
- Supabase JS (publishable + Admin/service role)
- Supabase — PostgreSQL, Auth, Storage, Realtime, RLS
- football-data.org — Datos de partidos, equipos y competiciones
- Node.js 22.13 o superior (consulta
.nvmrc) - Cuenta en Supabase
- API key de football-data.org
git clone https://github.com/titicuevas/Ninety.git
cd Ninety- Crea un proyecto en Supabase
- Ejecuta la migración SQL en
supabase/migrations/20250702000000_initial_profiles.sql - Activa Google OAuth en Authentication → Providers (opcional)
- Copia las credenciales del proyecto
- Ejecuta las migraciones de
supabase/migrations/en el SQL Editor (en orden por fecha)- Incluye
20250724140000_capsule_photos_limit_9.sql(límite de fotos 6 → 9) - Incluye
20250802120000_collections.sql(colecciones del diario;positionpara orden curado),20250810160000_collection_cover.sql(portada / Capsule destacada) y20250821120000_collection_likes.sql(me gusta en listas públicas)
- Incluye
- Verifica con
npm run verify:capsules --prefix backend
# Backend
cp backend/.env.example backend/.env
# Frontend
cp frontend/.env.example frontend/.envRellena las variables con tus credenciales de Supabase y football-data.org.
# Si usas nvm, activa primero la versión soportada
nvm use
# Instalar dependencias
npm install
npm install --prefix backend
npm install --prefix frontend
# Ejecutar frontend + backend en paralelo
npm run dev- Frontend: http://localhost:5173
- Backend API: http://localhost:3001
- Health check: http://localhost:3001/api/health
| Servicio | URL | Estado |
|---|---|---|
| Frontend (canónico cutover) | www.getninety.app | CNAME → Railway (arreglar Target Port si 502) |
| Frontend (apex) | getninety.app | Solo si hay ALIAS/A de Railway (Namecheap: quitar URL Redirect) |
| Frontend (legacy) | ninety.up.railway.app | Mantener durante transición |
| API | ninety-api.up.railway.app | Express; migrar a api.getninety.app |
| Health front | /health | {"status":"ok"} |
| Health API | /api/health | ✅ Online |
Checklist ops A→E (puerto, Namecheap, vars, Supabase, email): docs/auth-setup.md. Dominio propio de API, staging aislado y comprobaciones de salida: docs/production-readiness.md. Si Railway muestra Application failed to respond: Target Port del dominio =
PORTdel contenedor (logs:listening on http://0.0.0.0:N). No uses4173a ciegas. Si preview Vite da 403:frontend/vite.config.ts→preview.allowedHosts(ya incluye www/apex).
Frontend (ninety) — rebuild tras cambiar VITE_*:
VITE_API_URL=https://ninety-api.up.railway.app
API_URL=https://ninety-api.up.railway.app
SITE_URL=https://www.getninety.app
VITE_SITE_URL=https://www.getninety.app
API_URL/SITE_URLlas usaserve.mjspara previews Open Graph (WhatsApp, X, Telegram) en/c/:id,/u/:username,/u/:username/lists/:slugy/u/:username/vs. El frontend ya no incluye el SDK de Supabase: la auth va por/api/auth/*en el backend.
Backend (ninety-api):
NODE_ENV=production
CLIENT_URL=https://www.getninety.app
# Opcional (apex + Railway de fallback); CORS ya permite www/apex/Railway por defecto:
# CORS_ORIGINS=https://getninety.app,https://ninety.up.railway.app
SUPABASE_URL=https://tu-proyecto.supabase.co
SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
SUPABASE_SECRET_KEY=sb_secret_...
FOOTBALL_DATA_API_KEY=tu-api-keyEn Supabase → Authentication → URL Configuration (Site URL = canónico www; mantén Railway):
https://www.getninety.app/auth/callback
https://www.getninety.app/auth/reset-password
https://www.getninety.app/**
https://getninety.app/auth/callback
https://getninety.app/auth/reset-password
https://getninety.app/**
https://ninety.up.railway.app/auth/callback
https://ninety.up.railway.app/auth/reset-password
https://ninety.up.railway.app/**
Guía completa de auth + dominio: docs/auth-setup.md
Cuenta de prueba (seed en Supabase):
| Campo | Valor |
|---|---|
demo@ninety.app |
|
| Username | @beta_ninety (showcase vivo) / @aficionado_demo (seed:demo) |
| Perfil público | /u/beta_ninety (o Railway) |
En backend/.env (no subir a Git):
TEST_USER_EMAIL=demo@ninety.app
TEST_USER_PASSWORD=tu-contraseña-demoCon la API en marcha (npm run dev):
npm run seed:demoCrea 5 capsules con fotos de prueba para el usuario demo.
Sin necesidad de tener la API en marcha:
npm run seed:fansCrea 24 usuarios (fan01@ninety.app … fan24@ninety.app) con perfil, avatar del escudo de su equipo, 1–4 partidos públicos, red de follows (incluye enlaces con @beta_ninety si ya existe) y likes/comentarios entre los fans que sigue el demo. Comparten la contraseña de DEMO_FANS_PASSWORD o, si no está definida, TEST_USER_PASSWORD en backend/.env. También limpia listas residuales E2E … y reseñas/Capsules de e2e del perfil demo, deja la lista destacada Favoritos y siembra likes/comentarios en ella y en Capsules públicas del demo. El username del demo es DEMO_USERNAME (por defecto @beta_ninety).
# Unit backend
npm test
# Unit helpers frontend (fotos)
npm test --prefix frontend
# Smoke Supabase + API
npm run smoke:v1
# Demo flow API (login, capsules, follow…)
npm run demo:flow
# —— Playwright E2E ——
# Requiere: npx playwright install chromium
# Auth: TEST_USER_PASSWORD en backend/.env
npm run test:e2e:public # smoke público + a11y (sin login; CI)
npm run test:e2e:smoke # smoke público + home/feed autenticados
npm run test:e2e:critical # búsqueda, follow lists, nav móvil
npm run test:e2e:a11y # axe público + autenticado (auth: TEST_USER_PASSWORD)
npm run test:e2e:mobile # Pixel 5
npm run test:e2e # suite completa
npm run test:e2e:ui # modo UI Playwright
# Combo rápido QE local
npm run test:qa
# Lo mismo que GitHub Actions «CI» (secretos + typecheck + unitarios)
npm run test:ciEstructura e2e/:
| Carpeta | Qué cubre |
|---|---|
smoke/ |
Salud mínima (landing, login form, API health, home) |
critical/ |
Flujos de negocio (buscar aficionados, listas, capsule pública, nav móvil) |
a11y/ |
Accesibilidad con axe-core (público + autenticado) |
helpers/ |
Login, token, asserts a11y |
Tags: @smoke · @critical · @a11y · @mobile
Probar en móvil (375px), tablet (768px) y desktop (1280px):
| Ruta | Qué verificar |
|---|---|
/ |
Landing, preview Wrapped, CTAs apilados en móvil |
/login |
Formulario centrado, inputs táctiles |
/home |
Wrapped en grid, banner perfil en columna en móvil |
/feed |
Cards a ancho completo, likes/comentarios sin overflow |
/activity |
Timeline de follows (Capsules + listas + me gusta + comentarios); filtros ?type=; empty states y scroll infinito |
/search |
Grid 1 col móvil → 2 cols tablet+ |
/capsules/new |
Formulario max-w-md → lg:max-w-xl |
/profile |
Avatar y formulario legibles |
/u/beta_ninety |
Perfil público sin login, botón Seguir |
/u/beta_ninety/followers |
Lista pública de seguidores |
/u/beta_ninety/following |
Lista pública de seguidos |
/c/:id |
Capsule pública, compartir, comentarios en lectura |
Nav: tab bar inferior en móvil y tablet (<1024px); nav en header desde lg. Safe areas (notch / home indicator) en iOS.
Auditoría automática de calidad React (performance, accesibilidad, seguridad, mantenibilidad).
# Requiere Node >= 22.13
nvm use
npm run doctor # escanear frontend
npm run doctor:install # instalar skill para Cursor + CI| Comando | Descripción |
|---|---|
npm run doctor |
Escaneo completo con detalle |
npm run doctor:install |
Skill para Cursor + workflow GitHub Actions |
- Skill del agente:
.agents/skills/react-doctor/ - CI en PRs:
.github/workflows/react-doctor.yml - Documentación: react.doctor
Reglas que seguimos: LazyMotion + useReducedMotion, botones con type explícito, componentes React 19 sin forwardRef, Storage preparado vía useCapsuleStorage.
Evitar filtrar claves (Supabase secret, VAPID, tokens) en el repo:
| Capa | Qué |
|---|---|
| Local | npm run check:secrets — patrones propios (Supabase secret, VAPID, passwords de demo) |
| CI | Typecheck + unitarios (.github/workflows/ci.yml); e2e público en www; TruffleHog |
Workflows en push/PR a main:
| Workflow | Qué corre |
|---|---|
| CI | Secretos, migraciones, auditoría npm, tipos, código muerto, build/bundle, tests y umbrales de cobertura |
| QA E2E | Playwright público + a11y contra QA_BASE_URL; usa producción como fallback temporal |
| QA E2E (auth) | Suite autenticada solo cuando staging (QA_BASE_URL y QA_API_URL) está configurado, con TEST_USER_PASSWORD obligatorio |
| TruffleHog | Secretos verificados en el diff |
| React Doctor | Score en el frontend (informativo) |
Para staging: GitHub → Settings → Secrets and variables → Actions → variables QA_BASE_URL y QA_API_URL, más el secret TEST_USER_PASSWORD de la cuenta QA. Sin ese secret, el job autenticado falla explícitamente.
- Workflow secretos:
.github/workflows/trufflehog.yml - Escanea el rango del push/PR y falla si encuentra secretos verificados (
--results=verified) - Los secretos reales viven solo en
.env(gitignored) y en variables de Railway/Supabase/GitHub Actions
npm run check:secretsNinety/
├── frontend/ # App React
│ ├── src/
│ │ ├── components/ # UI reutilizable
│ │ ├── hooks/ # Custom hooks
│ │ ├── lib/ # Supabase, API client
│ │ ├── pages/ # Páginas/rutas
│ │ ├── routes/ # Configuración de rutas
│ │ ├── stores/ # Zustand stores
│ │ └── types/ # Tipos TypeScript
│ └── ...
├── backend/ # API Express
│ ├── src/
│ │ ├── config/ # Variables de entorno
│ │ ├── lib/ # Clientes (Supabase)
│ │ ├── middleware/ # Auth, errores
│ │ └── routes/ # Endpoints REST
│ └── ...
├── supabase/
│ └── migrations/ # Esquema SQL
└── README.md
| Método | Ruta | Auth | Descripción |
|---|---|---|---|
| GET | /api/health |
— | Health check |
| POST | /api/auth/login |
— | Login email/contraseña |
| POST | /api/auth/register |
— | Registro |
| POST | /api/auth/refresh |
— | Renovar sesión con refresh_token |
| POST | /api/auth/oauth/google |
— | Iniciar OAuth Google |
| POST | /api/auth/oauth/exchange |
— | Intercambio PKCE OAuth |
| POST | /api/auth/delete-account |
✅ | Borrado self-serve (confirma confirm_email; irreversible) |
| GET | /api/profile/me |
✅ | Perfil propio |
| PATCH | /api/profile/me |
✅ | Actualizar perfil |
| GET | /api/profile/:username |
— | Perfil público (metadatos) |
| GET | /api/profile/:username/followers |
opcional | Lista de seguidores (followed_by_me, follows_me) |
| GET | /api/profile/:username/following |
opcional | Lista de seguidos (followed_by_me, follows_me) |
| GET | /api/profile/search |
✅ | Buscar aficionados (?q=; anota followed_by_me + follows_me) |
| POST/DELETE | /api/profile/:username/follow |
✅ | Seguir / dejar de seguir |
| GET | /api/capsules/me |
✅ | Mis capsules |
| GET | /api/capsules/me/liked |
✅ | Capsules a las que di me gusta (públicas + propias; filtra blocks) |
| GET | /api/capsules/me/:matchId/following |
✅ | Follows con Capsule pública del mismo partido (is_public + blocks) |
| GET | /api/capsules/me/export |
✅ | Export diario JSON/CSV (GDPR) |
| POST | /api/capsules/me/import |
✅ | Import diario desde export JSON (omite match_id existentes; restore_photos opcional re-sube photo_urls remotas) |
| GET | /api/capsules/feed |
✅ | Feed (seguidos + tuyo); scope, sort, photos=1, competition |
| GET | /api/activity |
✅ | Timeline de actividad de follows (Capsules + colecciones públicas nuevas + me gusta + comentarios; type=capsule|collection|like|comment; blocks; limit/offset) |
| GET | /api/capsules/user/:username |
opcional | Perfil + capsules + stats + stats_by_year (Wrapped por año) |
| GET | /api/capsules/user/:username/in-common |
✅ | Partidos en común (tu diario ∩ Capsules públicas del otro) |
| GET | /api/capsules/:id |
opcional | Capsule pública (compartir) |
| POST/PATCH/DELETE | /api/capsules… |
✅ | CRUD capsules |
| POST/DELETE | /api/capsules/:id/like |
✅ | Likes |
| GET | /api/capsules/:id/likes/following |
✅ | Follows que dieron me gusta a esta Capsule |
| GET | /api/capsules/:id/comments/following |
✅ | Follows que comentaron esta Capsule |
| GET/POST/DELETE | /api/capsules/:id/comments… |
✅ | Comentarios |
| GET | /api/collections/me |
✅ | Mis colecciones |
| GET | /api/collections/me/liked |
✅ | Listas a las que di me gusta (públicas + propias; filtra blocks) |
| GET | /api/collections/me/export |
✅ | Export colecciones JSON (GDPR; ítems por match_id) |
| POST | /api/collections/me/import |
✅ | Import colecciones desde export JSON (omite slug existentes; enlaza Capsules por match_id) |
| GET | /api/collections/me/containing/:capsuleId |
✅ | Colecciones que ya incluyen una Capsule |
| POST/PATCH/DELETE | /api/collections… |
✅ | CRUD colecciones + ítems (cover_capsule_id en PATCH) |
| PUT | /api/collections/:id/items/reorder |
✅ | Reordenar Capsules (position) |
| GET | /api/collections/discover |
✅ | Descubrir listas públicas ajenas (q, `sort=relevant |
| GET | /api/profile/discover |
✅ | Sugerir aficionados (limit máx. 24; reason=favorite_team / nearby / active; match_reason: equipo / ciudad / país / active; filtra bloqueos) |
| GET | /api/profile/by-team |
✅ | Fans del mismo club favorito (?slug= + paginación; filtra user_blocks) |
| GET | /api/collections/user/:username |
opcional | Colecciones públicas |
| GET | /api/collections/user/:username/:slug |
opcional | Detalle colección (/u/:username/lists/:slug) |
| POST/DELETE | /api/collections/:id/like |
✅ | Me gusta en colección (pública o propia; respeta blocks) |
| GET | /api/collections/:id/likes |
opcional | Quién dio me gusta |
| GET | /api/collections/:id/likes/following |
✅ | Follows que dieron me gusta a esta lista |
| GET | /api/collections/:id/comments/following |
✅ | Follows que comentaron esta lista |
| GET | /api/football/matches/search |
✅ | Buscar partidos |
| GET | /api/football/competitions |
✅ | Competiciones |
| GET/PATCH | /api/notifications/preferences |
✅ | Preferencias por tipo + push aniversario/hito/Quiero ir opt-in + digest email opt-in + horario silencioso (push_quiet) |
| GET/POST/DELETE | /api/notifications/muted… |
✅ | Silenciar / reactivar usuario (alertas in-app + push) |
| GET/POST/DELETE | /api/profile/blocked… |
✅ | Bloquear / desbloquear usuario (ocultar perfil + Capsules) |
| POST/GET | /api/reports… |
✅ | Reportar usuario, Capsule o colección pública (target_type; cola admin-ready) |
| DELETE | /api/want-to-go/played |
✅ | Quitar de Quiero ir los ya jugados sin Capsule |
| GET | /api/want-to-go/me |
✅ | Mi Quiero ir (also_want_to_go de follows; un query por página) |
| GET | /api/want-to-go/user/:username |
opcional | Quiero ir público (próximos; sin notas; respeta blocks) |
| GET | /api/notifications |
✅ | Lista de alertas (actor.followed_by_me para seguir de vuelta) |
| POST | /api/internal/cron/push-digest |
cron | Digest push periódico (CRON_SECRET; agrupa likes/comentarios/follows) |
| POST | /api/internal/cron/push-diary |
cron | Push opt-in aniversarios/hitos + recordatorio Quiero ir (CRON_SECRET; diary_push_sent idempotente) |
| POST | /api/internal/cron/email-digest |
cron | Digest email semanal del diario (CRON_SECRET + Resend; opt-in; lunes en TZ; diary_email_digest_sent) |
| GET/POST | /api/email-digest/unsubscribe |
público | Baja one-click del digest email (firma HMAC) |
- Buscar partidos (ligas, copas, Champions)
- Crear y editar Capsules
- Mis Capsules + feed social básico
- Estadísticas básicas en Home
- Perfil editable
- Deploy en Railway
- Páginas legales (privacidad y términos)
- Fotos en Capsules
- Google OAuth
- Perfiles públicos
- Wrapped visual mejorado
- Likes en capsules
- Comentarios en capsules
- Seguir usuarios
- Listas de seguidores / seguidos
- Compartir (enlace público
/c/:id) - Previews Open Graph al compartir
- Wrapped anual
- IA (resúmenes, recomendaciones)
- Gamificación (insignias, logros)
- Mapa de estadios
- Estadísticas avanzadas
- Comparar diarios (cara a cara) — partidos, media, estadio y overlap de equipos vs otro aficionado (
/u/:username/vs) - Colecciones del diario — listas curadas de Capsules (“Clásicos”, “Viajes”, “Noches de Champions”) compartibles al estilo Letterboxd (
/collections,/u/:username/lists/:slug; migración20250802120000_collections.sql) - Exportar mi diario — descarga JSON/CSV desde Ajustes (backup y portabilidad GDPR; Capsules del diario, sin colecciones)
- Instalar Ninety (PWA) — añadir a pantalla de inicio desde Ajustes + shell offline ligero (
manifest.jsoncon PNG 192/512 + maskable,apple-touch-icon,sw.js,/offline.html) - Previews Open Graph en colecciones públicas y cara a cara (
serve.mjs)
- Colecciones descubribles — CTAs en Perfil / Mis Capsules / perfil público, «Añadir a colección» desde Capsule, empty states y humo e2e
- Cara a cara más visual (avatares, proporciones, equipos en común vacíos)
- Digest / recordatorios ligeros para volver al diario — tarjeta en Inicio (resumen semanal / nudge / hueco) + preferencia en Ajustes; on-device, sin emails
- Onboarding de valor (primera colección, primer compare)
- Aniversarios del diario («Tal día como hoy») — card en Inicio cuando hace ≥1 año viste un partido el mismo mes/día + preferencia en Ajustes; on-device, prioridad sobre digest, sin emails
- Hitos del diario — card en Inicio al llegar a 5 / 10 / 25 / 50 / 100 / 250 Capsules + preferencia en Ajustes; on-device, tras aniversario y antes del digest, sin emails
- Reordenar Capsules en colecciones — subir/bajar sobre la columna
position(ya en schema); el orden se refleja en la lista pública (PUT /api/collections/:id/items/reorder) - Portada de colección — destacar una Capsule o usar la primera foto como cover en listas públicas
- Importar diario desde export JSON — complemento GDPR al backup actual (Ajustes +
POST /api/capsules/me/import; fotos opcionales víarestore_photos) - Preferencias de alertas por tipo — silenciar likes / comentarios / follows (in-app + push), sin emails (
GET/PATCH /api/notifications/preferences; migración20250811143000_notification_preferences.sql) - Filtros del feed — competición o «solo con fotos» sobre Siguiendo / Explorar (
photos=1,competitionen/api/capsules/feed+ chips en/feed) - Pulido a11y en flujos clave — foco, landmarks y labels en colecciones y notificaciones
- Digest de actividad social — likes / comentarios (por cápsula) y follows agrupados en el centro de alertas; avatares apilados y marcar leído por grupo
- Empty states post-import — guiar al usuario tras restaurar el diario (colecciones, feed, comparar)
- Filtros en el centro de alertas — ver solo likes / comentarios / follows
- Silenciar usuario concreto — no recibir alertas de un actor (in-app + push), desde la fila o el perfil (
GET/POST/DELETE /api/notifications/muted; migración20250811170000_notification_mutes.sql) - Horario silencioso de push — franja local (timezone del dispositivo) sin push; in-app sí (
push_quietenGET/PATCH /api/notifications/preferences; migración20250811180000_notification_push_quiet_hours.sql) - Seguir de vuelta desde un follow — acción rápida en la fila del digest (
followed_by_meen actores deGET /api/notifications+ CTA en follows de un solo actor)
- Badge «Te sigue» + seguir de vuelta fuera del digest —
follows_meen perfil, listas, búsqueda, me gusta y Capsule pública; CTA «Seguir de vuelta» cuando aún no hay follow mutuo - Exportar / importar colecciones — incluir listas curadas en el backup GDPR (Ajustes +
GET/POST /api/collections/me/export|import; ítems pormatch_id) - Digest de push — agrupar likes/comentarios/follows en un push periódico en lugar de uno por evento (
push_sent_at+flushPushDigests; cronPOST /api/internal/cron/push-digesto intervalo 15 min en prod) - Restaurar fotos al importar el diario — re-subida opcional de
photo_urlsdel export (restore_photosen Ajustes +POST /api/capsules/me/import; solo URLs http/https accesibles, máx. 9/Capsule y 200/import) - Explorar colecciones ajenas — descubrimiento de listas públicas más allá del perfil (
GET /api/collections/discover+/collections/explore) - Borrado de cuenta self-serve — eliminar cuenta desde Ajustes (
POST /api/auth/delete-account; confirma email; cascade + limpieza Storage)
- Partido manual — guardar Capsules fuera del catálogo football-data (amistosos, locales, torneos no indexados) con
match_idnegativo estable (/search/manual+ CTAs en búsqueda) - Deep links en push digest — like/comentario abren
/c/:id(o#comments); follow único al perfil; resumen multi →/notifications - Colecciones en la navegación principal — acceso a Mis listas / Explorar desde el shell (
Listasen nav + subnav Mis listas / Explorar) - Legales alineados con borrado self-serve — Privacidad y Términos ya no piden email manual tras
POST /api/auth/delete-account - Bloquear usuario — dejar de ver Capsules/perfil de alguien (más allá del mute de alertas) (
GET/POST/DELETE /api/profile/blocked; migración20250813100000_user_blocks.sql; feed/discover/cápsula pública filtran bloqueos bidireccionales) - Discovery en frío — perfiles/colecciones útiles sin follows ni equipo favorito (
match_reason: activeen discover; empty states del feed/Inicio enlazan a/collections/explore)
- Reportar usuario / Capsule — denunciar abuso; complementa bloquear (migración
20250814120000_content_reports.sql+POST/GET /api/reports+ UI en perfil/cápsula pública; cola admin-ready vía service role, sin panel admin; colecciones en v16) - Enlace de invitación — compartir Ninety con deep link/código que atribuya o lleve a registro (
/invite/:username+?ref=; migración20250815120000_invite_attributions.sql;GET/POST /api/invites; UI Perfil/Ajustes) - Lista «Quiero ir» — partidos futuros/interesantes tipo watchlist Letterboxd (
/want-to-go+GET/POST/DELETE /api/want-to-go; migración20250816120000_want_to_go_matches.sql; CTA en búsqueda/partido manual; subnav Listas + Perfil) - Calendario del diario — vista mes de Capsules por fecha (
/diary/calendar+GET /api/capsules/me/calendarporwatched_at; acceso desde Mis Capsules / Perfil) - Push de aniversarios / hitos — opt-in en Ajustes (
push_anniversary/push_milestone); cronPOST /api/internal/cron/push-diary(+ intervalo 1h en prod); idempotenciadiary_push_sent; migración20250817120000_diary_push.sql; cards on-device siguen independientes - Página de equipo — fans del mismo club favorito (descubrimiento por equipo) (
/teams/:slug+GET /api/profile/by-team; club favorito clickable en perfil/búsqueda; filtra bloqueos) - Digest email semanal — resumen opt-in del diario vía Resend (
email_digesten Ajustes; cronPOST /api/internal/cron/email-digest; idempotenciadiary_email_digest_sent; migración20250818120000_email_digest.sql; baja one-click; no mezcla con digest push social)
- Recordatorio «Quiero ir» — push opt-in cuando se acerca un partido de la watchlist (ventana ~48 h;
push_want_to_goen Ajustes; mismo cronPOST /api/internal/cron/push-diary; idempotenciadiary_push_sentkindwant_to_go; deep link/want-to-go; migración20250819120000_want_to_go_push.sql; respeta quiet hours) - Notas / reseña corta en Capsule — texto libre además del rating (
note, máx. 1000; crear/editar + detalle propio/público; sin migración nueva — columna desde20250705120000_capsules.sql) - Tags en Capsules — etiquetas propias (clásico, viaje, derbi…) filtrables en Mis Capsules (
tags text[]máx. 8×24; crear/editar + filtro?tag=enGET /api/capsules/me; visibles en detalle/listado; migración20250820120000_capsule_tags.sql) - Me gusta en colecciones — señal social ligera en listas públicas (
POST/DELETE /api/collections/:id/like,GET /api/collections/:id/likes; contador + lista; migración20250821120000_collection_likes.sql; respeta blocks; sin likes en privadas ajenas) - Compartir calendario / mes del diario — link shareable + preview OG (
/u/:username/calendar/:year/:month+GET /api/capsules/user/:username/calendar; solo Capsules públicas; botón compartir/copiar en/diary/calendar) - Mentions en comentarios — @usuario notifica y enlaza al perfil (tipo
mention; máx. 5/comentario; sin self/blocked/dueño Capsule; prefs de comentarios; migración20250822120000_notification_mentions.sql)
- Respuestas a comentarios — hilos ligeros bajo un comentario (1 nivel); notifica al padre (
parent_idencapsule_comments;POSTconparent_id; UI hilo; tipocommental autor del padre; migración20250823120000_capsule_comment_replies.sql; blocks/mutes/prefs víanotifyUser) - Editar / borrar comentario propio — confirmación al borrar;
edited_atal editar;PATCH/DELETE /api/capsules/:id/comments/:commentId; UI en propio (incl. replies); ownership; migración20250824120000_capsule_comments_edited_at.sql - Comentarios en colecciones públicas — charla en la lista (además de likes);
GET/POST/PATCH/DELETE /api/collections/:id/comments; UI en página pública; blocks; sin comentarios en privadas ajenas; migración20250825120000_collection_comments.sql - Quiero ir en común — ver quién de tus follows también tiene ese partido (
GET /api/want-to-go/me/:matchId/following; UI ligera en/want-to-go) - Tags en diario público — filtrar Capsules públicas por tag en perfil ajeno (
?tag=+ chips;tagsen respuesta deGET /api/capsules/user/:username) - Colección destacada en perfil — pin de una lista pública (
featured_collection_iden profiles;PATCH /api/profile/me; visible en perfil público; migración20250826120000_featured_collection.sql)
- Feed de actividad de follows — timeline ligera aparte del feed de Capsules (
GET /api/activity+/activity; nuevas Capsules/colecciones públicas de gente que sigues; respeta blocks; paginaciónlimit/offset) - Guardar búsqueda / filtros del diario — recordar último tag/filtro en Mis Capsules (
localStorageninety.diaryFilters:v1:+ query sticky víauseDiaryFilterParams({ persist: true })) - Soft nudge «completa tu Capsule» — si hay rating sin nota/fotos, card discreta en Home (on-device;
ninety.incompleteCapsule:v1:; sin backend) - Notificación de like en colección — opt-in reutilizando prefs de likes (
collection_like+collection_id; migración20250827120000_notification_collection_likes.sql; in-app + digest push) - Estadio favorito / mapa enriquecido — destacar sede más visitada + deep link al diario (
context=stadium) y a Capsules (/c/:id); pin dorado en mapa - Export Wrapped / mes como texto compartible mejorado — copy one-tap (
Copiar texto) desde Wrapped y calendario; resumen enriquecido (buildWrappedShareText/buildDiaryMonthShareText)
- Filtros en Actividad — ver solo Capsules o solo listas (
?type=capsule|collectionenGET /api/activity+ chips en/activity) - Buscar / ordenar en Explorar colecciones —
q+sort=relevant|recent|likesenGET /api/collections/discovery UI en/collections/explore(también/search?tab=lists) - Respuestas en comentarios de colecciones — hilos 1 nivel (
parent_id; migración20250828120000_collection_comment_replies.sql; paridad con Capsules) - Compartir perfil como texto — resumen one-tap (
Copiar texto/ Compartir) desde perfil público y Ajustes de perfil (buildProfileShareText) - Soft nudge «Quiero ir» en Home — card cuando hay partidos cercanos en la watchlist (además del push; on-device
ninety.wantToGoNudge:v1:; ventana ~48 h)
- Notificación de comentario en colección — avisar al dueño (y al padre en reply) al comentar una lista pública (
type: comment+collection_id; deep link a la lista; prefs/digest de comentarios) - Mentions en comentarios de colecciones —
@usuarionotifica como en Capsules (extendernotifyCommentMentionsacollectionId; sin self/blocked) - Auto-quitar de Quiero ir al crear Capsule — al guardar el partido, salir de la watchlist sin paso manual (invalidar
want-to-go) - Soft nudge «ya jugó» en Quiero ir — card en Home cuando un partido de la lista ya pasó y aún no hay Capsule (on-device; CTA a crear Capsule; ventana ~14 días; prioridad tras el nudge pre-partido)
- Compartir colección como texto — resumen one-tap (
Copiar texto/ Compartir) además del enlace (buildCollectionShareText)
- Compartir Capsule como texto — resumen one-tap (
Copiar texto/ Compartir) con equipos, rating y nota además del enlace (buildCapsuleShareText; paridad con perfil / colección / mes) - Reportar colección — denunciar listas abusivas;
content_report_target_type+collection(migración20250829120000_content_reports_collection.sql) + UI en lista pública (POST/GET /api/reports; misma cola que usuario/Capsule) - También lo vieron — quién de tus follows tiene Capsule del mismo partido (
GET /api/capsules/me/:matchId/following; respetais_publicy blocks; UI en Capsule pública; índicecapsules_public_match_id_idx) - Mis me gusta — archivo de Capsules que te gustaron (
GET /api/capsules/me/liked+/likes; índicecapsule_likes_user_created_idx) - Quiero ir: próximos vs ya jugados — chips en
/want-to-go(?when=upcoming|played) + CTA «limpiar ya jugados sin Capsule» (DELETE /api/want-to-go/played)
- axe WCAG 2 A/AA en home, feed, Capsule, Mis listas y Quiero ir (además de landing / login / perfil público)
- Modales nativos (
<dialog>): foco al abrir, Esc y restauración al cerrar (reportar, confirmar, me gusta) - Teclado en chips e iconos de pantallas recientes + contraste de chips inactivos
- Listas que te gustaron — archivo de colecciones (
GET /api/collections/me/liked+/collections/likes; índicecollection_likes_user_created_idx)
- También les gusta — quién de tus follows dio me gusta a una lista pública (
GET /api/collections/:id/likes/following; paridad con También lo vieron / Quiero ir en común)
- También le gusta — quién de tus follows dio me gusta a una Capsule (
GET /api/capsules/:id/likes/following; paridad con listas)
- Actividad de me gusta — ver cuando alguien que sigues da me gusta a una Capsule o lista pública (
capsule_like/collection_likeenGET /api/activity)
- Actividad de comentarios — ver cuando alguien que sigues comenta una Capsule o lista pública (
capsule_comment/collection_commentenGET /api/activity; índicescapsule_comments_user_created_idx/collection_comments_user_created_idx)
- También comentaron — quién de tus follows comentó una Capsule (
GET /api/capsules/:id/comments/following; paridad con También le gusta)
- También comentaron — quién de tus follows comentó una lista pública (
GET /api/collections/:id/comments/following; paridad con Capsules)
- Seed de likes/comentarios entre fans que sigue el demo (
npm run seed:fans) y limpieza de listasE2E …para no ensuciar el perfil público
- Gramática del contador —
1 comentario/N comentariosen Capsules, listas y barra de invitados
- Limpieza de reseñas
Guardado E2E …/ CapsulesE2E fotosyTest E2Een el perfil demo (npm run seed:fans); el e2e de editar restaura la reseña y el de fotos borra la Capsule creada
- Lista Favoritos fijada en el perfil público demo (
featured_collection_idvíanpm run seed:fans) para que/u/beta_ninetyse lea como un diario Letterboxd
- Likes/comentarios de fans que sigue el demo en Favoritos (Actividad + «también le gusta / comentaron» al abrir la lista)
- Likes/comentarios de follows en una Capsule pública del demo (
npm run seed:fans) para que el diario y «también le gusta / comentó» no se vean vacíos
- Tras limpiar «Guardado E2E …»,
npm run seed:fansvuelve a poner las reseñas canónicas del diario demo (p. ej. Bournemouth–Liverpool con likes/comentarios)
- Preview de likes/comentarios/publicaciones de follows en Comunidad (
/home) con enlace a/activity - Suite e2e
demo-showcase.spec.tsque valida diario demo, Favoritos, Capsule social y Actividad
- Contador en el botón Actividad de Home y Feed cuando hay eventos de follows (aria-label accesible)
- Enlace Actividad con badge en nav desktop (≥ lg) y en el header móvil; la tab bar sigue con 6 ítems
- E2e: perfil público invitado muestra me gusta/comentarios en el diario del demo
-
npm run test:demo— unitdemoSocialSeed+ e2e showcase (requiere API local ynpm run seed:fans)
- En el perfil público, las Capsules con likes/comentarios muestran «también le gusta / comentó» sin abrir el detalle (Letterboxd-style)
-
npm run seed:fanssiembra likes/comentarios en 3 Capsules públicas del demo (no solo la más reciente)
- La colección destacada del perfil público muestra me gusta/comentarios (y «también le gusta / comentó» si estás logueado)
- El feed Siguiendo muestra «también le gusta / comentó» en las Capsules con engagement, igual que el diario público
-
/likesy listas que te gustaron muestran follows que también dieron me gusta (archivo Letterboxd)
-
/capsulesmuestra likes/comentarios y «también le gusta / comentó»;GET /api/capsules/meincluye contadores - Los e2e de showcase saltan si el demo de prod no tiene
seed:fans(QA E2E público ya no falla)
- QA E2E y helpers apuntan a
@beta_ninety(donde viveseed:fans); los tests exigen Favoritos, reseñas y Capsules sociales en lugar de saltar
-
/collections/explore(y sugerencias del feed) muestran likes/comentarios y «también le gusta / comentó»;GET /api/collections/discoverincluyecomments_count
-
/collectionsmuestra likes/comentarios y «también le gusta / comentó» en las listas públicas;GET /api/collections/meincluyecomments_count
- El grid de colecciones en
/u/:usernamemuestra comentarios y «también le gusta / comentó»;GET /api/collections/user/:usernameincluyecomments_count
- Comunidad en
/homemuestra «también le gusta / comentó» en el preview del feed (prioriza Capsules con engagement; listas sugeridas si el feed está vacío)
- El enlace de la portada en el perfil público (y Mis listas) queda oculto a AT (
aria-hidden) para no romperlink-nameen QA E2E
-
GET /api/collections/:idy el detalle público incluyencomments_count; Favoritos y Mis listas muestran el recuento en la ficha
- En
/collections/:id(lista pública) el dueño ve y responde el hilo sin ir a «Ver pública»
- El detalle público de una lista muestra likes/comentarios y «también le gusta / comentó» en cada Capsule (
GETde detalle incluye contadores encapsules)
- En
/collections/:id(lista pública) cada Capsule muestra likes/comentarios y «también le gusta / comentó», igual que la vista pública
- El día abierto en
/diary/calendary/u/:username/calendar/:year/:monthmuestra likes/comentarios;GET /api/capsules/me/calendarincluye contadores
-
/activitymuestra likes/comentarios de la Capsule o lista;GET /api/activityincluyelikes_countycomments_count
- «Actividad reciente» en Home muestra likes/comentarios de la Capsule o lista, igual que
/activity
-
PATCH /api/collections/:iddevuelvelikes_countycomments_count(el detalle no parpadea a 0 al editar)
- Diario, feed, Mis Capsules, me gusta, listas y calendario muestran quién de tus follows vio el mismo partido (
also_watcheden un query por página) -
npm run seed:fanssiembra Capsules de follows con el mismomatch_idque el demo - E2e del pie social en el calendario propio
- Dos follows por partido en
seed:fans(feed/Home/me gusta) + preview de Home priorizaalso_watched - E2e en feed, Mis Capsules, me gusta, Favoritos, detalle propio, calendarios y Home
- Lista pública y calendario público recargan con sesión para no perder
also_watched
-
/activityy «Actividad reciente» en Home muestran quién de tus follows vio el mismo partido -
GET /api/activityincluyealso_watcheden eventos de Capsule (un query por página) - El preview de Home mezcla likes/comentarios y «también lo vieron»
- E2e chromium en Actividad y Home (sin asserts nuevos en QA público)
-
GET /api/*(salvo health y cron interno) limita 180 req/min por IP en producción -
trust proxyen producción para no mezclar a todo el mundo en una sola IP detrás de Railway - Auth, fútbol y comentarios siguen con techos más estrictos encima del global
-
PATCH /api/capsules/:iddevuelve likes, comentarios yalso_watched(el detalle no parpadea a 0 al editar)
- Búsquedas, competición y equipo favorito pasan por
sanitizePostgrestSearch(sin comodines ni puntuación de filtros) - Listas
.in()/.or()de IDs solo aceptan UUID; notas de Capsule quitan etiquetas HTML - Bloqueos no interpolan IDs que no sean UUID en el DSL de PostgREST
-
GET /api/capsules/:idadjuntaalso_watched(mismo campo que el feed) - El detalle usa esa lista cuando viene en la respuesta (sin fetch extra si la API ya la trae)
- E2e chromium con skip si prod aún no despliega el campo
- Feed, diario, me gusta, listas y GET/PATCH Capsule adjuntan
also_likedyalso_commented(sin un fetch por tarjeta) - El pie social usa esas listas cuando vienen en la API
- E2e chromium con skip si prod aún no despliega los campos
- Mis listas, Explorar, me gusta de listas, perfil y GET/PATCH de colección adjuntan
also_likedyalso_commented - El pie de la ficha usa esas listas (sin un fetch por tarjeta)
- E2e chromium con skip si prod aún no despliega los campos
-
GET /api/activityadjuntaalso_likedyalso_commenteden Capsules y listas (un query por página) - La colección destacada del perfil público incluye el mismo pie social
- Actividad y preview de Home pintan esas listas cuando vienen en la API
- E2e chromium con skip si prod aún no despliega los campos
-
GET /api/activity?type=like|comment(además de Capsules / listas) - Chips en
/activitypara ver solo me gusta o solo comentarios - E2e chromium con skip si prod aún no acepta
type=like
-
GET /api/capsules/user/:username/in-commoncruza tu diario con las Capsules públicas del otro (un query por página, sin N+1) -
/u/:username/vslista los partidos que ambos habéis guardado, con las dos notas - E2e chromium con skip si prod aún no despliega el endpoint
-
GET /api/profile/discoveraceptareason=favorite_team|nearby|activeylimithasta 24 (Home/Feed siguen pidiendo 6) - Chips Todas / Mismo equipo / Cerca / Activos en
/search?tab=peoplecuando la búsqueda está vacía -
qsticky en la URL (como partidos y Explorar colecciones) - E2e chromium con skip si prod aún no tiene los chips
-
GET /api/want-to-go/user/:usernamelista próximos partidos (sin notas; respeta blocks) - Sección en
/u/:usernamey página/u/:username/want-to-go - E2e chromium con skip si prod aún no despliega el endpoint
-
GET /api/want-to-go/meadjuntaalso_want_to_go(follows, un query por página) -
/want-to-gopinta esas personas sin un fetch por partido - E2e chromium con skip si prod aún no trae el campo
-
/search?tab=listsreutiliza Explorar colecciones (q+sortsticky) - Pestaña Listas junto a Partidos y Aficionados
- E2e chromium con skip si prod aún no trae la pestaña
- Logo SVG de campo de fútbol (gradiente de césped, líneas de campo, "90 MIN") que escala sin perder calidad
- Componente
NinetyLogoreutilizable con tamaños xs/sm/md/lg/xl y animación pulse - 4 loaders random: balón rebotando, silbato pulsante, tarjeta amarilla con flip 3D y red de portería sacudiéndose
- 10 frases de carga futboleras (rotación aleatoria)
- Landing con animaciones de entrada escalonadas (
landing-stagger) - Header y splash HTML actualizados con el nuevo logo
- Diario real de
@beta_ninety: stats auténticas (partidos, media, liga top, estadios, 5 estrellas) - Últimas 3 Capsules con escudos, marcador, competición, fecha y valoración en estrellitas
- Skeleton de carga mientras llegan los datos; fallback silencioso si falla la API
- Hook
useLandingShowcasecon caché de 10 min y retry 1
- Tarjeta de Capsule (feed, diario, listas, perfil) con escudos, marcador grande y foto a ancho de tarjeta
- Pie social en una línea («también lo vieron · le gusta · comentó») en Capsules, listas y Actividad
- Detalle
/c/:idcon el mismo ticket; copy vacío de Actividad alineado con los chips
- Tema: Oscuro, minimalista, premium
- Color principal: Verde esmeralda (
#10b981) - Tipografía: Inter
- Inspiración: Letterboxd, Strava, Spotify, Notion
Proyecto privado — todos los derechos reservados.
Hecho con ⚽ por aficionados, para aficionados.