Skip to content

feat(leagues): league format with round-robin, Elo and admin access - #150

Merged
Japegomez merged 8 commits into
mainfrom
release/stack-5-leagues
Aug 11, 2026
Merged

feat(leagues): league format with round-robin, Elo and admin access#150
Japegomez merged 8 commits into
mainfrom
release/stack-5-leagues

Conversation

@Japegomez

@Japegomez Japegomez commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • League format (round-robin, open Elo), fixtures, standings, challenges
  • Profile stats polish: badge showcase, ranking section, ELO help, podium visuals
  • Admin access to private events without password
  • Match timers: 24h for tournaments, league fixtures stay planned until played
  • ELO/stats refresh on profile open
  • Layer 5/6 of stacked PR chain replacing release: ligas, estadísticas de perfil y seguridad #144

Test plan

  • Create league round-robin and open Elo
  • League fixtures stay planned until played
  • Admin can access private events without password
  • Badge showcase and unlock popup work
  • Ranking section shows city and global position
  • ELO refreshes on profile open

Summary by CodeRabbit

  • Nuevas funciones

    • Se incorporan ligas con creación, edición, parejas, clasificación, partidos, desafíos y acceso privado.
    • Explorar y Partidas muestran ligas públicas, próximas y en curso.
    • Se añaden invitaciones compartibles mediante enlaces y enlaces profundos.
    • Los perfiles incluyen rankings, logros destacados y avisos de nuevas insignias.
    • Se mejora la navegación y el historial entre pantallas.
  • Mejoras

    • La eliminación de cuentas requiere escribir «ELIMINAR».
    • Se actualizan nombres, botones y presentación de parejas.
    • Se añade información sobre ELO y se mejora el historial.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Japegomez, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 52 seconds

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Pro Plus

Run ID: 093de1b5-64c9-4a4c-9a84-ba71dddb9156

📥 Commits

Reviewing files that changed from the base of the PR and between e37df09 and c676954.

📒 Files selected for processing (18)
  • .github/workflows/ci.yml
  • src/app/(tabs)/explore/index.tsx
  • src/app/(tabs)/leagues/[id].tsx
  • src/app/(tabs)/leagues/create.tsx
  • src/app/(tabs)/matches/[id].tsx
  • src/components/ShareInviteButton.tsx
  • src/components/stats/BadgeShowcaseSection.tsx
  • src/hooks/useBadgeUnlocks.ts
  • src/services/leagues.service.ts
  • src/services/matches.service.ts
  • src/utils/leagueDisplay.ts
  • supabase/migrations/20260810120000_086_leagues.sql
  • supabase/migrations/20260810140000_088_league_pair_member_names.sql
  • supabase/migrations/20260810160000_090_match_team_display_names.sql
  • supabase/migrations/20260810190000_096_hard_badges.sql
  • supabase/migrations/20260810200000_097_badge_updates.sql
  • supabase/migrations/20260810210000_098_badge_showcase.sql
  • supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql
📝 Walkthrough

Walkthrough

Se incorpora una plataforma de ligas round-robin y Open Elo con persistencia, permisos, parejas, desafíos, clasificación, resultados, exploración y enlaces. También se añaden ranking, insignias, mejoras de perfil, controles compartidos y navegación contextual.

Changes

Ligas y partidas

