diff --git a/README.md b/README.md index ff3279d..4dd5da8 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,11 @@ El alcance se estructuró en **seis fases incrementales**, cada una con objetivo ```text Fase 1 ──► Core Autenticación · Perfil · Partidas · Descubrir -Fase 2 ──► Resultados Notificaciones · Validación de marcadores · Reportes -Fase 3 ──► Administración Panel de moderación · Analíticas · Auditoría -Fase 4 ──► Torneos Cuadros eliminatorios · Parejas · Sincronización en tiempo real -Fase 5 ──► Marcador Conteo en vivo local · Acceso sin registro -Fase 6 ──► Ligas y stats Ligas round-robin / Elo · Estadísticas · Logros · Clasificación + Fase 2 ──► Resultados Notificaciones · Validación de marcadores · Reportes + Fase 3 ──► Administración Panel de moderación · Analíticas · Auditoría + Fase 4 ──► Torneos Cuadros eliminatorios · Parejas · Sincronización en tiempo real + Fase 5 ──► Marcador Conteo en vivo local · Acceso sin registro + Fase 6 ──► Ligas y stats Ligas round-robin / Elo · Estadísticas · Logros · Clasificación ``` --- @@ -185,7 +185,7 @@ La aplicación sigue una **arquitectura cliente-servidor** con separación clara ### 🔍 Descubrir -- Listado de partidas, torneos y **ligas** públicas con ordenación por proximidad temporal +- Listado de partidas, torneos y **ligas** públicas y **privadas con contraseña** (aparecen en Descubrir; la contraseña desbloquea la vista) - Filtros por tipo, ciudad, fecha, plazas libres, visibilidad y estado - Búsqueda por texto y paginación eficiente - Actualización en tiempo real sin recarga manual diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 49003b7..e417080 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -17,7 +17,7 @@ App móvil para jugadores de mus en España que permite encontrar contrincantes - **Plantilla mixta (may. 2026):** en crear/editar se pueden añadir compañeros/rivales **por nombre** además de cuentas registradas; las plazas (UI, explore y cron) cuentan texto + confirmados (máx. 2 por equipo). El creador puede registrar marcador **sin validación rival** solo si no hay otros participantes con cuenta y la partida está **`in_progress`** (`record_match_result_direct`). Tras aprobar un resultado rival, un trigger en BD confirma el resultado y finaliza la partida (`018`). - **Eliminación de cuenta (may.–ago. 2026):** derecho de supresión RGPD vía Edge Function `delete-account`. Se borran auth, perfil, avatar y datos personales (reportes, cola de notificaciones). El **historial compartido se anonimiza**, no se elimina: referencias de partidas, **ligas y torneos** (creador, parejas, retos, grants) pasan al perfil interno **Usuario eliminado** (sentinel) o a texto «Usuario eliminado» (mig. `023`–`025`, `105`). CORS de la función: orígenes de producción + loopback local (`localhost` / `127.0.0.1`) para desarrollo web. - **Recuperación de contraseña (jul. 2026):** el email de reset redirige a `jugamus://auth/update-password` (debe estar en Redirect URLs de Supabase). Pantalla dedicada para nueva contraseña; errores visibles en web (sin depender de `Alert.alert`); tras éxito, cierre de sesión y CTA a login. No reutilizar la misma contraseña (`same_password` → 422). -- **Notificaciones en perfil (may./jul. 2026):** preferencias **push** («Todas») y por **evento** en perfil; sin notificaciones por correo; enlaces legales (términos, privacidad) en la misma pantalla. Eventos: unión; partida/torneo inicio, edición y cancelación (separados); resultado pendiente de validar; resultado pendiente de enviar (aviso ~5 h en curso); recordatorios 24 h y/o 2 h antes (chips multi-selección). `enqueue_notification` y `process-notifications` respetan `notify_push` + `notify_on_*` (migraciones `077`–`079`). +- **Notificaciones en perfil (may./jul./ago. 2026):** preferencias **push** («Todas») y por **evento** en perfil; sin notificaciones por correo; enlaces legales (términos, privacidad) en la misma pantalla. Eventos: unión; partida/torneo inicio, edición y cancelación (separados); resultado pendiente de validar; resultado pendiente de enviar (aviso ~5 h en curso); recordatorios 24 h y/o 2 h antes (chips multi-selección); **solicitudes de amistad** e **invitaciones a partidas** (`notify_on_friend_request` / `notify_on_match_invitation`, mig. `110`). `enqueue_notification` y `process-notifications` respetan `notify_push` + `notify_on_*` (migraciones `077`–`079`, `110`). - **Branding (may. 2026):** icono y splash con diseño minimalista de baraja española (basto); color de fondo `#1a5f4a` en splash e icono adaptativo Android. - **UI Ultra Limpio (may. 2026):** rediseño visual con tokens en `src/theme/` (fondo blanco, verde `#1A5F4A`, tipografía DM Sans). Listas principales (Mis partidas, Descubrir) con filas y punto de estado; previews con `ciudad · lugar`; cabecera Mis partidas sin contador de activas; FAB speed-dial encima de la tab bar; tab bar activa en verde brand. - **Torneos (may. 2026):** eliminación directa con parejas mixtas (registradas + texto). Partidos del cuadro reutilizan `matches` (`tournament_id`, metadatos de ronda). FAB speed-dial: crear partida, torneo o liga. Cuadro visual en canvas SVG con resultados por enfrentamiento. Byes automáticos si faltan parejas para potencia de 2; **partidas bye no cuentan** en historial ni analíticas admin. Avance de ronda al confirmar resultado (incl. propagación bye y relleno parcial del cuadro); partido siguiente con `start_at = NOW()`. Torneo pasa a `finished` al cerrar la final (y el partido de 3º/4º si está activado). Organizador puede ser árbitro (sin jugar); registra resultado directo si todos los jugadores del partido son texto. **Un jugador registrado solo en una pareja** por torneo. Descubrir: filtro partidas/torneos/ligas; partidas del cuadro o de liga no listadas ni unibles manualmente como partidas sueltas. Detalle y tarjetas muestran organizador y `ciudad · lugar`. Lugar en formularios: nombre obligatorio o «Lugar por definir». Historial de perfil colorea victoria/derrota. Sincronización multi-dispositivo: Supabase Realtime (`058`) en `matches`, participantes, resultados, torneos y parejas; invalidación React Query en Descubrir, Mis partidas, historial y ficha/cuadro de torneo (sin polling 30 s en detalle). **Edición de parejas (may.–jun. 2026):** organizador o miembros de la pareja pueden editar nombre y plazas texto en inscripción; solo el organizador elimina parejas (`062`). Opcional **3º y 4º puesto** entre perdedores de semifinales (`064`). En la ficha de un partido del cuadro, enlace **🏆 Ir al torneo** al detalle del torneo. @@ -34,15 +34,16 @@ App móvil para jugadores de mus en España que permite encontrar contrincantes - **Torneos — parejas y 3º puesto (jun. 2026):** cualquier miembro de la pareja puede editarla en inscripción (`062`); eliminar sigue siendo solo organizador. Al editar, **no se pueden quitar** jugadores de texto (solo renombrar; `069`). Opción **3º y 4º puesto** al crear torneo: partido entre perdedores de semifinales (`064`); torneo no finaliza hasta cerrar final y dicho partido si aplica. - **Partidas y torneos privados (jun. 2026):** visibilidad `private` con contraseña del organizador/creador. Aparecen en Descubrir (filtro Privadas). La contraseña **desbloquea la vista** (plantel/cuadro); unirse sigue con el flujo normal (partida) o parejas (torneo). Migraciones `066`–`068`. - **Invitaciones WhatsApp HTTPS (jul. 2026):** compartir ficha de partida o torneo por WhatsApp con enlace `https://musapp-731e1.web.app/m|t/{id}` (Firebase Hosting + App/Universal Links). Cualquier usuario que pueda ver la ficha puede compartir; privadas piden contraseña al abrir; unirse requiere registro. Sin app → redirect a tienda. EAS: `EXPO_PUBLIC_INVITE_HOST`. -- **Sesión Auth (jul. 2026):** JWT/sesión local persistida se revalida con `getUser()` al arranque y al volver a primer plano; si el refresh falla (salvo error de red), logout local y aviso «Tu sesión ha caducado…». Timeout por background largo sigue vigente. +- **Sesión Auth (jul.–ago. 2026):** JWT/sesión local persistida se revalida con `getUser()` al arranque y al volver a primer plano; si el refresh falla (salvo error de red), logout local y aviso «Tu sesión ha caducado…». Timeout por background largo: **6 h** (`BACKGROUND_SESSION_TIMEOUT_MS` en `sessionBackground.ts`). - **Torneos — auto-cancel sin cuadro (jul. 2026):** en `registration` pasado `start_at` sin `bracket_generated_at` → `cancelled` (+ notificación al organizador). Crear/editar: título/ciudad/lugar opcionales (defaults «Torneo» / «Ciudad por definir»). Aviso al guardar. Migraciones `070`/`071`. - **Torneos — inscripción y cancelación (jul. 2026):** `entry_fee` en torneo (default 0); flag `entry_fee_paid` por pareja; el organizador puede **cancelar** un torneo (`cancel_tournament`, mig. `073`–`076`). Mis partidas lista torneos donde el usuario organiza o juega (partidas de cuadro solo si participa). -- **PostHog producto (jul. 2026):** eventos `user_signed_up`, `match_created`, `match_joined`, `match_completed` (este último solo al pasar a `finished`, idempotente por `match_id`). KPIs de panel deben usar estos eventos / `Application Opened`, no `$pageview`. +- **PostHog producto (jul./ago. 2026):** eventos `user_signed_up`, `match_created`, `match_joined`, `match_completed` (este último solo al pasar a `finished`, idempotente por `match_id`); `friend_request_sent`, `friend_request_accepted` (auto-aceptación), `match_invite_sent`, `match_invite_accepted` (v1.8). KPIs de panel deben usar estos eventos / `Application Opened`, no `$pageview`. - **Torneos — cuadro interactivo (jul. 2026):** cada pareja en tarjeta propia; tap en pareja para registrar resultado y avanzar (modal rápido o ficha de partido). Etiquetas de ronda (cuartos, semifinal, final). Confirmación destructiva al organizar cuadro. Al cancelar torneo se cancelan **todos** los partidos del cuadro (`082`). Fix auto-cancel al poblar la final (`081`). Notificación «Validar resultado» solo si el resultado queda `pending_validation` (`083`). -- **Versión app (ago. 2026):** **1.7.1** (`app.json`, `package.json`). Hotfix de calidad: suite de characterization tests (services/hooks/utils/lib), umbrales de cobertura y proceso TDD (`docs/testing.md`). +- **Amigos e invitaciones a partidas (ago. 2026, v1.8):** solicitudes de amistad con mensaje opcional (máx. 200) desde perfil ajeno o búsqueda por nombre; cooldown tras rechazo reciente; lista de amigos/solicitudes en perfil propio. Invitar amigos a pareja/rival al crear o editar equipo (solo partida standalone `planned`/`in_progress` del creador); pendientes ocupan plaza; compartir vía modal en ficha tras crear. Mis Partidas: Invitaciones; ficha: banner Aceptar/Rechazar. Rechazar en partida iniciada/finalizada (o con resultado confirmado) cancela y voidea resultado; rivales invitados fuerzan `pending_validation`. Migraciones `108`–`116`. +- **Versión app (ago. 2026):** **1.8.0** (`app.json`, `package.json`). Incluye hotfix de calidad v1.7.1 (characterization tests, umbrales de cobertura ≥60%, proceso TDD en `docs/testing.md`). - **Security hardening ligas/stats (ago. 2026, mig. `106`):** `process_league_lifecycle` solo vía pg_cron (REVOKE a PUBLIC/authenticated). `enqueue_player_stats_recompute` no ejecutable por clientes. `get_player_stats` recalcula ELO/agregados solo para el propio usuario o admin; el resto lee stats cacheadas (mitiga amplificación cross-user). - **UI responsive (jul. 2026):** helpers `useResponsiveLayout` / `ScrollableModalBody` para escalado tipográfico y modales con scroll seguro en pantallas pequeñas. -- **Crear partida — UX (jul.–ago. 2026):** título, ciudad y lugar **opcionales** al crear (defaults: «Partida», «Ciudad por definir», «Lugar por definir» si vacíos). Sin toggle «Lugar por definir» en creación. Fecha/hora por defecto **+10 min** respecto a ahora. Botón ✕ cierra a **Mis partidas** (no `back` a Descubrir). Sin autofocus/teclado al abrir el formulario. Etiquetas de campo en negrita; sin asteriscos ni «(opcional)» en labels de crear/editar. **Empezar partida:** sin modal de confirmación (sí aviso si plantilla incompleta). Edición de partida conserva validación anterior. Aviso si plantilla incompleta (auto-cancel al llegar `start_at`). **Partida ya jugada (ago. 2026):** si `start_at` es anterior a ahora, el formulario muestra el marcador al final (casillas vacías; nombres de equipo derivados como en ficha); exige plantilla completa; al crear registra resultado directo (`record_match_result_direct`, mig. `085` admite también `finished_no_result`) y abre la partida `finished`. Orden de pareja/equipo en formularios y tarjetas: **integrantes primero, nombre después**. +- **Crear partida — UX (jul.–ago. 2026):** título, ciudad y lugar **opcionales** al crear (defaults: «Partida», «Ciudad por definir», «Lugar por definir» si vacíos). Sin toggle «Lugar por definir» en creación. Fecha/hora por defecto **+10 min** respecto a ahora. Botón ✕ cierra a **Mis partidas** (no `back` a Descubrir). Sin autofocus/teclado al abrir el formulario. Etiquetas de campo en negrita; sin asteriscos ni «(opcional)» en labels de crear/editar. **Empezar partida:** sin modal de confirmación (sí aviso si plantilla incompleta). Edición de partida conserva validación anterior. Aviso si plantilla incompleta (auto-cancel al llegar `start_at`). **Partida ya jugada (ago. 2026):** si `start_at` es anterior a ahora, el formulario muestra el marcador al final (casillas vacías; nombres de equipo derivados como en ficha); exige plantilla completa (nombres o amigos invitados); al crear registra resultado (directo o `pending_validation` si hay rivales invitados) y abre la partida. Orden de pareja/equipo en formularios y tarjetas: **integrantes primero, nombre después**. **Invitar amigos (v1.8):** selector de amigos como compañero/rival al crear; pestaña «Añadir amigo» en editar pareja. - **Locales nativos (ago. 2026):** `locales/es.json` anida claves bajo `ios` (SDK 54+) para no generar `ExtraTranslation` en Android `lintVitalRelease`. - **Apple Sign In (jun. 2026):** nombre de perfil legible con `fullName` de Apple y backfill para relay emails (`060`–`061`). - **Plantilla y cron (may. 2026):** unirse solo en `planned`. Al llegar `start_at`, cron promueve a `in_progress` solo con roster completo (4 plazas); si no, `cancelled`. Partida con hora actual y plantilla llena queda `in_progress` al crear/unirse. Listas y fichas de partida/torneo se actualizan vía Realtime + refetch al foco. @@ -113,7 +114,8 @@ Solo dos roles en el MVP: - Foto de perfil (opcional, comprimida automáticamente a ≤ 500KB) - Preferencias de notificación en pantalla de perfil: - **Todas** (`notify_push`): master que enciende/apaga todos los eventos - - **Por evento:** unión; inicio / edición / cancelación de partida o torneo; resultado pendiente de validar; resultado pendiente de enviar; recordatorios 24 h y 2 h (selección independiente) — columnas `notify_on_*` (migraciones `022`, `078`, `079`) + - **Por evento:** unión; inicio / edición / cancelación de partida o torneo; resultado pendiente de validar; resultado pendiente de enviar; recordatorios 24 h y 2 h (selección independiente); solicitudes de amistad; invitaciones a partidas — columnas `notify_on_*` (migraciones `022`, `078`, `079`, `110`) +- **Amigos (v1.8):** sección desplegable en perfil propio (lista, solicitudes enviadas/recibidas, eliminar amigo); botón persona+plus en perfil ajeno para enviar solicitud con mensaje opcional (mig. `108` / `110`) - Enlaces a términos y política de privacidad desde el perfil - **Perfil de otro usuario (solo lectura):** nombre, ciudad, teléfono según permisos del servidor e historial de partidas visibles; navegación desde participantes registrados en detalle de partida - **El teléfono solo es visible para participantes de la misma partida confirmada** (también en perfil ajeno vía RPC) @@ -158,9 +160,18 @@ Solo dos roles en el MVP: - Cada usuario tiene acceso a su historial de partidas pasadas +**Amigos e invitaciones (v1.8)** + +- Al crear o editar la pareja de una partida standalone (`planned`/`in_progress`, creador), se pueden invitar amigos confirmados a equipo A o B (capacidad acotada; pestaña «Añadir amigo» solo si aplica) +- Las invitaciones pendientes ocupan plaza de roster (la partida `planned` empieza a `start_at` aunque queden pendientes); al aceptar se revalida capacidad +- Mis Partidas muestra sección «Invitaciones»; la ficha muestra banner Aceptar/Rechazar y modal de compartir (WhatsApp/enlace) +- Rechazar (o cancelar) una invitación en partida `in_progress`/`finished`/`finished_no_result` (o con resultado confirmado) cancela la partida y voidea el resultado +- Rivales invitados (pendientes o aceptados) fuerzan resultado `pending_validation` (también en partidas pasadas) +- Solicitudes de amistad: mensaje ≤200; cooldown tras rechazo reciente; búsqueda por nombre (`111`); eliminar amigo cancela invitaciones pendientes entre el par (`114`) + ### F5 - Descubrir y filtrar (Fase 1 + 6) -- Listado de partidas, torneos y ligas públicas ordenado por fecha +- Listado de partidas, torneos y ligas **públicas y privadas con contraseña** ordenado por fecha (filtro de visibilidad; las privadas aparecen en Descubrir y requieren contraseña para desbloquear la vista) - Filtros: tipo (partidas / torneos / ligas), fecha, ciudad/pueblo, plazas libres, visibilidad, estado - Búsqueda por texto en el campo título - Paginación de 20 elementos por página @@ -182,6 +193,7 @@ Solo dos roles en el MVP: - Push cuando alguien se une a tu partida - Push cuando la partida es editada o cancelada - Push al ser expulsado o cuando alguien abandona +- Push de solicitud de amistad e invitación a partida (preferencias propias; v1.8 / mig. `110`) - Preferencias granulares en cliente (push + evento); `enqueue_notification` y Edge Function `process-notifications` respetan `notify_push` y `notify_on_*` **Notificaciones automáticas (Fase 2)** @@ -236,7 +248,7 @@ Solo dos roles en el MVP: - Open Elo: retos entre parejas; ranking Elo de pareja; fecha de fin opcional - Partidos de liga reutilizan `matches` con `league_id`; mismos flujos de resultado/marcador que partidas normales cuando aplica - Privadas con contraseña + grants; admin puede leer sin contraseña -- Listado en Descubrir (filtro Ligas); invitaciones/compartir según visibilidad +- Listado en Descubrir (filtro Ligas): ligas públicas y privadas con contraseña; la contraseña desbloquea la vista - Lifecycle y recordatorios vía cron (`process_league_lifecycle`, mig. `102`/`106`) ### F11 - Estadísticas, logros y clasificación (Fase 6) @@ -621,7 +633,7 @@ Supabase (PostgreSQL + Auth + Storage) - CA_AUTH2: Un usuario puede autenticarse con Google - CA_AUTH3: Un usuario puede autenticarse con Apple ID - CA_AUTH4: Un usuario puede recuperar su contraseña por email, fijar una nueva en `auth/update-password` e iniciar sesión después -- CA_AUTH5: La sesión persiste entre cierres de la app (refresh token) +- CA_AUTH5: La sesión persiste entre cierres de la app (refresh token); tras ≥6 h en segundo plano, al volver a primer plano se cierra la sesión con aviso - CA_AUTH6: Un usuario con `status: suspended` no puede iniciar sesión - CA_AUTH7: Un usuario puede eliminar su cuenta; se borra la identidad y se anonimiza su huella en partidas, ligas y torneos (perfil sentinel «Usuario eliminado») @@ -630,7 +642,9 @@ Supabase (PostgreSQL + Auth + Storage) - CA_PROF1: El teléfono se valida en formato E.164 antes de guardar - CA_PROF2: La foto de perfil se redimensiona automáticamente a ≤ 500KB al subir - CA_PROF3: El teléfono solo aparece visible para participantes de la misma partida confirmada -- CA_PROF4: Las preferencias de notificación (canal y por evento) se editan desde el perfil y persisten en `profiles` +- CA_PROF4: Las preferencias de notificación (canal y por evento, incl. amistad e invitaciones a partida) se editan desde el perfil y persisten en `profiles` +- CA_PROF8: Un usuario puede enviar, aceptar, rechazar o cancelar solicitudes de amistad y eliminar un amigo confirmado +- CA_PROF9: Solo amigos confirmados pueden recibir invitaciones a partidas standalone (pareja o rival) **Partidas** @@ -715,7 +729,7 @@ Supabase (PostgreSQL + Auth + Storage) **Contacto** -- CA_CONTACT1: Con teléfono visible en perfil ajeno, en iOS/Android se puede abrir la ficha nativa de nuevo contacto +- CA_CONTACT1: Con teléfono visible en perfil ajeno, en iOS/Android se puede abrir la ficha nativa de nuevo contacto; en web se puede copiar el número al portapapeles --- @@ -723,19 +737,19 @@ Supabase (PostgreSQL + Auth + Storage) Las siguientes funcionalidades están **fuera del MVP** y serán evaluadas para versiones futuras: -| Funcionalidad | Motivo del aplazamiento | -| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Ligas y clasificaciones | ~~Pospuesta~~ **Implementado (v1.7 / Fase 6):** round-robin, open Elo, stats, badges y leaderboard (mig. `086`+) | -| Chat interno | Requiere moderación constante; se delega en WhatsApp | -| Geolocalización GPS exacta | Sacrifica privacidad sin aportar valor suficiente en el MVP | -| Autenticación con Apple ID | ~~Pospuesta~~ **Movida al MVP** (obligatorio para App Store) | -| Soporte multilenguaje completo (i18n) | No prioritario hasta tener masa crítica de usuarios | -| Notificaciones personalizadas avanzadas | ~~Fase 3~~ **Implementado:** toggles por canal y evento en perfil; `enqueue_notification` / `process-notifications` respetan `notify_on_*` (mig. `077`–`079`) | -| Sistema de reputación y valoraciones | Necesita masa crítica de usuarios para ser útil | -| Integración con redes sociales | No crítico para el MVP | -| Modo offline/sincronización | Alta complejidad de sincronización; conexión asumida | -| Verificación de teléfono por SMS | Añade coste (Twilio ~0.05€/SMS) y complejidad; valorar en v2 | -| Compartir en otras redes (no WhatsApp) | WhatsApp HTTPS invites en producto (jul. 2026); otras redes fuera de alcance | +| Funcionalidad | Motivo del aplazamiento | +| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Ligas y clasificaciones | ~~Pospuesta~~ **Implementado (v1.7 / Fase 6):** round-robin, open Elo, stats, badges y leaderboard (mig. `086`+) | +| Chat interno | Requiere moderación constante; se delega en WhatsApp | +| Geolocalización GPS exacta | Sacrifica privacidad sin aportar valor suficiente en el MVP | +| Autenticación con Apple ID | ~~Pospuesta~~ **Movida al MVP** (obligatorio para App Store) | +| Soporte multilenguaje completo (i18n) | No prioritario hasta tener masa crítica de usuarios | +| Notificaciones personalizadas avanzadas | ~~Fase 3~~ **Implementado:** toggles por canal y evento en perfil (incl. amistad e invitaciones a partida, mig. `110`); `enqueue_notification` / `process-notifications` respetan `notify_on_*` (mig. `077`–`079`, `110`) | +| Sistema de reputación y valoraciones | Necesita masa crítica de usuarios para ser útil | +| Integración con redes sociales | No crítico para el MVP | +| Modo offline/sincronización | Alta complejidad de sincronización; conexión asumida | +| Verificación de teléfono por SMS | Añade coste (Twilio ~0.05€/SMS) y complejidad; valorar en v2 | +| Compartir en otras redes (no WhatsApp) | WhatsApp HTTPS invites en producto (jul. 2026); otras redes fuera de alcance | --- diff --git a/TASKS.md b/TASKS.md index 0d764c3..112cdce 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,6 @@ # Tareas - jugaMUS -> Actualizado: 11/08/2026 (hotfix v1.7.1: suite TDD + coverage gates) +> Actualizado: 12/08/2026 (merge main hotfix v1.7.1; timeout sesión 6 h; invites `117`) > Metodología: Kanban personal. Actualizar al inicio y al final de cada sesión de trabajo. --- @@ -26,6 +26,7 @@ | Minor partidas pasadas | Completada | Crear partida con resultado si `start_at` pasado; orden parejas; locales; v1.5.0 | | Release 1.7 | Completada | Contactos, stats/ELO/badges, ligas, CI permissions, deps; v1.7.0 | | Hotfix tests TDD | Completada | Characterization tests + coverage gates + docs; v1.7.1 | +| Release 1.8 | Completada | Amigos, invitaciones a partidas, prefs notif. friend/invite; v1.8.0 | --- @@ -440,6 +441,7 @@ Las notificaciones push **no** funcionan en Expo Go; hace falta un build con cre ### Sesión, torneos, analytics y hotfix (v1.2.1) — 23/07/2026 - [x] Sesión caducada tras días sin abrir: `validateAuthSession` + `ensureSessionValid`; logout automático y mensaje en login; harden HMR +- [x] Timeout al volver de segundo plano: **6 h** (`BACKGROUND_SESSION_TIMEOUT_MS`; antes 10 min) - [x] Torneos: auto-cancel en `start_at` sin cuadro organizado (migraciones `070`/`071`); título/ciudad/lugar opcionales; aviso al guardar; «Organizar cuadro» deshabilitado con menos de 2 parejas completas - [x] Partidas: aviso post-crear si plantilla incompleta (auto-cancel al `start_at`) - [x] PostHog: eventos funnels 1–2 + idempotencia `match_completed` + tests @@ -544,6 +546,27 @@ Las notificaciones push **no** funcionan en Expo Go; hace falta un build con cre - [x] Versión app → **1.7.1** (`app.json`, `package.json`) - [x] Commit + PR `hotfix/v1.7.1` → `main` (revisor/asignado Japegomez) +### Release 1.8 — amigos e invitaciones a partidas (ago. 2026) + +- [x] Sistema de amigos: solicitudes con mensaje, aceptar/rechazar/cancelar, eliminar amistad — mig. `108` / `110` +- [x] Invitaciones a partidas (pareja/rival) al crear o editar equipo; pendientes ocupan plaza de roster — mig. `109` +- [x] Mis Partidas: sección Invitaciones; ficha: banner Aceptar/Rechazar + deeplink WhatsApp +- [x] Resultado `pending_validation` cuando hay rivales invitados (también en partidas pasadas); rechazo cancela partida iniciada/finalizada +- [x] Preferencias push: `notify_on_friend_request` / `notify_on_match_invitation` (perfil + `enqueue_notification` + Edge Function) — mig. `110` +- [x] PostHog: `friend_request_sent`, `friend_request_accepted`, `match_invite_sent`, `match_invite_accepted` +- [x] Versión app → **1.8.0** (`app.json`, `package.json`) +- [x] Búsqueda de usuarios por `display_name` (RPC) — mig. `111` +- [x] Listado de mis invitaciones excluye canceladas — mig. `112` +- [x] Rechazar invitación con resultado confirmado: void + cancelar partida — mig. `113` +- [x] Review follow-ups amigos/invites: cooldown tras rechazo, mensaje ≤200, capacidad al aceptar, auth en `list_match_invitations`, `match_effective_roster_filled` SECURITY DEFINER, grants `badge_showcase`, cancel invites al eliminar amigo — mig. `114`/`115` +- [x] `send_friend_request` devuelve status; `get_profile_with_phone` sin prefs/badge sensibles — mig. `116` +- [x] Review follow-ups 2: escape ILIKE + trgm, `match_status`, `friend_request_accepted`, unique_violation, slim phone RPC — mig. `117` + Edge `process-notifications` +- [x] UX create/detail: capacidad de invites, `isSubmitting` de punta a punta, modal compartir en ficha, invite tab solo en partida standalone editable +- [x] Timeout sesión en segundo plano → **6 h** (`BACKGROUND_SESSION_TIMEOUT_MS`) +- [x] Contrato Descubrir: públicas + privadas con contraseña (docs README/REQUIREMENTS); CA_CONTACT1 incluye copia en web +- [x] Documentación alineada (`REQUIREMENTS.md`, `TASKS.md`, `README.md`) +- [x] Merge `main` (hotfix v1.7.1) en `develop` para desbloquear PR #158 + --- ## UI — Rediseño Ultra Limpio (may. 2026) diff --git a/app.json b/app.json index 93b4887..ce6607d 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "jugaMUS", "slug": "musapp", "scheme": "jugamus", - "version": "1.7.1", + "version": "1.8.0", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "light", diff --git a/package-lock.json b/package-lock.json index 416da8d..74823cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jugamus", - "version": "1.7.1", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jugamus", - "version": "1.7.1", + "version": "1.8.0", "dependencies": { "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/vector-icons": "^15.1.1", @@ -73,6 +73,7 @@ "jest-expo": "~54.0.17", "lint-staged": "^16.4.0", "prettier": "^3.9.6", + "react-test-renderer": "19.1.0", "typescript": "~5.9.2" } }, diff --git a/package.json b/package.json index cb24239..7ce3316 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jugamus", - "version": "1.7.1", + "version": "1.8.0", "main": "expo-router/entry", "scripts": { "start": "expo start", @@ -82,12 +82,14 @@ "jest-expo": "~54.0.17", "lint-staged": "^16.4.0", "prettier": "^3.9.6", + "react-test-renderer": "19.1.0", "typescript": "~5.9.2" }, "private": true, "overrides": { "brace-expansion": "$brace-expansion", "postcss": "8.5.26", + "react-test-renderer": "19.1.0", "tar": "7.5.22", "uuid": "11.1.1", "undici": "6.28.0", diff --git a/src/app/(tabs)/matches/[id].tsx b/src/app/(tabs)/matches/[id].tsx index bf5ec0f..509c8f0 100644 --- a/src/app/(tabs)/matches/[id].tsx +++ b/src/app/(tabs)/matches/[id].tsx @@ -26,6 +26,7 @@ import { MatchPasswordModal } from '@/components/matches/MatchPasswordModal' import { CancelMatchModal } from '@/components/matches/CancelMatchModal' import { IncompleteRosterStartModal } from '@/components/matches/IncompleteRosterStartModal' import { LeaveMatchModal } from '@/components/matches/LeaveMatchModal' +import { ShareMatchInviteModal } from '@/components/matches/ShareMatchInviteModal' import { DisputeResultModal } from '@/components/matches/DisputeResultModal' import { ResultCard } from '@/components/matches/ResultCard' import { RecordResultModal } from '@/components/matches/RecordResultModal' @@ -46,6 +47,12 @@ import { useStartMatch, useUpdateMatchTeam, } from '@/hooks/useMatches' +import { + useMatchInvitations, + useMyMatchInvitations, + useRespondMatchInvitation, + type MatchInvitationRow, +} from '@/hooks/useMatchInvitations' import { useLeague, useRecordLeagueMatchAsReferee } from '@/hooks/useLeagues' import { useTournament, useRecordTournamentMatchAsReferee } from '@/hooks/useTournaments' import { useMatchResult, useSubmitConfirmation, useSubmitResult } from '@/hooks/useResults' @@ -283,6 +290,7 @@ interface TeamSectionProps { teamLabel: string freeSlots: number participants: ParticipantWithProfile[] + pendingInvites: MatchInvitationRow[] rosterText: { team_a_player_1: string | null team_a_player_2: string | null @@ -305,11 +313,23 @@ function TextPlayerRow({ name }: { name: string }) { ) } +function PendingInviteRow({ name }: { name: string }) { + return ( + + + {name} + + Pendiente + + ) +} + function TeamSection({ team, teamLabel, freeSlots, participants, + pendingInvites, rosterText, matchId, canRevealPhone, @@ -319,6 +339,7 @@ function TeamSection({ onEdit, }: TeamSectionProps) { const entries = collectTeamRosterEntries(rosterText, participants, team) + const hasRoster = entries.length > 0 || pendingInvites.length > 0 return ( @@ -341,28 +362,36 @@ function TeamSection({ - {entries.length === 0 ? ( + {!hasRoster ? ( Sin jugadores aún ) : ( - entries.map((entry) => { - if (entry.kind === 'text') { - return - } - - // Guard defensivo: evita crash si el roster viene incompleto. - if (!entry.participant) return null - - return ( - + {entries.map((entry) => { + if (entry.kind === 'text') { + return + } + + // Guard defensivo: evita crash si el roster viene incompleto. + if (!entry.participant) return null + + return ( + + ) + })} + {pendingInvites.map((invite) => ( + - ) - }) + ))} + )} ) @@ -396,6 +425,28 @@ const textPlayer_s = StyleSheet.create({ name: { fontSize: 15, fontFamily: Fonts.semiBold, color: Colors.textPrimary }, }) +const pendingInvite_s = StyleSheet.create({ + row: { + backgroundColor: Colors.surface, + borderRadius: 10, + padding: 12, + marginBottom: 8, + borderWidth: 1, + borderColor: Colors.border, + borderStyle: 'dashed', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 10, + }, + name: { flex: 1, fontSize: 15, fontFamily: Fonts.semiBold, color: Colors.textSecondary }, + badge: { + fontSize: 12, + fontFamily: Fonts.semiBold, + color: Colors.statusPending, + }, +}) + // ─── Join Modal ─────────────────────────────────────────────────────────────── interface JoinModalProps { @@ -496,11 +547,13 @@ const jm = StyleSheet.create({ // ─── Screen ─────────────────────────────────────────────────────────────────── export default function MatchDetailScreen() { - const { id, openResult, gamesA, gamesB } = useLocalSearchParams<{ + const { id, openResult, gamesA, gamesB, confirmResult, shareInvite } = useLocalSearchParams<{ id: string openResult?: string gamesA?: string gamesB?: string + confirmResult?: string + shareInvite?: string }>() const router = useRouter() const insets = useSafeAreaInsets() @@ -532,6 +585,14 @@ export default function MatchDetailScreen() { isLoading: resultLoading, refetch: refetchResult, } = useMatchResult(id) + const { data: myInvitations } = useMyMatchInvitations() + const { + data: matchInvitations, + isPending: matchInvitationsPending, + isFetched: matchInvitationsFetched, + } = useMatchInvitations(id) + const respondInvitation = useRespondMatchInvitation() + const matchInvitationsReady = !matchInvitationsPending && matchInvitationsFetched useFocusEffect( useCallback(() => { @@ -568,6 +629,7 @@ export default function MatchDetailScreen() { const [editTeamVisible, setEditTeamVisible] = useState(false) const [editingTeam, setEditingTeam] = useState(null) const [passwordModalDismissed, setPasswordModalDismissed] = useState(false) + const [shareInviteVisible, setShareInviteVisible] = useState(false) const needsPrivateAccess = Boolean( match && @@ -650,11 +712,20 @@ export default function MatchDetailScreen() { }) } + // Wait for invitation query so undefined is not treated as "no invites". + if (!matchInvitationsReady) return + const active = match.participants.filter((p) => p.left_at === null) const myParticipation = active.find((p) => p.user_id === userId) const otherRegistered = active.filter((p) => p.user_id !== userId) + const hasOutstandingAccountInvites = (matchInvitations ?? []).some( + (inv) => inv.status === 'pending' + ) const isPersonalMatch = - !match.tournament_id && match.creator_id === userId && otherRegistered.length === 0 + !match.tournament_id && + match.creator_id === userId && + otherRegistered.length === 0 && + !hasOutstandingAccountInvites const frame = requestAnimationFrame(() => { setLockedScoreboardPrefill(prefill) @@ -666,7 +737,48 @@ export default function MatchDetailScreen() { }) return () => cancelAnimationFrame(frame) - }, [match, scoreboardPrefill, submitResultVisible, recordResultVisible, userId, id, router]) + }, [ + match, + scoreboardPrefill, + submitResultVisible, + recordResultVisible, + userId, + id, + router, + matchInvitations, + matchInvitationsReady, + ]) + + // Deep link / redirect: ?confirmResult=1 opens the result confirmation modal + // when the viewer is on the rival team of a pending_validation result. + useEffect(() => { + const flag = Array.isArray(confirmResult) ? confirmResult[0] : confirmResult + if (flag !== '1' || !match || !userId) return + const myPart = match.participants.find((p) => p.user_id === userId && p.left_at === null) + const result = resultBundle?.result ?? null + const canValidate = Boolean( + myPart && + result?.status === RESULT_STATUS.PENDING_VALIDATION && + myPart.team !== result.submitted_by_team && + !resultBundle?.myConfirmation + ) + if (!canValidate) return + const frame = requestAnimationFrame(() => { + setApproveResultVisible(true) + router.setParams({ confirmResult: undefined } as never) + }) + return () => cancelAnimationFrame(frame) + }, [confirmResult, match, resultBundle, userId, router]) + + useEffect(() => { + const flag = Array.isArray(shareInvite) ? shareInvite[0] : shareInvite + if (flag !== '1') return + const frame = requestAnimationFrame(() => { + setShareInviteVisible(true) + router.setParams({ shareInvite: undefined } as never) + }) + return () => cancelAnimationFrame(frame) + }, [shareInvite, router]) if (isLoading) { return ( @@ -696,7 +808,14 @@ export default function MatchDetailScreen() { const slotsA = freeTeamSlots(match, match.participants, TEAM.A) const slotsB = freeTeamSlots(match, match.participants, TEAM.B) - const hasSlots = slotsA > 0 || slotsB > 0 + const pendingInvites = (matchInvitations ?? []).filter( + (inv) => inv.status === 'pending' && match.status !== MATCH_STATUS.CANCELLED + ) + const pendingInvitesA = pendingInvites.filter((inv) => inv.team === TEAM.A) + const pendingInvitesB = pendingInvites.filter((inv) => inv.team === TEAM.B) + const displaySlotsA = Math.max(0, slotsA - pendingInvitesA.length) + const displaySlotsB = Math.max(0, slotsB - pendingInvitesB.length) + const hasSlots = displaySlotsA > 0 || displaySlotsB > 0 const canJoin = isPlanned && hasSlots && @@ -718,7 +837,25 @@ export default function MatchDetailScreen() { const rivalHasRegisteredParticipants = activeParticipants.some( (p) => p.user_id && p.team !== myParticipation?.team ) - const isPersonalMatch = !match.tournament_id && isCreator && otherRegistered.length === 0 + const rivalTeam = + myParticipation?.team === TEAM.A ? TEAM.B : myParticipation?.team === TEAM.B ? TEAM.A : null + const rivalHasPendingInvites = + rivalTeam === TEAM.A + ? pendingInvitesA.length > 0 + : rivalTeam === TEAM.B + ? pendingInvitesB.length > 0 + : false + // Pending account invites block direct close (record_match_result_direct); + // those matches must go through submit_match_result / pending_validation. + const hasOutstandingAccountInvites = (matchInvitations ?? []).some( + (inv) => inv.status === 'pending' + ) + const isPersonalMatch = + matchInvitationsReady && + !match.tournament_id && + isCreator && + otherRegistered.length === 0 && + !hasOutstandingAccountInvites // Las partidas de liga round-robin se quedan "planned" hasta que se juegan; // el backend las auto-inicia al recibir el resultado. @@ -786,6 +923,58 @@ export default function MatchDetailScreen() { !myResultConfirmation ) + const matchId = Array.isArray(id) ? id[0] : id + // list_my_match_invitations only returns pending invites; `match_status` is match status. + const myPendingInvitation = + (myInvitations ?? []).find( + (inv) => + inv.match_id === matchId && + inv.match_status !== MATCH_STATUS.CANCELLED && + match.status !== MATCH_STATUS.CANCELLED + ) ?? null + + const handleAcceptInvitation = async () => { + if (!myPendingInvitation) return + try { + await respondInvitation.mutateAsync({ + invitationId: myPendingInvitation.invitation_id, + accept: true, + matchId: myPendingInvitation.match_id, + team: myPendingInvitation.team, + }) + const refreshed = await refetchMatch() + const refreshedResult = await refetchResult() + const refreshedMatch = refreshed.data + const myPart = refreshedMatch?.participants.find( + (p) => p.user_id === userId && p.left_at === null + ) + const result = refreshedResult.data?.result ?? null + if ( + myPart && + result?.status === RESULT_STATUS.PENDING_VALIDATION && + myPart.team !== result.submitted_by_team + ) { + setApproveResultVisible(true) + } + } catch (err) { + Alert.alert('No se pudo aceptar', err instanceof Error ? err.message : 'Error') + } + } + + const handleRejectInvitation = async () => { + if (!myPendingInvitation) return + try { + await respondInvitation.mutateAsync({ + invitationId: myPendingInvitation.invitation_id, + accept: false, + matchId: myPendingInvitation.match_id, + team: myPendingInvitation.team, + }) + } catch (err) { + Alert.alert('No se pudo rechazar', err instanceof Error ? err.message : 'Error') + } + } + const awaitingRivalValidation = Boolean( latestResult?.status === RESULT_STATUS.PENDING_VALIDATION && myParticipation?.team === latestResult.submitted_by_team @@ -794,6 +983,12 @@ export default function MatchDetailScreen() { const status = matchStatusDisplay(match) const teamAName = resolveTeamName(match, TEAM.A, match.participants) const teamBName = resolveTeamName(match, TEAM.B, match.participants) + const inviteTeamLabel = + myPendingInvitation?.team === TEAM.A + ? teamAName + : myPendingInvitation?.team === TEAM.B + ? teamBName + : null const { canEdit: canEditTeam, teams: editableTeams } = canEditMatchTeam( match, @@ -1040,6 +1235,52 @@ export default function MatchDetailScreen() { + {myPendingInvitation ? ( + + + {myPendingInvitation.inviter_name} te ha invitado a unirte a{' '} + {inviteTeamLabel ?? 'esta partida'}. + + +