Layer / File(s) Summary
Modelo y ciclo de vida de ligas
src/types/database.types.ts, src/services/leagues.service.ts, src/hooks/useLeagues.ts, supabase/migrations/*
Se añaden tablas, RPC, permisos, fixtures, clasificación, Elo, desafíos y estados de finalización.
Pantallas y componentes de ligas
src/app/(tabs)/leagues/..., src/components/leagues/*, src/components/ui/AddPairButton.tsx
Se incorporan creación, edición, detalle, gestión de parejas, desafíos, cancelación y tablas de clasificación.
Integración con partidas y exploración
src/app/(tabs)/explore/index.tsx, src/app/(tabs)/matches/..., src/hooks/useMatches.ts, src/services/matches.service.ts, src/app/l/[id].tsx, src/app/leagues/[id].tsx
Las ligas aparecen en exploración, dashboard y partidas. Se añaden navegación, invitaciones y enlaces profundos.

Perfiles, insignias y navegación

Layer / File(s) Summary
Ranking e insignias
src/components/stats/*, src/services/stats.service.ts, src/hooks/useStats.ts, src/hooks/useBadgeUnlocks.ts, supabase/migrations/*badge*, supabase/migrations/*player_ranking*
Se añaden ranking global y local, catálogo de insignias, showcase editable, popup de desbloqueo y podio visual.
Perfiles y navegación contextual
src/app/(tabs)/profile/*, src/app/(tabs)/leaderboard/index.tsx, src/components/legal/LegalScreenLayout.tsx, src/utils/navigation.ts, src/app/(tabs)/_layout.tsx
Las pantallas usan retroceso con fallback. El perfil añade edición del avatar, teléfono bajo el nombre y acceso al ranking.
Controles compartidos y ajustes
src/components/ui/*, src/components/tournaments/*, src/components/DeleteAccountModal.tsx, src/utils/matchTeamNames.ts, package.json, locales/es.json
Se añaden botones de icono, mejoras en parejas, confirmación de eliminación, nombres visibles de equipos, confeti y texto actualizado de permisos de fotos.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.25% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed El título resume con claridad el cambio principal: soporte de ligas con formato round-robin, Elo y acceso administrativo.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch release/stack-5-leagues
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch release/stack-5-leagues

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 28

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (17)
supabase/migrations/20260810200000_097_badge_updates.sql-140-168 (1)

140-168: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Documente que streak_15 deja de otorgarse y se retira de los perfiles.

La función reconstruye v_badges desde v_keys en cada ejecución. Al no incluir streak_15, el bloque DO final elimina esa insignia de todos los perfiles que ya la tenían.

El encabezado del archivo lo indica, pero el efecto es una pérdida de datos visible para el usuario: una insignia ya conseguida desaparece del perfil y del showcase. Además, si alguna clave retirada figura en profiles.badge_showcase, queda una referencia huérfana.

Confirme que la retirada es intencional. Si lo es, considere depurar las referencias huérfanas en badge_showcase dentro de esta misma migración.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260810200000_097_badge_updates.sql` around lines 140 -
168, Confirma que la retirada de streak_15 y de las claves obsoletas es
intencional. Actualiza la reconstrucción de v_badges desde v_keys y el bloque DO
final para depurar también las referencias retiradas de profiles.badge_showcase,
evitando entradas huérfanas mientras se conserva el resto del showcase.
src/hooks/useBadgeUnlocks.ts-16-41 (1)

16-41: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reinicie el estado conocido cuando cambia sessionUserId.

knownKeysRef y queueRef persisten durante toda la vida del hook. El efecto solo depende de stats. Si la sesión cambia a otro usuario sin que el árbol se desmonte, el hook conserva las claves del usuario anterior. Las insignias del nuevo usuario que no figuren en ese conjunto se celebran como recién desbloqueadas.

Reinicie las referencias cuando cambia el identificador de sesión.

🛡️ Corrección propuesta
+  const lastUserRef = useRef<string | undefined>(undefined)
+
   useEffect(() => {
+    if (lastUserRef.current !== sessionUserId) {
+      lastUserRef.current = sessionUserId
+      knownKeysRef.current = null
+      queueRef.current = []
+      setCurrent(null)
+    }
     if (!stats) return
     const keys = stats.badges.map((b: PlayerBadge) => b.key)

Añada sessionUserId a las dependencias del efecto.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useBadgeUnlocks.ts` around lines 16 - 41, Actualiza el efecto que
procesa `stats` en el hook useBadgeUnlocks para incluir `sessionUserId` en sus
dependencias y reiniciar knownKeysRef y queueRef cuando cambie el usuario
autenticado. Mantén el comportamiento actual de detectar y encolar insignias
nuevas para el usuario activo, evitando conservar datos de la sesión anterior.
src/components/stats/TournamentPodiumSection.tsx-204-219 (1)

204-219: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use un único estado vacío cuando no existan medallas.

Con total === 0 y showMedalCounts === true, la pantalla muestra un podio con ceros y el mismo mensaje vacío tres veces. Devuelva un único bloque vacío antes de renderizar VisualPodium y las tres listas.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/stats/TournamentPodiumSection.tsx` around lines 204 - 219,
Actualiza la condición de estado vacío en TournamentPodiumSection para devolver
un único bloque cuando total === 0, independientemente de showMedalCounts.
Elimina la dependencia de showMedalCounts en esa validación y evita renderizar
VisualPodium y las tres PodiumList cuando no existan medallas.
src/components/stats/BadgeShowcase.tsx-114-121 (1)

114-121: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Bloquee las opciones mientras se guarda.

Estas opciones siguen activas cuando saving es true. El usuario puede iniciar dos mutaciones de badge_showcase antes de que termine la primera. El último UPDATE recibido puede sobrescribir la selección más reciente del usuario.

Añada disabled={saving} y rechace handlePick cuando saving sea true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/stats/BadgeShowcase.tsx` around lines 114 - 121, Disable the
badge picker Pressable using disabled={saving}, and update handlePick to return
immediately when saving is true. Ensure both the UI control and handler prevent
starting another badge_showcase mutation until the current save completes.
supabase/migrations/20260810230000_100_player_ranking.sql-67-69 (1)

67-69: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Compare la ciudad como un identificador normalizado.

ILIKE v_city trata % y _ como comodines. También excluye valores con espacios porque pr.city no usa BTRIM. Una ciudad como Madrid puede recibir un total y una posición local incorrectos.

Use la misma comparación exacta y normalizada en ambas consultas.

Corrección propuesta
-      AND (pr.city ILIKE v_city OR ps.user_id = p_user_id);
+      AND (lower(BTRIM(pr.city)) = lower(v_city) OR ps.user_id = p_user_id);

Also applies to: 104-106

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260810230000_100_player_ranking.sql` around lines 67 -
69, Replace the city condition in both ranking queries with the same exact
normalized comparison: trim surrounding whitespace from pr.city and compare it
to the normalized v_city using equality rather than ILIKE, while preserving the
ps.user_id = p_user_id fallback.
src/components/stats/FormBadges.tsx-34-45 (1)

34-45: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Agrupe cada resultado en un único elemento accesible.

En el View de cada ficha, use accessible y un accessibilityLabel que anuncie Victoria o Derrota. Añada Última partida cuando isLatest sea verdadero. Mantenga la flecha fuera del árbol de accesibilidad.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/stats/FormBadges.tsx` around lines 34 - 45, Actualiza el View
de cada ficha en FormBadges para que sea un único elemento accesible, usando
accessible y un accessibilityLabel con “Victoria” o “Derrota”, añadiendo “Última
partida” cuando isLatest sea verdadero. Excluye la flecha del árbol de
accesibilidad mediante la propiedad correspondiente, sin alterar el contenido
visual existente.
src/services/leagues.service.ts-346-375 (1)

346-375: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use el nombre visible de la pareja en los desafíos.

listLeagueChallenges toma league_pairs.name sin procesar. displayLeaguePairName (línea 566) solo usa el nombre almacenado cuando name_is_custom es verdadero; en el resto de casos compone el nombre con los miembros. Por eso un desafío puede mostrar un nombre distinto al de la misma pareja en la clasificación o en la lista de parejas.

Recupere también los campos de miembros y reutilice displayLeaguePairName.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/leagues.service.ts` around lines 346 - 375, Actualiza
listLeagueChallenges para recuperar también los campos de miembros y
name_is_custom de league_pairs, en lugar de seleccionar únicamente id y name.
Construye challenger_name y challenged_name reutilizando displayLeaguePairName
para cada pareja, manteniendo null cuando no exista una pareja asociada.
src/app/l/[id].tsx-5-11 (1)

5-11: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Las dos pantallas de redirección de liga son copias y comparten el mismo defecto. useLocalSearchParams puede devolver un array para un parámetro dinámico; en ese caso la plantilla /(tabs)/leagues/${id} genera una ruta no válida.

  • src/app/l/[id].tsx#L5-L11: normalice id con Array.isArray(...) ? id[0] : id y redirija con { pathname: '/(tabs)/leagues/[id]', params: { id } }.
  • src/app/leagues/[id].tsx#L5-L11: aplique la misma normalización y la misma forma de href, o extraiga un componente compartido de redirección y úselo en los dos archivos.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/l/`[id].tsx around lines 5 - 11, Normalice el parámetro id en ambas
pantallas, src/app/l/[id].tsx#L5-L11 y src/app/leagues/[id].tsx#L5-L11, usando
Array.isArray(...) ? id[0] : id antes de validar o redirigir. Reemplace la
interpolación de la ruta por un href con pathname '/(tabs)/leagues/[id]' y
params { id }, o extraiga y reutilice un componente compartido de redirección en
ambos archivos.
src/app/(tabs)/matches/[id].tsx-506-513 (1)

506-513: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Normalice id antes de limpiar el estado pendiente.

clearPendingMatchResultFromScoreboard compara pending?.matchId === matchId. Este código pasa id sin normalizar. La línea 626 del mismo componente sí normaliza (Array.isArray(id) ? id[0] : id), porque useLocalSearchParams puede devolver un array. Si id llega como array, la comparación falla y el estado pendiente no se limpia. El modal de resultado se vuelve a abrir al regresar a la pantalla.

🐛 Cambio propuesto
+  const matchId = Array.isArray(id) ? id[0] : id
+
   const closeMatchDetail = useCallback(() => {
-    clearPendingMatchResultFromScoreboard(id)
+    clearPendingMatchResultFromScoreboard(matchId)
     if (router.canGoBack()) {
       router.back()
       return
     }
     router.replace('/(tabs)/matches' as Href)
-  }, [router, id])
+  }, [router, matchId])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/matches/[id].tsx around lines 506 - 513, Normaliza el
parámetro id antes de llamar a clearPendingMatchResultFromScoreboard dentro de
closeMatchDetail, usando el mismo manejo de Array.isArray(id) ya aplicado en el
componente. Pasa siempre el valor escalar resultante para que la comparación
pending?.matchId === matchId funcione correctamente.
src/components/leagues/AddLeaguePairModal.tsx-86-99 (1)

86-99: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Valida en el cliente que la pareja tenga al menos un jugador.

handleSubmit envía el formulario aunque los dos slots estén vacíos. La restricción league_pairs_at_least_one_player, definida en supabase/migrations/20260810120000_086_leagues.sql (líneas 85-88), rechaza ese caso. El consumidor src/app/(tabs)/leagues/[id].tsx muestra el mensaje mediante showAlert, por lo que el usuario ve el texto de error de la base de datos.

Deshabilita el botón hasta que exista al menos un jugador.

🛡️ Corrección propuesta
+  const hasAnyPlayer =
+    playerAIsSelf || playerBIsSelf || playerAText.trim() !== '' || playerBText.trim() !== ''
+
   const handleSubmit = async () => {
-            <Button title="Guardar pareja" onPress={() => void handleSubmit()} loading={loading} />
+            <Button
+              title="Guardar pareja"
+              onPress={() => void handleSubmit()}
+              loading={loading}
+              disabled={!hasAnyPlayer}
+            />

Also applies to: 191-191

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/leagues/AddLeaguePairModal.tsx` around lines 86 - 99, Update
handleSubmit and the modal’s submit button state in AddLeaguePairModal so
submission is allowed only when at least one player slot is populated. Disable
the button when both playerAIsSelf/playerBIsSelf are false and their
corresponding text fields are empty, while preserving the existing reset and
error handling.
src/app/(tabs)/leagues/edit/[id].tsx-171-175 (1)

171-175: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Añade accessibilityLabel al botón de cierre.

El contenido del Pressable es el glifo . Los lectores de pantalla anuncian el carácter, no la acción. AddLeaguePairModal.tsx (línea 110) ya aplica accessibilityLabel="Cerrar" en el mismo control.

♿ Corrección propuesta
         <Pressable
           onPress={goBack}
-          accessibilityRole="button">
+          accessibilityRole="button"
+          accessibilityLabel="Cerrar">
           <Text style={s.closeX}>✕</Text>
         </Pressable>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/leagues/edit/[id].tsx around lines 171 - 175, Añade
accessibilityLabel="Cerrar" al Pressable de cierre en el flujo que usa goBack,
igualando el patrón existente de AddLeaguePairModal y haciendo que los lectores
de pantalla anuncien la acción en lugar del glifo ✕.
src/components/leagues/EditLeaguePairModal.tsx-158-173 (1)

158-173: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Deshabilita cada botón mientras la otra operación está en curso.

saveLoading y deleteLoading son independientes. El usuario puede pulsar «Eliminar pareja» mientras se guarda, o al contrario. Eso lanza dos mutaciones simultáneas sobre la misma pareja y el resultado final depende del orden de respuesta.

🐛 Corrección propuesta
           <Button
             title="Guardar cambios"
             onPress={() => void handleSubmit()}
             loading={saveLoading}
+            disabled={Boolean(deleteLoading)}
           />
           {canDelete ? (
             <Button
               title="Eliminar pareja"
               variant="outline"
               onPress={() => {
                 void onDelete()
               }}
               loading={deleteLoading}
+              disabled={Boolean(saveLoading)}
               style={styles.deleteBtn}
             />
           ) : null}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/leagues/EditLeaguePairModal.tsx` around lines 158 - 173,
Actualiza los botones del modal alrededor de handleSubmit y onDelete para
deshabilitar cada acción mientras cualquiera de las dos operaciones está en
curso: usa saveLoading || deleteLoading como estado de disabled en ambos
botones. Mantén sus estados loading individuales y la visibilidad condicionada
por canDelete.
supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql-63-86 (1)

63-86: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Usa el mismo resultado confirmado en ambas funciones

La fórmula sí coincide: k = 32, ROUND, piso de 100 y tratamiento de empates. Sin embargo, apply_match_elo usa solo el resultado confirmed más reciente, mientras _player_confirmed_match_rows devuelve todos. Si existen varios resultados confirmados para un partido, el rebuild aplica el Elo varias veces. Selecciona el mismo resultado más reciente o centraliza esta lógica.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql`
around lines 63 - 86, Actualiza _player_confirmed_match_rows para seleccionar
únicamente el resultado confirmed más reciente por partido, igual que
apply_match_elo, antes de calcular el Elo. Usa el mismo criterio de ordenamiento
y deduplicación para que el rebuild procese cada partido una sola vez y conserve
el tratamiento actual de empates y demás fórmulas.
src/app/(tabs)/leagues/[id].tsx-197-197 (1)

197-197: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

openEnded no es booleano y nowMs queda obsoleto.

Dos puntos en este cálculo:

  1. league.end_at && devuelve null o '' cuando el valor es falsy. Por eso openEnded no es boolean, y canChallenge (línea 390) hereda ese tipo. Envuelva la expresión en Boolean(...).
  2. nowMs solo se actualiza al montar y al enfocar la pantalla. Si la fecha de fin se supera mientras el usuario permanece en la pantalla, el botón "Desafiar" continúa visible y la mutación falla en el servidor.
🐛 Corrección propuesta para el tipo
-  const openEnded =
-    isOpenEloFormat(league.format) &&
-    league.end_at &&
-    new Date(league.end_at).getTime() > nowMs
+  const openEnded = Boolean(
+    isOpenEloFormat(league.format) &&
+      league.end_at &&
+      new Date(league.end_at).getTime() > nowMs
+  )

Also applies to: 260-263

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/leagues/[id].tsx at line 197, Convierte el cálculo de
openEnded en un booleano explícito usando Boolean(...) para que canChallenge
conserve un tipo boolean. Actualiza nowMs mientras la pantalla permanezca
enfocada, mediante un intervalo o temporizador con limpieza al
desenfocarse/desmontarse, de modo que el estado se recalcule cuando
league.end_at haya expirado y el botón “Desafiar” deje de mostrarse.
src/app/(tabs)/leagues/[id].tsx-425-432 (1)

425-432: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Conservar la pareja seleccionada al desafiar.

onChallenge solo abre el modal. No transmite la pareja pulsada. ChallengeModal (línea 599) recibe la lista completa challengeOpponents y obliga al usuario a elegir de nuevo el rival. Guarde la pareja pulsada en estado y pásela al modal como preselección.

♻️ Corrección propuesta
-                      onChallenge={
-                        canChallenge
-                          ? () => {
-                              setChallengeModalOpen(true)
-                            }
-                          : undefined
-                      }
+                      onChallenge={
+                        canChallenge
+                          ? () => {
+                              setPreselectedOpponentId(pair.id)
+                              setChallengeModalOpen(true)
+                            }
+                          : undefined
+                      }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/leagues/[id].tsx around lines 425 - 432, Actualiza el flujo
de desafío en el componente de la liga para guardar en estado la pareja pulsada
dentro de onChallenge, en lugar de solo abrir el modal. Pasa esa pareja como
preselección a ChallengeModal, que actualmente recibe challengeOpponents, y
conserva el comportamiento existente cuando no haya una selección inicial.
src/components/leagues/EloRanking.tsx-36-38 (1)

36-38: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Corregir la pluralización de "partidas".

Cuando row.played es 1, el texto muestra "1 partidas". Ajuste el sustantivo según el valor.

🐛 Corrección propuesta
               <Text style={styles.meta}>
-                {row.played} partidas · {row.wins}V / {row.losses}D
+                {row.played} {row.played === 1 ? 'partida' : 'partidas'} · {row.wins}V /{' '}
+                {row.losses}D
               </Text>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/leagues/EloRanking.tsx` around lines 36 - 38, Ajusta el texto
renderizado junto a `row.played` en `EloRanking` para mostrar “partida” cuando
el valor sea 1 y “partidas” en cualquier otro caso, manteniendo sin cambios la
información de victorias y derrotas.
src/utils/leagueStandings.ts-73-79 (1)

73-79: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Tratar el empate de juegos de forma explícita.

Si teamAGames === teamBGames, la rama else asigna la victoria a la pareja B y la derrota a la pareja A. El tipo StandingMatchResult admite valores iguales, por lo que un resultado con empate falsearía la clasificación. Descarte o gestione el empate de forma explícita.

🐛 Corrección propuesta
     if (r.teamAGames > r.teamBGames) {
       a.wins += 1
       b.losses += 1
-    } else {
+    } else if (r.teamBGames > r.teamAGames) {
       b.wins += 1
       a.losses += 1
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/leagueStandings.ts` around lines 73 - 79, Handle the tie case
explicitly in the standings update logic: when teamAGames equals teamBGames, do
not award a win or loss to either team. Keep the existing win/loss updates only
for strictly greater or lesser game counts in the surrounding league standings
function.
🧹 Nitpick comments (25)
supabase/migrations/20260810180000_094_league_badges.sql (2)

32-65: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Sustituya las cuatro subconsultas correlacionadas por una agregación única.

pair_stats ejecuta cuatro subconsultas correlacionadas sobre confirmed por cada pareja. Con N parejas se recorre confirmed 4N veces. El bloque DO de la línea 272 llama a recompute_player_stats_aggregates para todos los usuarios de player_stats, por lo que el coste se multiplica durante la migración.

Una sola agregación con GROUP BY sobre las filas de confirmed expandidas por pareja produce el mismo resultado en un único recorrido.

♻️ Estructura propuesta
  per_pair AS (
    SELECT c.league_id, c.pair_a AS pair_id, c.team_a_games AS gf, c.team_b_games AS ga FROM confirmed c
    UNION ALL
    SELECT c.league_id, c.pair_b AS pair_id, c.team_b_games AS gf, c.team_a_games AS ga FROM confirmed c
  ),
  agg AS (
    SELECT league_id, pair_id,
           COUNT(*)::INT AS played,
           COUNT(*) FILTER (WHERE gf > ga)::INT AS wins,
           SUM(gf)::INT AS games_for,
           SUM(ga)::INT AS games_against
    FROM per_pair GROUP BY league_id, pair_id
  ),
  pair_stats AS (
    SELECT lp.id AS pair_id, lp.league_id,
           COALESCE(a.played, 0) AS played,
           COALESCE(a.wins, 0) AS wins,
           COALESCE(a.games_for, 0) AS games_for,
           COALESCE(a.games_against, 0) AS games_against
    FROM public.league_pairs lp
    LEFT JOIN agg a ON a.pair_id = lp.id AND a.league_id = lp.league_id
    WHERE lp.league_id IN (SELECT DISTINCT up.league_id FROM user_pairs up)
  )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260810180000_094_league_badges.sql` around lines 32 -
65, Replace the four correlated subqueries in the pair_stats CTE with a single
aggregation pipeline: expand confirmed rows into per_pair using UNION ALL for
both sides, aggregate by league_id and pair_id in agg, then LEFT JOIN those
results to league_pairs while preserving COALESCE defaults and the existing
league filter. Keep the resulting played, wins, games_for, and games_against
values equivalent to the current logic.

271-281: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff

El recálculo masivo puede bloquear la migración.

El bloque DO recorre todas las filas de player_stats y ejecuta recompute_player_stats_aggregates de forma secuencial dentro de una sola transacción. Cada llamada hace varias agregaciones sobre matches, match_participants y match_results. Con un volumen de usuarios elevado, la migración mantiene una transacción larga y retiene bloqueos de escritura sobre player_stats.

Considere ejecutar el backfill fuera de la migración de esquema, o por lotes con confirmaciones intermedias.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260810180000_094_league_badges.sql` around lines 271 -
281, Move the existing-player badge backfill out of the migration transaction
instead of running the sequential DO block over all player_stats rows. Remove or
replace the v_uid loop invoking recompute_player_stats_aggregates, and provide a
separately executable batched process with intermediate commits so large
datasets do not hold player_stats locks for the entire migration.
supabase/migrations/20260810190000_096_hard_badges.sql (1)

15-25: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Evalúe _player_won_match una sola vez por fila.

La expresión public._player_won_match(mp.team, mr.team_a_games, mr.team_b_games) aparece en el CASE de la línea 16 y en el filtro de la línea 25. El planificador puede evaluarla dos veces por fila. Sobre un escaneo amplio, el coste se duplica sin necesidad.

Calcule el valor en una subconsulta previa y filtre sobre la columna resultante.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260810190000_096_hard_badges.sql` around lines 15 - 25,
Calcule public._player_won_match una sola vez por fila mediante una subconsulta
o CTE intermedia, exponiendo su resultado como una columna reutilizable.
Actualice el CASE y el filtro de la consulta para referenciar esa columna
calculada, manteniendo sin cambios la lógica de is_win y la exclusión de
resultados NULL.
src/hooks/useMatches.ts (2)

93-107: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Unifique los tres constructores de clave de exploración.

publicLeaguesExploreQueryKey, publicTournamentsExploreQueryKey (línea 78) y publicMatchesExploreQueryKey (línea 135) derivan los mismos nueve campos y solo cambian la raíz. Un campo de filtro nuevo debe añadirse tres veces.

Extraiga un helper que reciba la raíz y los filtros.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useMatches.ts` around lines 93 - 107, Unifica
publicLeaguesExploreQueryKey, publicTournamentsExploreQueryKey y
publicMatchesExploreQueryKey mediante un helper compartido que reciba la raíz y
los filtros, construya una única clave con los nueve campos actuales y conserve
sus valores por defecto y normalización. Haz que los tres constructores deleguen
en ese helper usando únicamente sus respectivas raíces.

384-388: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Centralice la invalidación de las consultas de liga.

Las claves literales coinciden actualmente con sus constructores. Use invalidateLeagueQueries(queryClient, updated.league_id) de src/hooks/useLeagues.ts para evitar duplicación y mantener también la invalidación de league-challenges.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useMatches.ts` around lines 384 - 388, Reemplace las tres llamadas
literales a invalidateQueries dentro del bloque de updated.league_id en
useMatches por invalidateLeagueQueries(queryClient, updated.league_id),
reutilizando el helper de useLeagues.ts para centralizar también la invalidación
de league-challenges.
src/app/(tabs)/explore/index.tsx (1)

281-301: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Elimine la duplicación entre tournamentFilters y leagueFilters.

Los dos objetos derivan los mismos nueve campos de filters. Cualquier campo nuevo debe añadirse dos veces y es fácil que se desincronicen.

Construya un único objeto derivado y reutilícelo en ambos hooks.

♻️ Cambio propuesto
-  const leagueFilters: PublicLeaguesListFilters = useMemo(
-    () => ({
-      search: filters.search,
-      city: filters.city,
-      status: filters.status,
-      hideCelebrated: filters.hideCelebrated,
-      startAfter: filters.startAfter,
-      startBefore: filters.startBefore,
-      minFreeSlots: filters.minFreeSlots,
-      contentType: filters.contentType,
-      visibility: filters.visibility ?? 'all',
-    }),
-    [filters]
-  )
+  // `PublicLeaguesListFilters` y `PublicTournamentsListFilters` comparten la misma forma.
+  const leagueFilters: PublicLeaguesListFilters = tournamentFilters
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/explore/index.tsx around lines 281 - 301, Elimina la
duplicación entre los objetos tournamentFilters y leagueFilters creando un único
objeto derivado de filters con los nueve campos compartidos, incluido el valor
predeterminado de visibility, y reutilízalo en usePublicLeaguesExplore y el hook
de torneos. Actualiza las dependencias de useMemo para mantener el
comportamiento existente.
supabase/migrations/20260810120000_086_leagues.sql (1)

503-503: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

El CASE es redundante.

Las dos ramas devuelven p_end_at. Sustituye la expresión por p_end_at.

♻️ Refactor propuesto
-    CASE WHEN v_format = 'open_elo' THEN p_end_at ELSE p_end_at END,
+    p_end_at,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260810120000_086_leagues.sql` at line 503, Elimina el
CASE redundante en la expresión de la migración y reemplázalo directamente por
p_end_at, manteniendo sin cambios el resto de la lógica.
supabase/migrations/20260810240000_101_admin_private_access.sql (1)

4-101: 🩺 Stability & Availability | 🔵 Trivial

Considera registrar los accesos de administrador a contenido privado.

Las tres funciones conceden lectura a los administradores sobre partidos, torneos y ligas privados sin contraseña. El repositorio ya define la tabla audit_logs. Estas funciones son STABLE, por lo que no pueden escribir en ella.

Si el cumplimiento normativo exige trazabilidad, registra el acceso en la capa de servicio o en los RPC de lectura que devuelven contenido privado, no en estos predicados.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260810240000_101_admin_private_access.sql` around lines
4 - 101, No añadas escrituras de auditoría dentro de auth_can_read_match,
auth_can_read_tournament ni auth_can_read_league, porque son funciones STABLE;
mantén estos predicados como validaciones de acceso y registra los accesos
administrativos al contenido privado en la capa de servicio o en los RPC de
lectura que lo devuelven, reutilizando audit_logs.
src/components/leagues/AddLeaguePairModal.tsx (1)

114-120: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Usa KeyboardAwareScrollView en lugar de combinar KeyboardAvoidingView con automaticallyAdjustKeyboardInsets.

Los dos mecanismos ajustan el desplazamiento al aparecer el teclado. En iOS pueden acumularse y desplazar el contenido más de lo necesario. El repositorio ya define @/components/ui/KeyboardAwareScrollView, que se usa en src/app/(tabs)/leagues/edit/[id].tsx (línea 10).

Unifica el manejo del teclado con ese componente, o elimina uno de los dos mecanismos.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/leagues/AddLeaguePairModal.tsx` around lines 114 - 120,
Reemplaza la combinación de KeyboardAvoidingView y ScrollView en
AddLeaguePairModal por el componente KeyboardAwareScrollView de
`@/components/ui/KeyboardAwareScrollView`, siguiendo el uso existente en la
pantalla de edición de ligas. Conserva los estilos, el contenido y la
configuración de persistencia del teclado, y elimina los props y wrappers
redundantes de ajuste del teclado.
src/utils/elo.ts (1)

29-46: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Deriva deltaB de deltaA para conservar el Elo total.

Math.round redondea los valores exactamente medios hacia arriba. Por eso eloDelta(a, b, 1, k) y eloDelta(b, a, 0, k) no siempre son opuestos: si el producto es ±2.5, se obtiene 3 y -2, y el sistema crea 1 punto de Elo. En un ranking de suma cero esto acumula deriva.

♻️ Cambio propuesto
   const scoreA: 0 | 1 = aWon ? 1 : 0
   const deltaA = eloDelta(ratingA, ratingB, scoreA, kFactor)
-  const deltaB = eloDelta(ratingB, ratingA, aWon ? 0 : 1, kFactor)
+  const deltaB = -deltaA
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/elo.ts` around lines 29 - 46, En computeEloUpdate, deriva deltaB a
partir de deltaA para garantizar que ambos cambios sean opuestos y conservar el
total de Elo; elimina el cálculo independiente mediante eloDelta para el jugador
B, manteniendo sin cambios los valores anteriores y posteriores.
src/utils/leagueStandings.test.ts (2)

30-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Renombra la variable y el título del test.

La variable two contiene tres parejas. El título del test es idéntico al de la línea 30 salvo el sufijo, lo que dificulta identificar cuál falla en el informe de Jest.

♻️ Cambio propuesto
-  it('uses games difference when h2h equal among tied', () => {
-    const two = [
+  it('ranks by games difference when every pair has one win', () => {
+    const trio = [
       { id: 'x', name: 'X' },
       { id: 'y', name: 'Y' },
       { id: 'z', name: 'Z' },
     ]
-    const rows = computeLeagueStandings(two, [
+    const rows = computeLeagueStandings(trio, [
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/leagueStandings.test.ts` around lines 30 - 36, Renombra la variable
`two` en el test `uses games difference when h2h equal among tied` para reflejar
que contiene tres parejas y actualiza el título para distinguirlo claramente del
test de la línea 30, manteniendo la misma intención y aserciones.

20-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

El test no comprueba el desempate que anuncia su nombre.

Las aserciones sólo verifican que hay tres filas y que todas tienen una victoria. El orden no se comprueba, por lo que el test pasaría aunque el desempate por diferencia de juegos estuviera roto. Con estos datos las diferencias son b = +1, a = 0, c = -1.

♻️ Aserción propuesta
     expect(rows).toHaveLength(3)
     expect(new Set(rows.map((r) => r.wins))).toEqual(new Set([1]))
+    expect(rows.map((r) => r.pairId)).toEqual(['b', 'a', 'c'])
+    expect(rows.map((r) => r.gamesDiff)).toEqual([1, 0, -1])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/leagueStandings.test.ts` around lines 20 - 28, Strengthen the test
named “uses games difference when h2h equal in a cycle” by asserting the
standings order reflects the games-difference tiebreak: team b first, team a
second, and team c third. Keep the existing length and wins assertions if
useful, but verify the ordered team identifiers rather than only aggregate wins.
src/app/(tabs)/leagues/create.tsx (2)

234-236: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Unifica la presentación de errores en showAlert.

El archivo importa showAlert de @/utils/alert y lo usa en runDeletePair (línea 296). Los demás errores usan Alert.alert de react-native directamente. Esa mezcla evita la abstracción compartida y puede divergir en plataforma web. Sustituye las cuatro llamadas a Alert.alert por showAlert y elimina la importación de Alert.

Also applies to: 249-252, 265-268, 283-286

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/leagues/create.tsx around lines 234 - 236, Reemplaza las
cuatro llamadas a Alert.alert en los manejadores de error de create.tsx,
incluida la del bloque catch mostrado y las secciones indicadas, por showAlert
manteniendo sus títulos y mensajes. Elimina la importación de Alert desde
react-native y conserva la importación existente de showAlert.

392-405: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

El valor mostrado puede no coincidir con el estado del formulario.

value={field.value || defaultEndAt()} muestra una fecha calculada cuando end_at está vacío, pero no la escribe en el formulario. En ese caso superRefine marca «La liga abierta requiere fecha de fin» mientras el selector muestra una fecha válida. Asigna el valor por defecto con setValue al elegir el formato OPEN_ELO en lugar de calcularlo en el render.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/leagues/create.tsx around lines 392 - 405, Actualiza el flujo
de selección de formato alrededor de `formatValue` para que, al elegir
`LEAGUE_FORMAT.OPEN_ELO`, asigne la fecha de `defaultEndAt()` al campo `end_at`
mediante `setValue` cuando esté vacío. Después, usa `field.value` directamente
en `DateTimePicker` para mantener sincronizados el valor mostrado y el estado
validado por `superRefine`.
supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql (1)

134-154: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sustituye el parche por texto por una definición explícita de get_player_stats.

pg_get_functiondef + replace + EXECUTE depende de una cadena literal exacta dentro del cuerpo. Cualquier cambio de formato en una migración anterior (espacios, comentario intercalado, nombre de parámetro) provoca RAISE EXCEPTION y bloquea el despliegue. Además, el cuerpo resultante no queda visible en el repositorio, lo que dificulta auditar una función SECURITY DEFINER.

Declara CREATE OR REPLACE FUNCTION public.get_player_stats(uuid) con el cuerpo completo y la llamada a refresh_player_stats, e incluye ahí mismo el atributo VOLATILE en lugar del ALTER FUNCTION posterior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql`
around lines 134 - 154, Replace the dynamic pg_get_functiondef/replace/EXECUTE
patch with an explicit CREATE OR REPLACE FUNCTION public.get_player_stats(uuid)
definition containing the complete existing function body and calling
public.refresh_player_stats. Preserve the function’s existing signature, return
type, security attributes, and behavior, and declare VOLATILE in the function
definition; remove the separate ALTER FUNCTION statement.
src/components/leagues/LeaguePairCard.tsx (1)

60-78: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Renderiza el contenedor de acciones sólo cuando hay botones.

styles.actions aplica marginTop: 10 de forma incondicional. En el paso 2 de la creación de ligas la tarjeta recibe sólo onEdit, por lo que el contenedor queda vacío y añade espacio inferior sin contenido.

♻️ Cambio propuesto
-      <View style={styles.actions}>
-        {onJoin && joinLabel ? (
+      {(onJoin && joinLabel) || (onChallenge && challengeLabel) ? (
+        <View style={styles.actions}>
+          {onJoin && joinLabel ? (

Ajusta el cierre del bloque de forma acorde.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/leagues/LeaguePairCard.tsx` around lines 60 - 78, Render the
styles.actions container only when at least one action button is available,
meaning the existing onJoin/joinLabel or onChallenge/challengeLabel conditions
are satisfied. Update the surrounding JSX in LeaguePairCard so the empty
container is not mounted while preserving both button render paths and their
existing styles.
src/components/ui/IconButton.tsx (1)

32-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Añade respuesta visual al pulsar.

Pressable recibe un array de estilos estático. El botón no cambia de aspecto durante la pulsación, por lo que el usuario no recibe confirmación táctil visual. Este componente se reutiliza para editar parejas y añadir parejas, donde la respuesta importa.

♻️ Cambio propuesto
     <Pressable
-      style={[
+      style={({ pressed }) => [
         styles.base,
         variant === 'outline' && styles.outline,
         variant === 'primary' && styles.primary,
         disabled && styles.disabled,
+        pressed && !disabled && styles.pressed,
         style,
       ]}
   disabled: { opacity: 0.45 },
+  pressed: { opacity: 0.6 },
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/IconButton.tsx` around lines 32 - 48, Update the Pressable
in IconButton to provide a pressed-state visual style through its style
callback, while preserving the existing base, variant, disabled, and custom
styles. Add or reuse a pressed style so tapping the button visibly changes its
appearance without altering onPress behavior.
src/hooks/useLeagues.ts (1)

226-235: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Invalida también las consultas de partidas al iniciar la liga.

startLeague genera los fixtures como partidas nuevas. onSuccess invalida las claves de liga, el explorador público y el panel del usuario, pero no las claves ['match', …] ni las listas de partidas ya cacheadas. useAcceptLeagueChallenge (líneas 260-263) sí lo hace. Aplica el mismo criterio para que los fixtures aparezcan de inmediato.

♻️ Cambio propuesto
     onSuccess: (_void, { leagueId }) => {
       invalidateLeagueQueries(queryClient, leagueId)
       invalidatePublicExplore(queryClient)
       invalidateMyMatchesDashboard(queryClient, sessionUserId)
+      queryClient.invalidateQueries({ queryKey: ['match'], exact: false })
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useLeagues.ts` around lines 226 - 235, Actualiza el callback
onSuccess de la mutación que ejecuta startLeague para invalidar también las
consultas de partidas y sus listas cacheadas, igual que
useAcceptLeagueChallenge. Conserva las invalidaciones existentes de liga,
explorador público y panel del usuario, reutilizando las mismas funciones o
claves de invalidación disponibles.
src/components/leagues/EloRanking.tsx (1)

12-18: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Corregir el comentario de la línea 12.

El comentario indica que las filas deben llegar ya ordenadas por current_elo descendente. El componente aplica su propio orden en las líneas 14-18, por lo que esa precondición no existe. Actualice el comentario para describir el orden que aplica el componente: Elo, victorias y nombre.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/leagues/EloRanking.tsx` around lines 12 - 18, Actualiza el
comentario de documentación sobre EloRanking para indicar que el componente
ordena las filas por Elo descendente, luego por victorias descendentes y
finalmente por nombre de pareja. Elimina la referencia a que las filas deban
llegar previamente ordenadas.
src/components/leagues/StandingsTable.tsx (1)

34-50: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Agrupar cada fila para los lectores de pantalla.

Cada fila se compone de celdas Text independientes. Un lector de pantalla las lee como valores sueltos, sin relación con las cabeceras "PJ", "PG", "PP" y "PTS". Añada accessible y un accessibilityLabel por fila que incluya posición, nombre de la pareja y estadísticas.

♻️ Refactor propuesto
-          <View key={row.pair_id} style={[styles.row, isPodium && styles.rowPodium]}>
+          <View
+            key={row.pair_id}
+            accessible
+            accessibilityLabel={`Posición ${row.rank}. ${row.pair_name}. ${row.played} jugados, ${row.wins} ganados, ${row.losses} perdidos.`}
+            style={[styles.row, isPodium && styles.rowPodium]}>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/leagues/StandingsTable.tsx` around lines 34 - 50, Actualiza el
contenedor de cada fila dentro de rows.map en StandingsTable para marcarlo como
accesible y proporcionar un accessibilityLabel único que agrupe la posición, el
nombre de la pareja y las estadísticas PJ, PG, PP y PTS, usando los valores de
row.rank, row.pair_name, row.played, row.wins, row.losses y row.points.
src/app/(tabs)/leagues/[id].tsx (1)

222-233: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Alinear el refetch en foco con refreshAll.

En la línea 229 se llama challengesQ.refetch() sin comprobar el formato. En refreshAll (línea 271) el refetch de desafíos sí depende de isOpenEloFormat(league.format). Aplique la misma condición en ambos sitios para evitar peticiones innecesarias en ligas round-robin.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/leagues/[id].tsx around lines 222 - 233, Update the focus
effect around refetchLeague so challengesQ.refetch() runs only when
isOpenEloFormat(league.format) is true, matching the condition already used by
refreshAll; preserve the existing fullAccess guard and other refetch behavior.
src/utils/leagueFixtures.test.ts (1)

4-26: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Añadir un aserto de unicidad de enfrentamientos.

Los tests validan el número de fixtures y las banderas de vuelta. No validan que cada pareja juegue contra cada otra exactamente una vez. Un generador con un emparejamiento duplicado y otro ausente pasaría estos tests. Añada un aserto sobre el conjunto de enfrentamientos.

♻️ Refactor propuesto
   it('generates correct count for 4 pairs single round', () => {
     const ids = ['a', 'b', 'c', 'd']
     const fixtures = generateRoundRobinFixtures(ids, false)
     expect(fixtures).toHaveLength(expectedMatchCount(4, false))
     expect(fixtures.every((f) => !f.isSecondLeg)).toBe(true)
+    const keys = fixtures.map((f) => [f.pairAId, f.pairBId].sort().join('-'))
+    expect(new Set(keys).size).toBe(expectedMatchCount(4, false))
   })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/leagueFixtures.test.ts` around lines 4 - 26, Extend the tests
around generateRoundRobinFixtures to assert matchup uniqueness: normalize each
fixture’s pairAId/pairBId into an order-independent representation, then verify
the set contains every distinct pair exactly once for single-round fixtures.
Keep the existing count, second-leg, and bye assertions unchanged.
src/lib/shareInvite.ts (1)

13-14: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Ternarios anidados sin exhaustividad sobre InviteShareKind en dos archivos. Los dos sitios resuelven el valor de kind con ternarios anidados y dejan 'league' en la rama por defecto. Si se añade un cuarto valor a la unión, ambos devolverán el resultado de liga sin error de compilación. La causa raíz compartida es la ausencia de una estructura exhaustiva indexada por InviteShareKind.

  • src/lib/shareInvite.ts#L13-L14: sustituya los ternarios por un Record<InviteShareKind, string> con las etiquetas partida, torneo y liga.
  • src/components/ShareInviteButton.tsx#L26-L31: sustituya los ternarios por un Record<InviteShareKind, (id: string) => string> con los tres constructores de URL, y obtenga la URL con builders[kind](id).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/shareInvite.ts` around lines 13 - 14, Replace the nested ternary in
src/lib/shareInvite.ts lines 13-14 with a Record<InviteShareKind, string>
containing the partida, torneo, and liga labels. Also replace the nested ternary
in src/components/ShareInviteButton.tsx lines 26-31 with a
Record<InviteShareKind, (id: string) => string> for the three URL builders, then
obtain the URL through builders[kind](id) so both sites are exhaustive.
src/components/leagues/ChallengeList.tsx (1)

25-25: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Usar una constante para el estado del desafío.

El literal 'pending' se compara en crudo. El repositorio ya centraliza estados en src/constants (por ejemplo LEAGUE_STATUS y MATCH_VISIBILITY, importados en src/app/(tabs)/leagues/[id].tsx, línea 33). Exporte una constante de estado de desafío y úsela aquí. Así se evita la divergencia con el enum de base de datos.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/leagues/ChallengeList.tsx` at line 25, Replace the raw
'pending' comparison in ChallengeList with a shared exported challenge-status
constant from the repository’s constants module, adding that constant if needed.
Import and use the constant in the pending filter so it remains aligned with the
database enum.
src/lib/inviteLinks.ts (1)

18-20: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Añadir una prueba para el enlace de liga.

src/lib/inviteLinks.test.ts no cubre buildLeagueHttpsInviteUrl. El caso de EXPO_PUBLIC_INVITE_HOST ausente ya está cubierto mediante buildMatchHttpsInviteUrl.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/inviteLinks.ts` around lines 18 - 20, Añade una prueba en
inviteLinks.test.ts para buildLeagueHttpsInviteUrl que verifique la URL HTTPS
generada con un leagueId y un host configurado. Reutiliza el patrón de las
pruebas existentes de buildMatchHttpsInviteUrl, sin duplicar la cobertura del
caso de EXPO_PUBLIC_INVITE_HOST ausente.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Pro Plus

Run ID: 8bf36996-3cb4-4ff4-b573-e50c014dc451

📥 Commits

Reviewing files that changed from the base of the PR and between bf9ac07 and c637dfa.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (88)
  • locales/es.json
  • package.json
  • src/app/(tabs)/_layout.tsx
  • src/app/(tabs)/explore/index.tsx
  • src/app/(tabs)/leaderboard/index.tsx
  • src/app/(tabs)/leagues/[id].tsx
  • src/app/(tabs)/leagues/create.tsx
  • src/app/(tabs)/leagues/edit/[id].tsx
  • src/app/(tabs)/matches/[id].tsx
  • src/app/(tabs)/matches/create.tsx
  • src/app/(tabs)/matches/edit/[id].tsx
  • src/app/(tabs)/matches/index.tsx
  • src/app/(tabs)/matches/scoreboard/[id].tsx
  • src/app/(tabs)/profile/[userId].tsx
  • src/app/(tabs)/profile/edit.tsx
  • src/app/(tabs)/profile/index.tsx
  • src/app/(tabs)/profile/stats/[userId].tsx
  • src/app/(tabs)/tournaments/[id].tsx
  • src/app/(tabs)/tournaments/create.tsx
  • src/app/(tabs)/tournaments/edit/[id].tsx
  • src/app/l/[id].tsx
  • src/app/leagues/[id].tsx
  • src/components/DeleteAccountModal.tsx
  • src/components/ShareInviteButton.tsx
  • src/components/leagues/AddLeaguePairModal.tsx
  • src/components/leagues/CancelLeagueModal.tsx
  • src/components/leagues/ChallengeList.tsx
  • src/components/leagues/ChallengeModal.tsx
  • src/components/leagues/EditLeaguePairModal.tsx
  • src/components/leagues/EloRanking.tsx
  • src/components/leagues/LeaguePairCard.tsx
  • src/components/leagues/StandingsTable.tsx
  • src/components/legal/LegalScreenLayout.tsx
  • src/components/stats/BadgeList.tsx
  • src/components/stats/BadgeShowcase.tsx
  • src/components/stats/BadgeShowcaseSection.tsx
  • src/components/stats/BadgeUnlockPopup.tsx
  • src/components/stats/ELOBadge.tsx
  • src/components/stats/FormBadges.tsx
  • src/components/stats/ProfileStatsCard.tsx
  • src/components/stats/RankingSection.tsx
  • src/components/stats/TournamentPodiumSection.tsx
  • src/components/tournaments/AddPairModal.tsx
  • src/components/tournaments/PairCard.tsx
  • src/components/ui/AddPairButton.tsx
  • src/components/ui/CreateFab.tsx
  • src/components/ui/IconButton.tsx
  • src/constants/index.ts
  • src/hooks/useBadgeUnlocks.ts
  • src/hooks/useLeagues.ts
  • src/hooks/useMatches.ts
  • src/hooks/useResults.ts
  • src/hooks/useStats.ts
  • src/lib/inviteLinks.ts
  • src/lib/shareInvite.ts
  • src/services/leagues.service.ts
  • src/services/matches.service.ts
  • src/services/profiles.service.ts
  • src/services/stats.service.ts
  • src/types/database.types.ts
  • src/utils/elo.test.ts
  • src/utils/elo.ts
  • src/utils/exploreFilters.ts
  • src/utils/leagueDisplay.test.ts
  • src/utils/leagueDisplay.ts
  • src/utils/leagueFixtures.test.ts
  • src/utils/leagueFixtures.ts
  • src/utils/leagueForm.ts
  • src/utils/leagueStandings.test.ts
  • src/utils/leagueStandings.ts
  • src/utils/matchTeamNames.test.ts
  • src/utils/matchTeamNames.ts
  • src/utils/navigation.ts
  • supabase/migrations/20260810120000_086_leagues.sql
  • supabase/migrations/20260810130000_087_explore_exclude_league_matches.sql
  • supabase/migrations/20260810140000_088_league_pair_member_names.sql
  • supabase/migrations/20260810150000_089_league_finish_on_all_matches.sql
  • supabase/migrations/20260810160000_090_match_team_display_names.sql
  • supabase/migrations/20260810180000_094_league_badges.sql
  • supabase/migrations/20260810181000_095_league_badges_apply.sql
  • supabase/migrations/20260810190000_096_hard_badges.sql
  • supabase/migrations/20260810200000_097_badge_updates.sql
  • supabase/migrations/20260810210000_098_badge_showcase.sql
  • supabase/migrations/20260810220000_099_badge_showcase_grants.sql
  • supabase/migrations/20260810230000_100_player_ranking.sql
  • supabase/migrations/20260810240000_101_admin_private_access.sql
  • supabase/migrations/20260811000000_102_match_timers_tournament_league.sql
  • supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql

Comment thread src/app/(tabs)/explore/index.tsx
Comment thread src/app/(tabs)/leagues/[id].tsx Outdated
Comment thread src/app/(tabs)/leagues/create.tsx Outdated
Comment thread src/app/(tabs)/leagues/create.tsx Outdated
Comment thread src/app/(tabs)/matches/[id].tsx Outdated
Comment on lines +6 to +202
CREATE OR REPLACE FUNCTION public.recompute_player_stats_aggregates(p_user_id UUID)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
r RECORD;
v_played INT := 0;
v_wins INT := 0;
v_losses INT := 0;
v_streak INT := 0;
v_best INT := 0;
v_run INT := 0;
v_won BOOLEAN;
v_form JSONB := '[]'::jsonb;
v_form_arr TEXT[] := ARRAY[]::TEXT[];
v_t_won INT := 0;
v_t_finals INT := 0;
v_t_thirds INT := 0;
v_venues INT := 0;
v_badges JSONB := '[]'::jsonb;
v_existing JSONB;
v_now TIMESTAMPTZ := NOW();
v_key TEXT;
v_keys TEXT[];
v_nemesis_wins INT := 0;
v_league_gold INT := 0;
v_league_silver INT := 0;
v_league_bronze INT := 0;
v_league_part INT := 0;
v_broke_nine BOOLEAN := FALSE;
BEGIN
PERFORM public.ensure_player_stats_row(p_user_id);

FOR r IN
SELECT * FROM public._player_confirmed_match_rows(p_user_id)
LOOP
v_won := public._player_won_match(r.team, r.team_a_games, r.team_b_games);
IF v_won IS NULL THEN
CONTINUE;
END IF;

v_played := v_played + 1;
IF v_won THEN
v_wins := v_wins + 1;
IF v_run >= 0 THEN
v_run := v_run + 1;
ELSE
v_run := 1;
END IF;
IF v_run > v_best THEN
v_best := v_run;
END IF;
v_form_arr := array_append(v_form_arr, 'won');
ELSE
v_losses := v_losses + 1;
IF v_run <= 0 THEN
v_run := v_run - 1;
ELSE
v_run := -1;
END IF;
v_form_arr := array_append(v_form_arr, 'lost');
END IF;

IF r.tournament_id IS NOT NULL
AND r.tournament_round_size = 2
AND NOT r.tournament_is_third_place THEN
v_t_finals := v_t_finals + 1;
IF r.tournament_winner_pair_id IS NOT NULL
AND (
(r.team = 'A' AND r.tournament_winner_pair_id = r.tournament_pair_a_id)
OR (r.team = 'B' AND r.tournament_winner_pair_id = r.tournament_pair_b_id)
) THEN
v_t_won := v_t_won + 1;
END IF;
END IF;

IF r.tournament_id IS NOT NULL
AND r.tournament_is_third_place
AND r.tournament_winner_pair_id IS NOT NULL
AND (
(r.team = 'A' AND r.tournament_winner_pair_id = r.tournament_pair_a_id)
OR (r.team = 'B' AND r.tournament_winner_pair_id = r.tournament_pair_b_id)
) THEN
v_t_thirds := v_t_thirds + 1;
END IF;
END LOOP;

v_streak := v_run;

IF cardinality(v_form_arr) > 5 THEN
v_form_arr := v_form_arr[(cardinality(v_form_arr) - 4):cardinality(v_form_arr)];
END IF;
v_form := to_jsonb(v_form_arr);

SELECT COUNT(DISTINCT (city || '|' || COALESCE(place_text, '')))::INT
INTO v_venues
FROM public._player_confirmed_match_rows(p_user_id);

SELECT COALESCE(MAX(cnt), 0)::INT INTO v_nemesis_wins
FROM (
SELECT opp.user_id, COUNT(*)::INT AS cnt
FROM public.match_participants me
JOIN public.match_participants opp
ON opp.match_id = me.match_id
AND opp.user_id <> me.user_id
AND opp.team <> me.team
AND opp.state = 'confirmed'
JOIN public.matches m ON m.id = me.match_id
JOIN public.match_results mr ON mr.match_id = m.id AND mr.status = 'confirmed'
WHERE me.user_id = p_user_id
AND me.state = 'confirmed'
AND m.status = 'finished'
AND COALESCE(m.tournament_is_bye, FALSE) = FALSE
AND public._player_won_match(me.team, mr.team_a_games, mr.team_b_games) IS TRUE
GROUP BY opp.user_id
) s;

SELECT
COUNT(*) FILTER (WHERE lr.rank = 1)::INT,
COUNT(*) FILTER (WHERE lr.rank = 2)::INT,
COUNT(*) FILTER (WHERE lr.rank = 3)::INT,
COUNT(*)::INT
INTO v_league_gold, v_league_silver, v_league_bronze, v_league_part
FROM public._finished_league_ranks_for_user(p_user_id) lr;

v_broke_nine := public._player_broke_nine_win_streak(p_user_id);

SELECT badges INTO v_existing FROM public.player_stats WHERE user_id = p_user_id;
IF v_existing IS NULL THEN
v_existing := '[]'::jsonb;
END IF;

v_keys := ARRAY[]::TEXT[];
IF v_wins >= 1 THEN v_keys := array_append(v_keys, 'first_win'); END IF;
IF v_wins >= 10 THEN v_keys := array_append(v_keys, 'wins_10'); END IF;
IF v_wins >= 25 THEN v_keys := array_append(v_keys, 'wins_25'); END IF;
IF v_wins >= 50 THEN v_keys := array_append(v_keys, 'wins_50'); END IF;
IF v_wins >= 100 THEN v_keys := array_append(v_keys, 'wins_100'); END IF;
IF v_t_won >= 1 THEN v_keys := array_append(v_keys, 'tournament_winner'); END IF;
IF v_t_finals >= 1 THEN v_keys := array_append(v_keys, 'tournament_finalist'); END IF;
IF v_league_gold >= 1 THEN v_keys := array_append(v_keys, 'league_winner'); END IF;
IF v_league_silver >= 1 THEN v_keys := array_append(v_keys, 'league_runner_up'); END IF;
IF (v_league_gold + v_league_silver + v_league_bronze) >= 1 THEN
v_keys := array_append(v_keys, 'league_podium');
END IF;
IF v_league_part >= 3 THEN v_keys := array_append(v_keys, 'league_regular'); END IF;
IF v_best >= 5 THEN v_keys := array_append(v_keys, 'streak_5'); END IF;
IF v_best >= 10 THEN v_keys := array_append(v_keys, 'streak_10'); END IF;
IF v_broke_nine THEN v_keys := array_append(v_keys, 'streak_breaker'); END IF;
IF v_t_won >= 1 AND v_league_gold >= 1 THEN
v_keys := array_append(v_keys, 'double_champion');
END IF;
IF v_played >= 50 THEN v_keys := array_append(v_keys, 'veteran_50'); END IF;
IF v_played >= 100 THEN v_keys := array_append(v_keys, 'veteran_100'); END IF;
IF v_venues >= 5 THEN v_keys := array_append(v_keys, 'explorer_5'); END IF;
IF v_venues >= 20 THEN v_keys := array_append(v_keys, 'explorer_20'); END IF;
IF v_nemesis_wins >= 5 THEN v_keys := array_append(v_keys, 'nemesis_confirmed'); END IF;
IF v_nemesis_wins >= 10 THEN v_keys := array_append(v_keys, 'rivalry_10'); END IF;
IF v_t_won >= 5 THEN v_keys := array_append(v_keys, 'crown_5'); END IF;
IF v_t_won >= 10 THEN v_keys := array_append(v_keys, 'crown_10'); END IF;
IF v_league_gold >= 3 THEN v_keys := array_append(v_keys, 'league_crown_3'); END IF;

v_badges := '[]'::jsonb;
FOREACH v_key IN ARRAY v_keys
LOOP
IF EXISTS (
SELECT 1 FROM jsonb_array_elements(v_existing) e
WHERE e->>'key' = v_key
) THEN
v_badges := v_badges || (
SELECT e FROM jsonb_array_elements(v_existing) e WHERE e->>'key' = v_key LIMIT 1
);
ELSE
v_badges := v_badges || jsonb_build_array(
jsonb_build_object('key', v_key, 'earned_at', v_now)
);
END IF;
END LOOP;

UPDATE public.player_stats
SET
matches_played = v_played,
wins = v_wins,
losses = v_losses,
current_streak = v_streak,
best_win_streak = v_best,
tournaments_won = v_t_won,
tournament_finals = v_t_finals,
tournament_thirds = v_t_thirds,
last_form = v_form,
badges = v_badges,
updated_at = v_now
WHERE user_id = p_user_id;
END;
$$;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Extraiga los cálculos comunes para evitar la tercera copia de la función.

recompute_player_stats_aggregates se reescribe completa en supabase/migrations/20260810180000_094_league_badges.sql, supabase/migrations/20260810190000_096_hard_badges.sql y en este archivo. Las tres versiones repiten el bucle de partidas, el recuento de sedes, el cálculo de némesis, la agregación de ligas y la fusión de insignias. Solo cambia la lista de claves.

Esta duplicación ya produjo divergencia: el defecto de concatenación con city nula existe en las tres copias.

Extraiga los cálculos en funciones auxiliares estables, por ejemplo _player_venue_count(uuid) y _player_max_wins_vs_rival(uuid). Las migraciones posteriores que solo añaden insignias pasarían a modificar únicamente el bloque de v_keys.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260810200000_097_badge_updates.sql` around lines 6 -
202, Refactor recompute_player_stats_aggregates and the earlier duplicate
definitions so shared match aggregation, venue counting, nemesis calculation,
league aggregation, and badge merging live in stable helper functions, including
helpers such as _player_venue_count(uuid) and _player_max_wins_vs_rival(uuid).
Update each function to reuse those helpers and retain only its version-specific
v_keys additions, fixing venue counting through the shared implementation so
NULL city values are handled consistently.

Comment thread supabase/migrations/20260810200000_097_badge_updates.sql Outdated
Comment on lines +4 to +5
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS badge_showcase TEXT[] NOT NULL DEFAULT '{}';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Imponga la integridad de badge_showcase en la base de datos.

El cliente filtra insignias obtenidas, pero un usuario autenticado puede actualizar su propio perfil mediante la API y omitir esa validación. Así puede mostrar insignias no obtenidas, duplicadas o más de tres insignias en perfiles visibles.

  • supabase/migrations/20260810210000_098_badge_showcase.sql#L4-L5: añada un trigger que rechace valores vacíos, duplicados, más de tres claves y claves que no existan en player_stats.badges.
  • supabase/migrations/20260810220000_099_badge_showcase_grants.sql#L11-L17: conserve el permiso solo después de que la validación persistente proteja esta actualización directa.
📍 Affects 2 files
  • supabase/migrations/20260810210000_098_badge_showcase.sql#L4-L5 (this comment)
  • supabase/migrations/20260810220000_099_badge_showcase_grants.sql#L11-L17
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@supabase/migrations/20260810210000_098_badge_showcase.sql` around lines 4 -
5, En supabase/migrations/20260810210000_098_badge_showcase.sql, añade un
trigger de validación para badge_showcase que rechace valores vacíos,
duplicados, con más de tres claves o cuyas claves no existan en
player_stats.badges. En
supabase/migrations/20260810220000_099_badge_showcase_grants.sql, conserva el
permiso de actualización indicado, asegurándote de que la validación persistente
del trigger lo proteja.

@Japegomez
Japegomez force-pushed the release/stack-5-leagues branch from c637dfa to 98f01b0 Compare August 10, 2026 22:18
@Japegomez
Japegomez force-pushed the release/stack-5-leagues branch from 98f01b0 to 8327171 Compare August 10, 2026 22:22
Japegomez and others added 3 commits August 11, 2026 01:11
… podium (#142)

* feat(stats): add player statistics, ELO ranking, and H2H match insights

Introduce player_stats with ELO, win rate, rivalries, and badges backed by Supabase RPCs, plus profile stats screens, match H2H context, and an ELO leaderboard.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(stats): add tournament podium medals with named lists

Show gold, silver and bronze medal counts on profile stats and list each tournament title in the stats detail screen.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(stats): unify league/tournament podiums and improve profile stats UX

Merge podium medals across tournaments and leagues with source labels in detail view, surface win rate and medals at the top of profiles, and fix get_player_stats RPC volatility for PostgREST.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(matches): include phone_e164 in nested profile fallback select

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Satisfy CodeQL actions/missing-workflow-permissions by declaring least-privilege permissions on CI, Quality, Release, and Sentry health workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(leagues): add league format with round-robin, Elo, and UX polish

Introduce leagues end-to-end (schema, services, screens, explore integration) with pair management, standings, and match grouping by jornadas. Includes profile visibility fixes, auto-finish when all fixtures complete, result screen name resolution, icon-based pair actions, and navigation from matches to their league.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(nav): return to match from league and to profile from history

Preserve origin when closing match/league/tournament screens with X so league detail returns to the match and profile history returns to the profile.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(profile): polish stats UX, badge showcase, ranking and admin access

Improve profile statistics with visual podium, featured badges, unlock
popup, city/global ranking, safer account deletion, history-based
navigation, and admin bypass for private password-protected events.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(stats): refresh ELO on profile load and fix match timers

Keep league fixtures planned until played, extend tournament no-result timeout to 24h, and recalculate player stats when opening the profile. Also polish add-pair UX, ELO help copy, and empty podium messaging.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
@Japegomez
Japegomez force-pushed the release/stack-5-leagues branch from 8327171 to 0586dab Compare August 10, 2026 23:11
Base automatically changed from release/stack-4-ci-permissions to main August 11, 2026 08:54
Japegomez and others added 2 commits August 11, 2026 11:36
…ue SQL

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app/(tabs)/explore/index.tsx (1)

474-481: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

El mensaje de error sólo describe el fallo de partidas.

La condición de error ya cubre torneos y ligas. El cuerpo sigue mostrando error, que pertenece a useInfinitePublicMatches. Si sólo falla la consulta de ligas o de torneos, error es undefined y la pantalla muestra «Error desconocido». El aviso sobre la migración 009_list_public_matches tampoco corresponde a esos fallos.

Extrae también error de las consultas de torneos y ligas, y muestra el primero disponible. Muestra la nota de migración sólo cuando falla la consulta de partidas.

🐛 Corrección propuesta
   const {
     data: leagues,
     isLoading: leaguesLoading,
     isError: leaguesIsError,
+    error: leaguesError,
     isRefetching: leaguesRefetching,
     refetch: refetchLeagues,
   } = usePublicLeaguesExplore(leagueFilters)
+        const shownError = (showMatches && isError && error) || (showLeagues && leaguesError) || null
         <Text style={styles.errorMsg}>
-          {error instanceof Error ? error.message : 'Error desconocido'}
+          {shownError instanceof Error ? shownError.message : 'Error desconocido'}
         </Text>
-        <Text style={styles.errorHint}>
-          Si acabas de actualizar la app, aplica la migración Supabase `009_list_public_matches` en
-          tu proyecto.
-        </Text>
+        {showMatches && isError ? (
+          <Text style={styles.errorHint}>
+            Si acabas de actualizar la app, aplica la migración Supabase `009_list_public_matches`
+            en tu proyecto.
+          </Text>
+        ) : null}

Calcula shownError fuera del JSX.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/explore/index.tsx around lines 474 - 481, Actualiza el flujo
de error del componente de exploración para extraer también los errores de las
consultas de torneos y ligas, y calcula un `shownError` antes del JSX usando el
primer error disponible entre partidas, torneos y ligas. Usa `shownError` en el
mensaje mostrado y renderiza la nota de migración `009_list_public_matches`
únicamente cuando falle la consulta de partidas.
🟡 Other comments (2)
src/app/(tabs)/leagues/[id].tsx-416-422 (1)

416-422: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

El botón «Desafiar» de cada pareja no preselecciona el rival.

onChallenge sólo abre ChallengeModal sin indicar la pareja pulsada. El usuario debe volver a elegir el rival en la lista del modal. Guarda el id de la pareja pulsada y pásalo al modal como selección inicial.

🐛 Corrección propuesta
+  const [challengeTargetPairId, setChallengeTargetPairId] = useState<string | null>(null)
                       onChallenge={
                         canChallenge
                           ? () => {
+                              setChallengeTargetPairId(pair.id)
                               setChallengeModalOpen(true)
                             }
                           : undefined
                       }
       <ChallengeModal
         visible={challengeModalOpen}
-        onClose={() => setChallengeModalOpen(false)}
+        onClose={() => {
+          setChallengeModalOpen(false)
+          setChallengeTargetPairId(null)
+        }}
         opponents={challengeOpponents}
+        initialPairId={challengeTargetPairId}

ChallengeModal necesita aceptar la nueva prop initialPairId.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/leagues/[id].tsx around lines 416 - 422, Update the challenge
handler around onChallenge to store the pressed pair’s id before opening the
modal, then pass that id to ChallengeModal through its new initialPairId prop so
the tapped opponent is preselected.
src/services/leagues.service.ts-227-254 (1)

227-254: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Limpia end_at cuando el formato deja de ser open_elo.

updateLeague sólo escribe end_at cuando el valor recibido es verdadero. Si el organizador cambia el formato de open_elo a single_round, la fila conserva la fecha de fin anterior. La pantalla de detalle sólo muestra end_at para open_elo, así que el dato queda persistido e invisible.

🐛 Corrección propuesta
-  if (data.end_at !== undefined && data.end_at) {
-    payload.end_at = startAtToTimestamptzIso(data.end_at)
-  }
+  if (data.end_at !== undefined) {
+    payload.end_at = data.end_at ? startAtToTimestamptzIso(data.end_at) : null
+  }
+  if (data.format !== undefined && data.format !== LEAGUE_FORMAT.OPEN_ELO) {
+    payload.end_at = null
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/leagues.service.ts` around lines 227 - 254, Actualiza
updateLeague para limpiar end_at cuando el formato deja de ser open_elo,
asignando explícitamente null al payload en ese caso. Conserva la conversión
existente para una fecha end_at proporcionada y asegúrate de que el cambio de
open_elo a single_round elimine la fecha persistida.
🧹 Nitpick comments (8)
src/components/stats/BadgeShowcaseSection.tsx (1)

39-45: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

El catch anula la señal de error que espera BadgeShowcase.

BadgeShowcase.handlePick (src/components/stats/BadgeShowcase.tsx, líneas 41-49) solo muestra pickerError y mantiene el selector abierto si onChange rechaza. Aquí el catch resuelve la promesa. El selector se cierra y la ranura vuelve al valor anterior sin explicación en la propia tarjeta, además del Alert.

Elija una sola vía: relance el error tras el Alert, o elimine el catch y deje que BadgeShowcase muestre el mensaje.

♻️ Refactor propuesto
         onChange={async (next) => {
           try {
             await updateProfile.mutateAsync({ badge_showcase: next })
           } catch (err) {
             Alert.alert('Error', err instanceof Error ? err.message : 'No se pudo guardar el logro')
+            throw err
           }
         }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/stats/BadgeShowcaseSection.tsx` around lines 39 - 45, Update
the onChange handler in BadgeShowcaseSection to preserve
BadgeShowcase.handlePick’s rejection-based error flow: either rethrow err after
showing the Alert or remove the catch and rely on BadgeShowcase’s pickerError
handling, choosing only one error-display path.
src/utils/leagueFixtures.ts (1)

12-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Elimina generateRoundRobinFixtures

No hay consumidores de producción de generateRoundRobinFixtures ni de expectedMatchCount. startLeague delega la generación al RPC generate_league_fixtures. Además, ambas implementaciones ya difieren en la orientación de algunos partidos, por ejemplo con tres parejas. Conserva la utilidad solo si tendrá un consumidor; en ese caso, alinea ambas implementaciones y añade una prueba de paridad.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/leagueFixtures.ts` around lines 12 - 66, Elimina la función
generateRoundRobinFixtures y cualquier código asociado que no tenga consumidores
de producción, incluyendo expectedMatchCount si también está sin uso. Mantén
startLeague delegando exclusivamente en el RPC generate_league_fixtures; solo
conserva la utilidad si se incorpora un consumidor real, alineando entonces su
orientación con el RPC y añadiendo una prueba de paridad.
src/app/(tabs)/leagues/[id].tsx (2)

319-324: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Incluye los desafíos en el indicador de refresco.

refreshAll recarga también challengesQ en formato Open Elo, pero refreshing no observa challengesQ.isRefetching. El indicador desaparece antes de terminar la recarga.

♻️ Refactor propuesto
           <RefreshControl
-            refreshing={isRefetchingLeague || standingsQ.isRefetching || matchesQ.isRefetching}
+            refreshing={
+              isRefetchingLeague ||
+              standingsQ.isRefetching ||
+              matchesQ.isRefetching ||
+              challengesQ.isRefetching
+            }
             onRefresh={() => void refreshAll()}
           />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/leagues/[id].tsx around lines 319 - 324, Incluye
challengesQ.isRefetching en la expresión refreshing del RefreshControl junto con
isRefetchingLeague, standingsQ.isRefetching y matchesQ.isRefetching, para que el
indicador permanezca activo hasta que refreshAll termine de recargar los
desafíos.

252-253: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

openEnded no es un booleano.

La expresión devuelve false, null, o la cadena de league.end_at. Se usa después en condiciones de renderizado (líneas 386 y 432). Hoy las tres expresiones terminan en un operador ternario, así que no se renderiza texto suelto. Envuelve el valor en Boolean(...) para evitar una fuga de texto si alguien reordena esas condiciones.

♻️ Refactor propuesto
-  const openEnded =
-    isOpenEloFormat(league.format) && league.end_at && new Date(league.end_at).getTime() > nowMs
+  const openEnded = Boolean(
+    isOpenEloFormat(league.format) && league.end_at && new Date(league.end_at).getTime() > nowMs
+  )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/leagues/[id].tsx around lines 252 - 253, Update the openEnded
assignment near isOpenEloFormat so it always produces a boolean by wrapping the
existing condition in Boolean(...). Preserve the current open-format and future
end_at checks while ensuring downstream render conditions receive only true or
false.
src/utils/leagueDisplay.ts (1)

28-33: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Usa Object.hasOwn en la comprobación de la etiqueta de formato.

El operador in también encuentra propiedades heredadas del prototipo. Un valor como 'constructor' devolvería una función en lugar de una cadena y rompería el renderizado del texto. Hoy el valor procede de la base de datos, así que el riesgo es teórico.

♻️ Refactor propuesto
 export function leagueFormatDisplay(format: string): string {
-  if (format in LEAGUE_FORMAT_LABELS) {
+  if (Object.hasOwn(LEAGUE_FORMAT_LABELS, format)) {
     return LEAGUE_FORMAT_LABELS[format as LeagueFormat]
   }
   return format
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/leagueDisplay.ts` around lines 28 - 33, Actualiza la comprobación
dentro de leagueFormatDisplay para usar Object.hasOwn sobre LEAGUE_FORMAT_LABELS
en lugar del operador in, manteniendo el retorno de la etiqueta únicamente para
claves propias y el fallback format sin cambios.
src/app/(tabs)/leagues/create.tsx (1)

192-199: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Elimina el useFocusEffect vacío.

El callback no ejecuta ninguna acción y la función de limpieza está vacía. La dependencia reset tampoco se usa. El bloque sólo añade ruido y un useCallback innecesario en cada foco.

♻️ Refactor propuesto
-  useFocusEffect(
-    useCallback(() => {
-      return () => {
-        // Keep current step/state while the screen stays mounted.
-        // Full reset happens after successful creation in `finish()`.
-      }
-    }, [reset])
-  )
-

Retira también el import de useFocusEffect si deja de usarse.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/leagues/create.tsx around lines 192 - 199, Elimina el bloque
vacío de useFocusEffect alrededor del callback de la pantalla de creación de
ligas y retira el import de useFocusEffect si ya no tiene otros usos en el
archivo; no modifiques el flujo de reset de finish().
src/app/(tabs)/explore/index.tsx (1)

111-116: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Simplifica leagueStatusTone.

Tres ramas devuelven 'upcoming', incluida la rama por defecto. Sólo IN_PROGRESS cambia el tono.

♻️ Refactor propuesto
 function leagueStatusTone(league: LeagueRow): StatusDotTone {
-  if (league.status === LEAGUE_STATUS.IN_PROGRESS) return 'active'
-  if (league.status === LEAGUE_STATUS.REGISTRATION) return 'upcoming'
-  if (league.status === LEAGUE_STATUS.FINISHED) return 'upcoming' // tono neutro (gris)
-  return 'upcoming'
+  // Los demás estados usan el tono neutro.
+  return league.status === LEAGUE_STATUS.IN_PROGRESS ? 'active' : 'upcoming'
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(tabs)/explore/index.tsx around lines 111 - 116, Simplifica
leagueStatusTone para devolver 'active' únicamente cuando league.status sea
LEAGUE_STATUS.IN_PROGRESS y devolver 'upcoming' para cualquier otro estado.
Elimina las ramas redundantes de REGISTRATION, FINISHED y el caso por defecto,
manteniendo el mismo comportamiento.
src/components/ShareInviteButton.tsx (1)

30-35: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

La rama final asume league de forma implícita.

Si InviteShareKind gana un miembro nuevo, la cadena ternaria construye una URL de liga sin aviso del compilador. Usa un mapa por tipo para obtener comprobación exhaustiva.

♻️ Refactor propuesto
+const INVITE_URL_BUILDERS: Record<InviteShareKind, (id: string) => string> = {
+  match: buildMatchHttpsInviteUrl,
+  tournament: buildTournamentHttpsInviteUrl,
+  league: buildLeagueHttpsInviteUrl,
+}
-      const url =
-        kind === 'match'
-          ? buildMatchHttpsInviteUrl(id)
-          : kind === 'tournament'
-            ? buildTournamentHttpsInviteUrl(id)
-            : buildLeagueHttpsInviteUrl(id)
+      const url = INVITE_URL_BUILDERS[kind](id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ShareInviteButton.tsx` around lines 30 - 35, Replace the
nested ternary in the URL construction with an exhaustive mapping keyed by
InviteShareKind, so adding a new kind produces a compile-time error instead of
implicitly using buildLeagueHttpsInviteUrl. Update the logic around
buildMatchHttpsInviteUrl, buildTournamentHttpsInviteUrl, and
buildLeagueHttpsInviteUrl while preserving the existing URL results for match,
tournament, and league.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/`(tabs)/matches/[id].tsx:
- Around line 769-777: Update canRecordAsLeagueReferee so league round-robin
matches represented by isPlannedLeagueMatch can be recorded before they become
in progress. Replace the isInProgress-only requirement with the same
planned-or-in-progress condition used by canSubmitResult and canOpenScoreboard,
while preserving all other eligibility checks.

In `@src/hooks/useBadgeUnlocks.ts`:
- Around line 16-42: Reset the badge-tracking state when sessionUserId changes
by adding an effect keyed to sessionUserId that clears knownKeysRef, empties
queueRef, and sets current to null. Ensure the existing stats effect then treats
the new account’s badges as its initial known set and does not carry over
unlocks from the previous session.

In `@src/services/leagues.service.ts`:
- Around line 480-505: Replace the `auth_can_read_league(id) as viewer_can_read`
selection in the leagues query with an RPC or view that exposes only leagues
visible to the current authenticated user, since the scalar function cannot be
invoked this way and must depend on `auth.uid()` at query time. Preserve the
existing league fields and ensure the exploration query no longer relies on a
generated column or invalid computed select expression.

In `@supabase/migrations/20260810120000_086_leagues.sql`:
- Around line 1479-1522: Remove the EXECUTE grant for authenticated on
process_league_lifecycle, leaving execution restricted to the role used by the
cron job. Follow the existing permission pattern of
process_match_state_transitions and keep the cancel_league grant unchanged.
- Around line 1555-1567: Actualiza el flujo que inserta en match_results y
modifica matches para invocar recompute_player_stats_for_match después de que el
UPDATE establezca status en 'finished'. Mantén la inserción y la finalización
existentes, y coloca la llamada antes de continuar con recalculate_league_elo y
maybe_finish_league.

In `@supabase/migrations/20260810140000_088_league_pair_member_names.sql`:
- Around line 144-165: Actualiza el flujo de creación alrededor de la función
que inserta en league_pairs para guardar primero la pareja y calcular después el
nombre automático con league_pair_slot_label, siguiendo el orden usado por
join_league_pair. Conserva el nombre personalizado sin recalcularlo, pero para
nombres automáticos actualiza la fila recién insertada con las etiquetas
resueltas después de que la pareja exista.

In `@supabase/migrations/20260810160000_090_match_team_display_names.sql`:
- Around line 183-195: Agrega la condición m.status IN ('planned',
'in_progress') a las cláusulas WHERE de ambos backfills de torneo que actualizan
team_a_name y team_b_name, igual que en los backfills de liga, manteniendo sin
cambios el resto de los criterios.
- Around line 167-181: Actualiza ambos bloques UPDATE para aplicar el patrón
sobre el nombre almacenado en la partida (team_a_name o team_b_name), no sobre
lp.name. Mantén las condiciones de league_pair, league_id y status, y conserva
league_pair_display_name(lp) como valor de reemplazo.

In `@supabase/migrations/20260810180000_094_league_badges.sql`:
- Around line 302-311: Elimine el bloque DO de
supabase/migrations/20260810180000_094_league_badges.sql#L302-L311 y de
supabase/migrations/20260810190000_096_hard_badges.sql#L301-L310; conserve un
único backfill de player_stats en
supabase/migrations/20260810200000_097_badge_updates.sql#L206-L215, o muévalo
fuera de la migración a un proceso por lotes si el volumen lo requiere.

In `@supabase/migrations/20260810190000_096_hard_badges.sql`:
- Around line 92-100: Remove the trailing comma after the streak_before CTE
definition in the migration, immediately before the main SELECT EXISTS query.
Keep the CTE definitions and the existing SELECT EXISTS logic unchanged.
- Around line 62-78: Remove the trailing comma after the streak_before CTE and
before the final SELECT EXISTS statement, while preserving the existing
win_streak_after calculation in streak_at_match.

In `@supabase/migrations/20260810200000_097_badge_updates.sql`:
- Around line 170-185: Actualiza la reconstrucción de v_badges en el bucle
FOREACH para conservar todas las insignias de v_existing, incluso cuando sus
claves no estén en v_keys. Retira únicamente las claves obsoletas mediante una
lista explícita del catálogo y evita que insignias permanentes como
league_winner o crown_5 desaparezcan durante un recálculo.

In `@supabase/migrations/20260810210000_098_badge_showcase.sql`:
- Around line 13-31: Corrige la consulta del UPDATE que construye `keys` para
deduplicar por clave (`k`) conservando únicamente la primera posición (`ord`) de
cada clave, en lugar de usar `DISTINCT k, ord`. Aplica `btrim` al valor antes de
filtrar y almacenar, rechazando valores nulos o vacíos tras recortar espacios, y
conserva el límite de tres claves y su orden original.
- Around line 39-44: Update the SECURITY DEFINER functions
validate_badge_showcase and get_viewable_user_profile to use SET search_path =
''. Within validate_badge_showcase, qualify unnest, btrim, length, cardinality,
jsonb_array_elements, and count with pg_catalog., while preserving the existing
auth.uid() qualification.

In `@supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql`:
- Around line 160-178: Replace the fragile pg_get_functiondef text-rewrite block
with an explicit CREATE OR REPLACE FUNCTION definition for
get_player_stats(uuid), preserving its existing signature, return type, security
attributes, and behavior while calling refresh_player_stats directly. Remove the
dynamic replacement and related string checks so the complete function
definition is versioned and readable.
- Around line 138-150: Elimina de refresh_player_stats la actualización de
profiles.badge_showcase basada en player_stats, evitando que get_player_stats
modifique perfiles ajenos durante una lectura. Conserva únicamente la lectura o
recálculo necesario de estadísticas y traslada la depuración del showcase al
flujo que recalcula las insignias después de confirmar un resultado, incluyendo
también el caso de rivales y compañeros gestionado por rebuild_player_elo.

---

Outside diff comments:
In `@src/app/`(tabs)/explore/index.tsx:
- Around line 474-481: Actualiza el flujo de error del componente de exploración
para extraer también los errores de las consultas de torneos y ligas, y calcula
un `shownError` antes del JSX usando el primer error disponible entre partidas,
torneos y ligas. Usa `shownError` en el mensaje mostrado y renderiza la nota de
migración `009_list_public_matches` únicamente cuando falle la consulta de
partidas.

---

Other comments:
In `@src/app/`(tabs)/leagues/[id].tsx:
- Around line 416-422: Update the challenge handler around onChallenge to store
the pressed pair’s id before opening the modal, then pass that id to
ChallengeModal through its new initialPairId prop so the tapped opponent is
preselected.

In `@src/services/leagues.service.ts`:
- Around line 227-254: Actualiza updateLeague para limpiar end_at cuando el
formato deja de ser open_elo, asignando explícitamente null al payload en ese
caso. Conserva la conversión existente para una fecha end_at proporcionada y
asegúrate de que el cambio de open_elo a single_round elimine la fecha
persistida.

---

Nitpick comments:
In `@src/app/`(tabs)/explore/index.tsx:
- Around line 111-116: Simplifica leagueStatusTone para devolver 'active'
únicamente cuando league.status sea LEAGUE_STATUS.IN_PROGRESS y devolver
'upcoming' para cualquier otro estado. Elimina las ramas redundantes de
REGISTRATION, FINISHED y el caso por defecto, manteniendo el mismo
comportamiento.

In `@src/app/`(tabs)/leagues/[id].tsx:
- Around line 319-324: Incluye challengesQ.isRefetching en la expresión
refreshing del RefreshControl junto con isRefetchingLeague,
standingsQ.isRefetching y matchesQ.isRefetching, para que el indicador
permanezca activo hasta que refreshAll termine de recargar los desafíos.
- Around line 252-253: Update the openEnded assignment near isOpenEloFormat so
it always produces a boolean by wrapping the existing condition in Boolean(...).
Preserve the current open-format and future end_at checks while ensuring
downstream render conditions receive only true or false.

In `@src/app/`(tabs)/leagues/create.tsx:
- Around line 192-199: Elimina el bloque vacío de useFocusEffect alrededor del
callback de la pantalla de creación de ligas y retira el import de
useFocusEffect si ya no tiene otros usos en el archivo; no modifiques el flujo
de reset de finish().

In `@src/components/ShareInviteButton.tsx`:
- Around line 30-35: Replace the nested ternary in the URL construction with an
exhaustive mapping keyed by InviteShareKind, so adding a new kind produces a
compile-time error instead of implicitly using buildLeagueHttpsInviteUrl. Update
the logic around buildMatchHttpsInviteUrl, buildTournamentHttpsInviteUrl, and
buildLeagueHttpsInviteUrl while preserving the existing URL results for match,
tournament, and league.

In `@src/components/stats/BadgeShowcaseSection.tsx`:
- Around line 39-45: Update the onChange handler in BadgeShowcaseSection to
preserve BadgeShowcase.handlePick’s rejection-based error flow: either rethrow
err after showing the Alert or remove the catch and rely on BadgeShowcase’s
pickerError handling, choosing only one error-display path.

In `@src/utils/leagueDisplay.ts`:
- Around line 28-33: Actualiza la comprobación dentro de leagueFormatDisplay
para usar Object.hasOwn sobre LEAGUE_FORMAT_LABELS en lugar del operador in,
manteniendo el retorno de la etiqueta únicamente para claves propias y el
fallback format sin cambios.

In `@src/utils/leagueFixtures.ts`:
- Around line 12-66: Elimina la función generateRoundRobinFixtures y cualquier
código asociado que no tenga consumidores de producción, incluyendo
expectedMatchCount si también está sin uso. Mantén startLeague delegando
exclusivamente en el RPC generate_league_fixtures; solo conserva la utilidad si
se incorpora un consumidor real, alineando entonces su orientación con el RPC y
añadiendo una prueba de paridad.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: QUIET

Plan: Pro Plus

Run ID: 856152e0-adc9-4183-8508-a7d097bc46e9

📥 Commits

Reviewing files that changed from the base of the PR and between c637dfa and e37df09.

📒 Files selected for processing (26)
  • src/app/(tabs)/explore/index.tsx
  • src/app/(tabs)/leagues/[id].tsx
  • src/app/(tabs)/leagues/create.tsx
  • src/app/(tabs)/matches/[id].tsx
  • src/app/(tabs)/matches/index.tsx
  • src/components/ShareInviteButton.tsx
  • src/components/leagues/StandingsTable.tsx
  • src/components/stats/BadgeShowcaseSection.tsx
  • src/components/stats/BadgeUnlockPopup.tsx
  • src/components/stats/ProfileStatsCard.tsx
  • src/components/stats/RankingSection.tsx
  • src/hooks/useBadgeUnlocks.ts
  • src/services/leagues.service.ts
  • src/services/stats.service.ts
  • src/types/database.types.ts
  • src/utils/leagueDisplay.ts
  • src/utils/leagueFixtures.ts
  • supabase/migrations/20260810120000_086_leagues.sql
  • supabase/migrations/20260810140000_088_league_pair_member_names.sql
  • supabase/migrations/20260810160000_090_match_team_display_names.sql
  • supabase/migrations/20260810180000_094_league_badges.sql
  • supabase/migrations/20260810190000_096_hard_badges.sql
  • supabase/migrations/20260810200000_097_badge_updates.sql
  • supabase/migrations/20260810210000_098_badge_showcase.sql
  • supabase/migrations/20260811000000_102_match_timers_tournament_league.sql
  • supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql

Comment thread src/app/(tabs)/matches/[id].tsx
Comment thread src/hooks/useBadgeUnlocks.ts
Comment on lines +480 to +505
let query = supabase
.from('leagues')
.select(
[
'id',
'title',
'description',
'notes',
'start_at',
'end_at',
'city',
'place_defined',
'place_text',
'duration_target_games',
'visibility',
'location_privacy',
'format',
'status',
'creator_id',
'fixtures_generated_at',
'elo_initial',
'elo_k_factor',
// Helper to check access to private leagues.
'auth_can_read_league(id) as viewer_can_read',
].join(',')
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- definición de auth_can_read_league ---'
rg -n -C6 'auth_can_read_league' supabase/migrations -g '*.sql'

echo '--- otros usos de sintaxis func(...) as alias en select() ---'
rg -n -C3 "select\(" src/services -g '*.ts' | rg -n -C3 '\) as '

echo '--- vistas o columnas generadas para exploración de ligas ---'
rg -n -C4 'create (or replace )?view|generated always as' supabase/migrations -g '*league*'

Repository: Japegomez/jugaMUS

Length of output: 19469


🌐 Web query:

PostgREST select parameter支持 function call computed field syntax

💡 Result:

In PostgREST, computed fields are implemented as PostgreSQL functions that take a table type as an argument [1][2]. When you want to include these computed fields in a response, you simply list their name in the select parameter, just as you would with a regular column [1][2]. For example, if you have a function named full_name that takes the people table as an argument: CREATE FUNCTION full_name(people) RETURNS text AS $$ SELECT $1.first_name || ' ' || $1.last_name; $$ LANGUAGE SQL; You can retrieve it via the select parameter: GET /people?select=full_name,job The syntax for including a computed field does not require special function call syntax (such as parentheses) when it is treated as a computed column [3]. If you were to add parentheses, such as select=full_name, PostgREST would typically interpret this as a request for resource embedding or a different type of operation, which may lead to unexpected results or errors if the function is not designed as a table-valued function for embedding [3]. Key distinctions to keep in mind: 1. Computed Columns: Defined as functions on a table type. Accessed in the select parameter by their name only (e.g.,?select=my_computed_field) [1][2]. 2. Computed Relationships: If the function returns a table (set of rows), it can be used for resource embedding. In this case, you use the embedding syntax with parentheses (e.g.,?select=,my_function()) [4][3]. 3. RPC Functions: Functions not tied to a table type are accessed via the /rpc path rather than the select parameter on a table endpoint [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- servicio: consulta y filtrado de ligas ---'
sed -n '450,565p' src/services/leagues.service.ts

echo '--- función de autorización ---'
sed -n '65,105p' supabase/migrations/20260810240000_101_admin_private_access.sql
sed -n '165,205p' supabase/migrations/20260810120000_086_leagues.sql

echo '--- usos de viewer_can_read ---'
rg -n -C4 'viewer_can_read|auth_can_read_league\(id\)' src supabase -g '*.ts' -g '*.sql'

echo '--- comprobación estructural de la cadena select ---'
python3 - <<'PY'
from pathlib import Path
import re

path = Path("src/services/leagues.service.ts")
text = path.read_text()
match = re.search(r"\.select\(\s*\[\s*(.*?)\s*\]\.join\(['\"][,]?['\"]\)", text, re.S)
if not match:
    raise SystemExit("No se encontró la lista select de leagues.service.ts")
items = match.group(1)
target = "auth_can_read_league(id) as viewer_can_read"
print("target_present:", target in items)
print("target_is_scalar_call_alias:", bool(re.search(r"\b\w+\([^)]*\)\s+as\s+\w+", items)))
print("select_items:")
for line in items.splitlines():
    if "auth_can_read_league" in line or "::" in line or " as " in line:
        print(line.strip())
PY

Repository: Japegomez/jugaMUS

Length of output: 8418


Corrige el campo calculado de acceso

auth_can_read_league(uuid) existe, pero es una función escalar y no puede usarse con auth_can_read_league(id) as viewer_can_read en select. La consulta puede devolver un error 400 y dejar vacía la exploración. Usa un RPC o una vista que exponga las ligas visibles; una columna generada no es adecuada porque el resultado depende de auth.uid().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/leagues.service.ts` around lines 480 - 505, Replace the
`auth_can_read_league(id) as viewer_can_read` selection in the leagues query
with an RPC or view that exposes only leagues visible to the current
authenticated user, since the scalar function cannot be invoked this way and
must depend on `auth.uid()` at query time. Preserve the existing league fields
and ensure the exploration query no longer relies on a generated column or
invalid computed select expression.

Comment thread supabase/migrations/20260810120000_086_leagues.sql Outdated
Comment thread supabase/migrations/20260810120000_086_leagues.sql
Comment thread supabase/migrations/20260810200000_097_badge_updates.sql Outdated
Comment thread supabase/migrations/20260810210000_098_badge_showcase.sql
Comment thread supabase/migrations/20260810210000_098_badge_showcase.sql
Comment thread supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql Outdated
Comment thread supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql Outdated
Japegomez and others added 3 commits August 11, 2026 11:51
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Tighten league referee recording, badge unlock resets, explore errors, and SQL migrations; drop unused client round-robin fixtures helper.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Japegomez
Japegomez merged commit 83319c0 into main Aug 11, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant