diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7d8022e..8537a1e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,8 +6,8 @@ on:
pull_request:
branches: [develop, main]
-# Caller workflow invokes reusable workflow (quality.yml).
-# It must allow the permissions required by the called workflow checkout.
+# Caller workflows that only invoke reusable jobs need an empty permissions block.
+# The called workflow (quality.yml) declares its own token scope.
permissions:
contents: read
diff --git a/locales/es.json b/locales/es.json
index b10ee1f..e8c0272 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -1,6 +1,6 @@
{
"ios": {
"CFBundleDisplayName": "jugaMUS",
- "NSPhotoLibraryUsageDescription": "jugaMUS accede a tus fotos para que puedas elegir una imagen de perfil. La foto se sube a tu cuenta y la verán otros jugadores en las partidas y torneos en los que participes."
+ "NSPhotoLibraryUsageDescription": "jugaMUS accede a tus fotos para que puedas elegir una imagen de perfil. La foto se sube a tu cuenta y la verán otros jugadores en las partidas, torneos y ligas en los que participes."
}
}
diff --git a/package-lock.json b/package-lock.json
index 0bd9135..573ce58 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -45,6 +45,7 @@
"react-hook-form": "^7.83.0",
"react-native": "0.81.5",
"react-native-chart-kit": "^6.12.2",
+ "react-native-confetti-cannon": "^1.5.2",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
@@ -16109,6 +16110,12 @@
"react-native-svg": "> 6.4.1"
}
},
+ "node_modules/react-native-confetti-cannon": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/react-native-confetti-cannon/-/react-native-confetti-cannon-1.5.2.tgz",
+ "integrity": "sha512-IZuWjlW7QsdxEGNnvpD6W+7iKCCQhnd5BvuNvMtirU7Nxm8WS2N6LPGMBz1ZYDuusG+GRZkoXXTNCdoAAGpCTg==",
+ "license": "MIT"
+ },
"node_modules/react-native-is-edge-to-edge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
diff --git a/package.json b/package.json
index 29c0072..c7c2d1f 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"react-hook-form": "^7.83.0",
"react-native": "0.81.5",
"react-native-chart-kit": "^6.12.2",
+ "react-native-confetti-cannon": "^1.5.2",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-svg": "15.12.1",
diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx
index c25070f..f97c7b7 100644
--- a/src/app/(tabs)/_layout.tsx
+++ b/src/app/(tabs)/_layout.tsx
@@ -31,6 +31,7 @@ export default function TabsLayout() {
return (
+
+
+
diff --git a/src/app/(tabs)/explore/index.tsx b/src/app/(tabs)/explore/index.tsx
index 0690c76..593127e 100644
--- a/src/app/(tabs)/explore/index.tsx
+++ b/src/app/(tabs)/explore/index.tsx
@@ -29,14 +29,25 @@ import {
parseIsoToDate,
} from '@/components/ui/dateTimePickerUtils'
import { MunicipalityPicker } from '@/components/ui/MunicipalityPicker'
-import { MATCH_STATUS, TOURNAMENT_STATUS, type ExploreContentType } from '@/constants'
-import { useInfinitePublicMatches, usePublicTournamentsExplore } from '@/hooks/useMatches'
+import {
+ LEAGUE_STATUS,
+ MATCH_STATUS,
+ TOURNAMENT_STATUS,
+ type ExploreContentType,
+} from '@/constants'
+import {
+ useInfinitePublicMatches,
+ usePublicLeaguesExplore,
+ usePublicTournamentsExplore,
+} from '@/hooks/useMatches'
import type {
PublicMatchExplorerRow,
PublicMatchesListFilters,
VisibilityFilter,
} from '@/services/matches.service'
+import type { LeagueRow, PublicLeaguesListFilters } from '@/services/leagues.service'
import type { PublicTournamentsListFilters, TournamentRow } from '@/services/tournaments.service'
+import { leagueFormatDisplay, leagueStatusDisplay } from '@/utils/leagueDisplay'
import { Colors } from '@/theme/colors'
import { Fonts } from '@/theme/typography'
import { screenTopPadding } from '@/theme/layout'
@@ -97,6 +108,10 @@ function tournamentStatusTone(tournament: TournamentRow): StatusDotTone {
return 'upcoming'
}
+function leagueStatusTone(league: LeagueRow): StatusDotTone {
+ return league.status === LEAGUE_STATUS.IN_PROGRESS ? 'active' : 'upcoming'
+}
+
function ExploreMatchRow({ row, onPress }: { row: PublicMatchExplorerRow; onPress: () => void }) {
const tone = matchStatusTone(row.status)
return (
@@ -158,6 +173,37 @@ function ExploreTournamentRow({ row, onPress }: { row: TournamentRow; onPress: (
)
}
+function ExploreLeagueRow({ row, onPress }: { row: LeagueRow; onPress: () => void }) {
+ const tone = leagueStatusTone(row)
+ return (
+
+
+
+ Liga · {leagueFormatDisplay(row.format)}
+
+ {row.title}
+
+
+ {formatCityAndPlace(row.city, row.place_defined, row.place_text)}
+
+
+
+
+ {leagueStatusDisplay(row).text}
+
+ {formatDisplay(row.start_at)}
+
+ {row.status === LEAGUE_STATUS.REGISTRATION ? 'Sin iniciar' : 'Ver clasificación'}
+
+
+
+ )
+}
+
export default function ExploreScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
@@ -213,10 +259,36 @@ export default function ExploreScreen() {
const {
data: tournaments,
isLoading: tournamentsLoading,
+ isError: tournamentsIsError,
+ error: tournamentsError,
isRefetching: tournamentsRefetching,
refetch: refetchTournaments,
} = usePublicTournamentsExplore(tournamentFilters)
+ 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]
+ )
+
+ const {
+ data: leagues,
+ isLoading: leaguesLoading,
+ isError: leaguesIsError,
+ error: leaguesError,
+ isRefetching: leaguesRefetching,
+ refetch: refetchLeagues,
+ } = usePublicLeaguesExplore(leagueFilters)
+
const matchRows = useMemo(() => data?.pages.flatMap((p) => p.rows) ?? [], [data?.pages])
const exploreItems = useMemo((): ExploreItem[] => {
@@ -232,16 +304,23 @@ export default function ExploreScreen() {
start_at: row.start_at,
row,
}))
- const merged = [...matchItems, ...tournamentItems].sort(
+ const leagueItems: ExploreItem[] = (leagues ?? []).map((row) => ({
+ kind: 'league',
+ id: row.id,
+ start_at: row.start_at,
+ row,
+ }))
+ const merged = [...matchItems, ...tournamentItems, ...leagueItems].sort(
(a, b) => new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
)
return filterExploreItemsForCelebrated(merged, filters.hideCelebrated)
- }, [matchRows, tournaments, filters.hideCelebrated])
+ }, [matchRows, tournaments, leagues, filters.hideCelebrated])
const refetchAll = useCallback(() => {
void refetch()
void refetchTournaments()
- }, [refetch, refetchTournaments])
+ void refetchLeagues()
+ }, [refetch, refetchTournaments, refetchLeagues])
const openFilterModal = useCallback(() => {
setDraftCity(filters.city)
@@ -323,7 +402,7 @@ export default function ExploreScreen() {
const listHeader = (
-
+
)
- const showMatches = filters.contentType !== 'tournaments'
- const showTournaments = filters.contentType !== 'matches'
+ const showMatches = filters.contentType !== 'tournaments' && filters.contentType !== 'leagues'
+ const showTournaments = filters.contentType !== 'matches' && filters.contentType !== 'leagues'
+ const showLeagues = filters.contentType !== 'matches' && filters.contentType !== 'tournaments'
const listFooter = isFetchingNextPage ? (
@@ -368,17 +448,27 @@ export default function ExploreScreen() {
if (
(showMatches && isLoading && !data) ||
- (showTournaments && tournamentsLoading && !tournaments)
+ (showTournaments && tournamentsLoading && !tournaments) ||
+ (showLeagues && leaguesLoading && !leagues)
) {
return (
- Cargando partidas y torneos…
+ Cargando partidas, torneos y ligas…
)
}
- if (isError) {
+ if (
+ (showMatches && isError) ||
+ (showTournaments && tournamentsIsError) ||
+ (showLeagues && leaguesIsError)
+ ) {
+ const shownError =
+ (showMatches && isError ? error : null) ??
+ (showTournaments && tournamentsIsError ? tournamentsError : null) ??
+ (showLeagues && leaguesIsError ? leaguesError : null)
+
return (
No se pudo cargar el listado
- {error instanceof Error ? error.message : 'Error desconocido'}
+ {shownError instanceof Error ? shownError.message : 'Error desconocido'}
-
- Si acabas de actualizar la app, aplica la migración Supabase `009_list_public_matches` en
- tu proyecto.
-
-
)
}
@@ -409,11 +501,16 @@ export default function ExploreScreen() {
row={item.row}
onPress={() => router.push(`/(tabs)/matches/${item.id}`)}
/>
- ) : (
+ ) : item.kind === 'tournament' ? (
router.push(`/(tabs)/tournaments/${item.id}`)}
/>
+ ) : (
+ router.push(`/(tabs)/leagues/${item.id}`)}
+ />
)
}
ListHeaderComponent={listHeader}
@@ -421,7 +518,9 @@ export default function ExploreScreen() {
contentContainerStyle={[styles.listContent, { paddingBottom: insets.bottom + 88 }]}
onEndReached={onEndReached}
onEndReachedThreshold={0.35}
- refreshing={(isRefetching && !isFetchingNextPage) || tournamentsRefetching}
+ refreshing={
+ (isRefetching && !isFetchingNextPage) || tournamentsRefetching || leaguesRefetching
+ }
onRefresh={refetchAll}
ListEmptyComponent={
@@ -431,7 +530,9 @@ export default function ExploreScreen() {
? 'No hay partidas que coincidan con tu búsqueda y filtros.'
: filters.contentType === 'tournaments'
? 'No hay torneos que coincidan con tu búsqueda y filtros.'
- : 'No hay partidas ni torneos que coincidan con tu búsqueda y filtros.'}{' '}
+ : filters.contentType === 'leagues'
+ ? 'No hay ligas que coincidan con tu búsqueda y filtros.'
+ : 'No hay partidas, torneos ni ligas que coincidan con tu búsqueda y filtros.'}{' '}
Prueba a ampliar fechas o quitar filtros.
{
const selected = draftContentType === opt.value
diff --git a/src/app/(tabs)/leaderboard/index.tsx b/src/app/(tabs)/leaderboard/index.tsx
index 20fbb14..ec8d11b 100644
--- a/src/app/(tabs)/leaderboard/index.tsx
+++ b/src/app/(tabs)/leaderboard/index.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useCallback, useState } from 'react'
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useRouter, type Href } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -15,11 +15,19 @@ export default function LeaderboardScreen() {
const [city, setCity] = useState('')
const { data, isPending, isError, refetch } = useLeaderboard(city || null)
+ const goBack = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
+ router.replace('/(tabs)/profile' as Href)
+ }, [router])
+
return (
router.back()}
+ onPress={goBack}
accessibilityRole="button"
hitSlop={8}
style={styles.closeWrap}>
diff --git a/src/app/(tabs)/leagues/[id].tsx b/src/app/(tabs)/leagues/[id].tsx
new file mode 100644
index 0000000..3784fbe
--- /dev/null
+++ b/src/app/(tabs)/leagues/[id].tsx
@@ -0,0 +1,677 @@
+import { useCallback, useState } from 'react'
+import { useFocusEffect } from '@react-navigation/native'
+import { useLocalSearchParams, useRouter, type Href } from 'expo-router'
+import {
+ ActivityIndicator,
+ Pressable,
+ RefreshControl,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+
+import {
+ AddLeaguePairModal,
+ type AddLeaguePairFormValues,
+} from '@/components/leagues/AddLeaguePairModal'
+import { CancelLeagueModal } from '@/components/leagues/CancelLeagueModal'
+import { ChallengeList } from '@/components/leagues/ChallengeList'
+import { ChallengeModal } from '@/components/leagues/ChallengeModal'
+import { EditLeaguePairModal } from '@/components/leagues/EditLeaguePairModal'
+import { EloRanking } from '@/components/leagues/EloRanking'
+import { LeaguePairCard } from '@/components/leagues/LeaguePairCard'
+import { StandingsTable } from '@/components/leagues/StandingsTable'
+import { AddPairButton } from '@/components/ui/AddPairButton'
+import { Button } from '@/components/ui/Button'
+import { ShareInviteButton } from '@/components/ShareInviteButton'
+import { formatDisplay } from '@/components/ui/dateTimePickerUtils'
+import { MatchPasswordModal } from '@/components/matches/MatchPasswordModal'
+import { LEAGUE_STATUS, MATCH_VISIBILITY } from '@/constants'
+import { useAuthStore } from '@/hooks/useAuth'
+import {
+ useAcceptLeagueChallenge,
+ useAddLeaguePair,
+ useCancelLeague,
+ useCreateLeagueChallenge,
+ useGrantLeaguePasswordAccess,
+ useJoinLeaguePair,
+ useLeague,
+ useLeagueChallenges,
+ useLeagueMatches,
+ useLeagueStandings,
+ useRejectLeagueChallenge,
+ useRemoveLeaguePair,
+ useStartLeague,
+ useUpdateLeaguePair,
+} from '@/hooks/useLeagues'
+import {
+ canEditLeaguePair,
+ canJoinLeaguePair,
+ findUserLeaguePairId,
+ isLeaguePairComplete,
+ userIsInLeaguePair,
+ type LeagueMatchRow,
+ type LeaguePairRow,
+} from '@/services/leagues.service'
+import { confirmAlert, showAlert } from '@/utils/alert'
+import {
+ isOpenEloFormat,
+ isRoundRobinFormat,
+ leagueFormatDisplay,
+ leagueStatusDisplay,
+} from '@/utils/leagueDisplay'
+import { formatCityAndPlace } from '@/utils/location'
+import { matchStatusDisplay } from '@/utils/matchDisplay'
+
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+import { screenTopPadding } from '@/theme/layout'
+
+type TabKey = 'standings' | 'matches' | 'pairs'
+
+function LeagueMatchCard({ match, onPress }: { match: LeagueMatchRow; onPress: () => void }) {
+ const status = matchStatusDisplay({ status: match.status })
+ const score =
+ match.team_a_games != null && match.team_b_games != null
+ ? `${match.team_a_games} - ${match.team_b_games}`
+ : null
+ return (
+
+
+
+ {match.pair_a_name ?? 'Pareja A'} vs {match.pair_b_name ?? 'Pareja B'}
+
+
+ {status.text}
+
+
+
+ {match.round_number != null ? `Jornada ${match.round_number} · ` : ''}
+ {formatDisplay(match.start_at)}
+ {score ? ` · ${score}` : ''}
+
+
+ )
+}
+
+type RoundGroup = {
+ key: string
+ label: string
+ order: number
+ matches: LeagueMatchRow[]
+}
+
+function groupMatchesByRound(matches: LeagueMatchRow[]): RoundGroup[] {
+ const byKey = new Map()
+ for (const m of matches) {
+ const hasRound = m.round_number != null
+ const key = hasRound ? `round-${m.round_number}${m.is_second_leg ? '-vuelta' : ''}` : 'no-round'
+ if (!byKey.has(key)) {
+ byKey.set(key, {
+ key,
+ label: hasRound
+ ? `Jornada ${m.round_number}${m.is_second_leg ? ' (vuelta)' : ''}`
+ : 'Partidos',
+ order: hasRound ? m.round_number! * 10 + (m.is_second_leg ? 1 : 0) : 9999,
+ matches: [],
+ })
+ }
+ byKey.get(key)!.matches.push(m)
+ }
+ return Array.from(byKey.values()).sort((a, b) => a.order - b.order)
+}
+
+function MatchesByRound({
+ matches,
+ inRegistration,
+ onMatchPress,
+}: {
+ matches: LeagueMatchRow[]
+ inRegistration: boolean
+ onMatchPress: (matchId: string) => void
+}) {
+ if (matches.length === 0) {
+ return (
+
+ {inRegistration ? 'Los partidos aparecerán al iniciar la liga' : 'Aún no hay partidos'}
+
+ )
+ }
+ const groups = groupMatchesByRound(matches)
+ return (
+
+ {groups.map((g) => (
+
+ {g.label}
+ {g.matches.map((m) => (
+ onMatchPress(m.match_id)} />
+ ))}
+
+ ))}
+
+ )
+}
+
+export default function LeagueDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>()
+ const router = useRouter()
+ const insets = useSafeAreaInsets()
+ const userId = useAuthStore((s) => s.session?.user.id)
+
+ const closeLeagueDetail = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
+ router.replace('/(tabs)/matches' as Href)
+ }, [router])
+
+ const {
+ data: league,
+ isLoading,
+ isError,
+ refetch: refetchLeague,
+ isRefetching: isRefetchingLeague,
+ } = useLeague(id)
+
+ const [tab, setTab] = useState('pairs')
+ const [pairModalOpen, setPairModalOpen] = useState(false)
+ const [editingPair, setEditingPair] = useState(null)
+ const [challengeModalOpen, setChallengeModalOpen] = useState(false)
+ const [passwordModalDismissed, setPasswordModalDismissed] = useState(false)
+ const [cancelVisible, setCancelVisible] = useState(false)
+ const [nowMs, setNowMs] = useState(() => Date.now())
+ const [challengeActionId, setChallengeActionId] = useState(null)
+
+ const needsPassword = Boolean(
+ league &&
+ league.visibility === MATCH_VISIBILITY.PRIVATE &&
+ league.viewer_has_full_access === false
+ )
+ const passwordModalVisible = needsPassword && !passwordModalDismissed
+ const fullAccess = !needsPassword
+
+ const standingsQ = useLeagueStandings(id, fullAccess)
+ const matchesQ = useLeagueMatches(id, fullAccess)
+ const challengesQ = useLeagueChallenges(
+ id,
+ fullAccess && Boolean(league && isOpenEloFormat(league.format))
+ )
+
+ const grantAccess = useGrantLeaguePasswordAccess()
+ const addPair = useAddLeaguePair()
+ const joinPair = useJoinLeaguePair()
+ const updatePair = useUpdateLeaguePair()
+ const removePair = useRemoveLeaguePair()
+ const startLeague = useStartLeague()
+ const cancelLeague = useCancelLeague()
+ const createChallenge = useCreateLeagueChallenge()
+ const acceptChallenge = useAcceptLeagueChallenge()
+ const rejectChallenge = useRejectLeagueChallenge()
+
+ useFocusEffect(
+ useCallback(() => {
+ setNowMs(Date.now())
+ void refetchLeague()
+ if (fullAccess) {
+ void standingsQ.refetch()
+ void matchesQ.refetch()
+ void challengesQ.refetch()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- refetch on focus only
+ }, [fullAccess, id, refetchLeague])
+ )
+
+ if (isLoading && !league) {
+ return (
+
+
+
+ )
+ }
+
+ if (isError || !league) {
+ return (
+
+ No se pudo cargar la liga
+
+
+ )
+ }
+
+ const status = leagueStatusDisplay(league)
+ const isCreator = userId === league.creator_id
+ const inRegistration = league.status === LEAGUE_STATUS.REGISTRATION
+ const inProgress = league.status === LEAGUE_STATUS.IN_PROGRESS
+ const acceptingPairs = inRegistration || inProgress
+ const userPairId = userId ? findUserLeaguePairId(league.pairs, userId) : null
+ const completePairs = league.pairs.filter(isLeaguePairComplete)
+ const canStart = isCreator && inRegistration && completePairs.length >= 2
+ const openEnded = Boolean(
+ isOpenEloFormat(league.format) && league.end_at && new Date(league.end_at).getTime() > nowMs
+ )
+
+ const refreshAll = async () => {
+ await refetchLeague()
+ if (fullAccess) {
+ await Promise.all([
+ standingsQ.refetch(),
+ matchesQ.refetch(),
+ isOpenEloFormat(league.format) ? challengesQ.refetch() : Promise.resolve(),
+ ])
+ }
+ }
+
+ const handleAddPair = async (values: AddLeaguePairFormValues) => {
+ if (!userId) return
+ const already = userIsInLeaguePair(league.pairs, userId)
+ const useSelfA = values.playerAIsSelf && !already
+ const useSelfB = values.playerBIsSelf && !already && !useSelfA
+ try {
+ await addPair.mutateAsync({
+ leagueId: id,
+ name: values.name.trim() || undefined,
+ playerAUserId: useSelfA ? userId : null,
+ playerAText: useSelfA ? null : values.playerAText.trim() || null,
+ playerBUserId: useSelfB ? userId : null,
+ playerBText: useSelfB ? null : values.playerBText.trim() || null,
+ })
+ setPairModalOpen(false)
+ } catch (err) {
+ showAlert('Error', err instanceof Error ? err.message : 'No se pudo añadir')
+ throw err
+ }
+ }
+
+ const handleStart = async () => {
+ const ok = await confirmAlert(
+ 'Iniciar liga',
+ isRoundRobinFormat(league.format)
+ ? 'Se generarán todos los enfrentamientos. Podrás seguir añadiendo parejas (jugarán catch-up). ¿Continuar?'
+ : 'La liga abierta empezará y las parejas podrán desafiarse hasta la fecha de fin. ¿Continuar?'
+ )
+ if (!ok) return
+ try {
+ await startLeague.mutateAsync({ leagueId: id, format: league.format })
+ setTab('standings')
+ } catch (err) {
+ showAlert('Error', err instanceof Error ? err.message : 'No se pudo iniciar')
+ }
+ }
+
+ const challengeOpponents = completePairs.filter((p) => p.id !== userPairId)
+
+ return (
+
+
+
+ ✕
+
+
+
+ void refreshAll()}
+ />
+ }>
+ {league.title}
+
+
+ {status.text}
+
+ {leagueFormatDisplay(league.format)}
+
+
+ {formatCityAndPlace(league.city, league.place_defined, league.place_text)}
+
+ Inicio: {formatDisplay(league.start_at)}
+ {isOpenEloFormat(league.format) && league.end_at ? (
+ Fin: {formatDisplay(league.end_at)}
+ ) : null}
+ {league.organizer_display_name ? (
+ Organiza: {league.organizer_display_name}
+ ) : null}
+ {league.description ? {league.description} : null}
+
+ {!needsPassword ? (
+
+ ) : null}
+
+ {fullAccess ? (
+ <>
+
+ {(
+ [
+ ['pairs', 'Parejas'],
+ ['standings', isOpenEloFormat(league.format) ? 'Elo' : 'Clasificación'],
+ ['matches', 'Partidos'],
+ ] as const
+ ).map(([key, label]) => (
+ setTab(key)}
+ accessibilityRole="button"
+ accessibilityState={{ selected: tab === key }}>
+ {label}
+
+ ))}
+
+
+ {tab === 'pairs' ? (
+
+ {league.pairs.map((pair) => {
+ const join = canJoinLeaguePair(pair, userId, league.pairs, league.status)
+ const canEdit = canEditLeaguePair(pair, userId, isCreator, league.status)
+ const canChallenge =
+ inProgress &&
+ isOpenEloFormat(league.format) &&
+ Boolean(userPairId) &&
+ pair.id !== userPairId &&
+ isLeaguePairComplete(pair) &&
+ openEnded
+ return (
+ setEditingPair(pair) : undefined}
+ joinLabel={join.canJoin ? 'Unirme' : undefined}
+ onJoin={
+ join.canJoin && join.openSlot
+ ? () =>
+ void joinPair
+ .mutateAsync({
+ pairId: pair.id,
+ slot: join.openSlot!,
+ leagueId: id,
+ })
+ .catch((err) =>
+ showAlert(
+ 'Error',
+ err instanceof Error ? err.message : 'No se pudo unir'
+ )
+ )
+ : undefined
+ }
+ joinLoading={joinPair.isPending}
+ challengeLabel={canChallenge ? 'Desafiar' : undefined}
+ onChallenge={
+ canChallenge
+ ? () => {
+ setChallengeModalOpen(true)
+ }
+ : undefined
+ }
+ />
+ )
+ })}
+ {acceptingPairs ? (
+ 0}
+ onPress={() => setPairModalOpen(true)}
+ />
+ ) : null}
+ {inProgress && isOpenEloFormat(league.format) && userPairId && openEnded ? (
+ setChallengeModalOpen(true)}
+ style={{ marginTop: 8 }}
+ />
+ ) : null}
+ {inProgress && isOpenEloFormat(league.format) ? (
+
+ Desafíos
+ {
+ setChallengeActionId(challengeId)
+ void acceptChallenge
+ .mutateAsync({ challengeId, leagueId: id })
+ .then((ch) => {
+ if (ch.match_id) {
+ router.push(`/(tabs)/matches/${ch.match_id}` as Href)
+ }
+ })
+ .catch((err) =>
+ showAlert(
+ 'Error',
+ err instanceof Error ? err.message : 'No se pudo aceptar'
+ )
+ )
+ .finally(() => setChallengeActionId(null))
+ }}
+ onReject={(challengeId) => {
+ setChallengeActionId(challengeId)
+ void rejectChallenge
+ .mutateAsync({ challengeId, leagueId: id })
+ .catch((err) =>
+ showAlert(
+ 'Error',
+ err instanceof Error ? err.message : 'No se pudo rechazar'
+ )
+ )
+ .finally(() => setChallengeActionId(null))
+ }}
+ />
+
+ ) : null}
+
+ ) : null}
+
+ {tab === 'standings' ? (
+
+ {isOpenEloFormat(league.format) ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
+ {tab === 'matches' ? (
+ router.push(`/(tabs)/matches/${matchId}` as Href)}
+ />
+ ) : null}
+
+ {canStart ? (
+ void handleStart()}
+ loading={startLeague.isPending}
+ style={{ marginTop: 16 }}
+ />
+ ) : null}
+
+ {isCreator && inRegistration ? (
+ router.push(`/(tabs)/leagues/edit/${id}` as Href)}
+ style={{ marginTop: 8 }}
+ />
+ ) : null}
+
+ {isCreator && (inRegistration || inProgress) ? (
+ setCancelVisible(true)}
+ style={{ marginTop: 8 }}
+ />
+ ) : null}
+ >
+ ) : null}
+
+
+ setPasswordModalDismissed(true)}
+ isLoading={grantAccess.isPending}
+ accessOnly
+ title="Liga privada"
+ hint="Introduce la contraseña para ver la liga"
+ onSubmit={async (password) => {
+ await grantAccess.mutateAsync({ leagueId: id, password })
+ }}
+ />
+
+ setPairModalOpen(false)}
+ onSubmit={handleAddPair}
+ loading={addPair.isPending}
+ defaultSelfSlot={userId && userIsInLeaguePair(league.pairs, userId) ? null : 'a'}
+ selfJoinDisabled={Boolean(userId && userIsInLeaguePair(league.pairs, userId))}
+ />
+
+ setEditingPair(null)}
+ saveLoading={updatePair.isPending}
+ deleteLoading={removePair.isPending}
+ canDelete={isCreator && inRegistration && !league.fixtures_generated_at}
+ onSubmit={async (values) => {
+ if (!editingPair) return
+ try {
+ await updatePair.mutateAsync({
+ pairId: editingPair.id,
+ leagueId: id,
+ name: values.name.trim() || undefined,
+ playerAText: editingPair.player_a_user_id ? null : values.playerAText.trim() || null,
+ playerBText: editingPair.player_b_user_id ? null : values.playerBText.trim() || null,
+ })
+ setEditingPair(null)
+ } catch (err) {
+ showAlert('Error', err instanceof Error ? err.message : 'No se pudo guardar')
+ throw err
+ }
+ }}
+ onDelete={async () => {
+ if (!editingPair) return
+ try {
+ await removePair.mutateAsync({ pairId: editingPair.id, leagueId: id })
+ setEditingPair(null)
+ } catch (err) {
+ showAlert('Error', err instanceof Error ? err.message : 'No se pudo eliminar')
+ }
+ }}
+ />
+
+ setChallengeModalOpen(false)}
+ opponents={challengeOpponents}
+ loading={createChallenge.isPending}
+ onChallenge={async (pairId) => {
+ await createChallenge.mutateAsync({ leagueId: id, challengedPairId: pairId })
+ }}
+ />
+
+ setCancelVisible(false)}
+ hasFixturesOrInProgress={Boolean(league.fixtures_generated_at) || inProgress}
+ loading={cancelLeague.isPending}
+ onConfirm={async () => {
+ await cancelLeague.mutateAsync(id)
+ router.replace('/(tabs)/matches' as Href)
+ }}
+ />
+
+ )
+}
+
+const s = StyleSheet.create({
+ root: { flex: 1, backgroundColor: Colors.background },
+ topBar: { flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 16 },
+ close: { fontSize: 22, color: Colors.textSecondary, padding: 8 },
+ container: { padding: 16, paddingBottom: 48 },
+ centered: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: Colors.background,
+ },
+ error: { color: Colors.danger, marginBottom: 12 },
+ title: { fontSize: 24, fontFamily: Fonts.bold, color: Colors.textPrimary },
+ badgeRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 8 },
+ badge: { borderWidth: 1, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 2 },
+ badgeText: { fontSize: 12, fontFamily: Fonts.semiBold },
+ format: { fontSize: 13, color: Colors.textSecondary },
+ meta: { fontSize: 13, color: Colors.textSecondary, marginTop: 4 },
+ desc: { fontSize: 14, color: Colors.textPrimary, marginTop: 10, lineHeight: 20 },
+ shareBtn: { marginTop: 12 },
+ tabs: { flexDirection: 'row', marginTop: 16, marginBottom: 12, gap: 6 },
+ tab: {
+ flex: 1,
+ paddingVertical: 10,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ alignItems: 'center',
+ },
+ tabActive: { borderColor: Colors.primary, backgroundColor: Colors.surface },
+ tabText: { fontSize: 13, fontFamily: Fonts.medium, color: Colors.textSecondary },
+ tabTextActive: { color: Colors.primary },
+ sectionTitle: {
+ fontSize: 15,
+ fontFamily: Fonts.semiBold,
+ color: Colors.textPrimary,
+ marginBottom: 8,
+ marginTop: 8,
+ },
+ roundGroup: { marginTop: 12 },
+ roundLabel: {
+ fontSize: 13,
+ fontFamily: Fonts.bold,
+ color: Colors.primary,
+ marginBottom: 6,
+ textTransform: 'uppercase',
+ letterSpacing: 0.4,
+ },
+ empty: { color: Colors.textSecondary, fontStyle: 'italic', paddingVertical: 12 },
+ matchCard: {
+ backgroundColor: Colors.surface,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ padding: 12,
+ marginBottom: 8,
+ },
+ matchCardHeader: { flexDirection: 'row', justifyContent: 'space-between', gap: 8 },
+ matchCardTitle: { flex: 1, fontSize: 14, fontFamily: Fonts.semiBold, color: Colors.textPrimary },
+ matchStatusBadge: { borderWidth: 1, borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2 },
+ matchStatusText: { fontSize: 11, fontFamily: Fonts.semiBold },
+ matchCardMeta: { fontSize: 12, color: Colors.textSecondary, marginTop: 6 },
+})
diff --git a/src/app/(tabs)/leagues/create.tsx b/src/app/(tabs)/leagues/create.tsx
new file mode 100644
index 0000000..6853b57
--- /dev/null
+++ b/src/app/(tabs)/leagues/create.tsx
@@ -0,0 +1,573 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useRouter, type Href } from 'expo-router'
+import { useCallback, useState } from 'react'
+import { Controller, useForm } from 'react-hook-form'
+import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { z } from 'zod'
+
+import {
+ AddLeaguePairModal,
+ type AddLeaguePairFormValues,
+} from '@/components/leagues/AddLeaguePairModal'
+import {
+ EditLeaguePairModal,
+ type EditLeaguePairFormValues,
+} from '@/components/leagues/EditLeaguePairModal'
+import { LeaguePairCard } from '@/components/leagues/LeaguePairCard'
+import { AddPairButton } from '@/components/ui/AddPairButton'
+import { Button } from '@/components/ui/Button'
+import { KeyboardAwareScrollView } from '@/components/ui/KeyboardAwareScrollView'
+import { dateToLocalIsoString } from '@/components/ui/dateTimePickerUtils'
+import { DateTimePicker } from '@/components/ui/DateTimePicker'
+import { Input } from '@/components/ui/Input'
+import { MunicipalityPicker } from '@/components/ui/MunicipalityPicker'
+import {
+ LEAGUE_FORMAT,
+ LEAGUE_FORMAT_LABELS,
+ MATCH_VISIBILITY,
+ type LeagueFormat,
+} from '@/constants'
+import { useAuthStore } from '@/hooks/useAuth'
+import {
+ useAddLeaguePair,
+ useCreateLeague,
+ useRemoveLeaguePair,
+ useUpdateLeaguePair,
+} from '@/hooks/useLeagues'
+import { isLeaguePairComplete, type LeaguePairRow } from '@/services/leagues.service'
+import { acknowledgeAlert, confirmAlert, showAlert } from '@/utils/alert'
+import { showFormFieldsMissingAlert } from '@/utils/formValidation'
+import {
+ AUTO_START_LEAGUE_ALERT,
+ DEFAULT_LEAGUE_CITY,
+ DEFAULT_LEAGUE_TITLE,
+ leaguePlacePayload,
+} from '@/utils/leagueForm'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+import { screenTopPadding } from '@/theme/layout'
+
+const schema = z
+ .object({
+ title: z.string().trim().max(80, 'El título es demasiado largo').optional().or(z.literal('')),
+ description: z.string().trim().max(300).optional().or(z.literal('')),
+ start_at: z.string().min(1, 'Selecciona fecha y hora de inicio'),
+ end_at: z.string().optional().or(z.literal('')),
+ city: z
+ .string()
+ .trim()
+ .max(120, 'Nombre de ciudad demasiado largo')
+ .optional()
+ .or(z.literal('')),
+ place_text: z
+ .string()
+ .trim()
+ .max(150, 'Texto de lugar demasiado largo')
+ .optional()
+ .or(z.literal('')),
+ duration_target_games: z.number().int().min(1).max(6),
+ format: z.enum([
+ LEAGUE_FORMAT.SINGLE_ROUND,
+ LEAGUE_FORMAT.DOUBLE_ROUND,
+ LEAGUE_FORMAT.OPEN_ELO,
+ ]),
+ visibility: z.enum([MATCH_VISIBILITY.PUBLIC, MATCH_VISIBILITY.LINK, MATCH_VISIBILITY.PRIVATE]),
+ password: z.string().max(100, 'Contraseña demasiado larga').optional().or(z.literal('')),
+ notes: z.string().trim().max(300).optional().or(z.literal('')),
+ })
+ .superRefine((data, ctx) => {
+ if (data.visibility === MATCH_VISIBILITY.PRIVATE && !data.password?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Introduce una contraseña para la liga privada',
+ path: ['password'],
+ })
+ }
+ if (data.format === LEAGUE_FORMAT.OPEN_ELO) {
+ if (!data.end_at?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'La liga abierta requiere fecha de fin',
+ path: ['end_at'],
+ })
+ } else if (new Date(data.end_at).getTime() <= new Date(data.start_at).getTime()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'La fecha de fin debe ser posterior al inicio',
+ path: ['end_at'],
+ })
+ }
+ }
+ })
+
+type FormValues = z.infer
+
+function defaultStartAt() {
+ const d = new Date()
+ d.setHours(d.getHours() + 2, 0, 0, 0)
+ return dateToLocalIsoString(d)
+}
+
+function defaultEndAt() {
+ const d = new Date()
+ d.setDate(d.getDate() + 30)
+ d.setHours(23, 0, 0, 0)
+ return dateToLocalIsoString(d)
+}
+
+function createDefaultFormValues(): FormValues {
+ return {
+ title: '',
+ description: '',
+ start_at: defaultStartAt(),
+ end_at: defaultEndAt(),
+ city: '',
+ place_text: '',
+ duration_target_games: 3,
+ format: LEAGUE_FORMAT.SINGLE_ROUND,
+ visibility: MATCH_VISIBILITY.PUBLIC,
+ password: '',
+ notes: '',
+ }
+}
+
+function Chip({
+ label,
+ sublabel,
+ selected,
+ onPress,
+}: {
+ label: string
+ sublabel?: string
+ selected: boolean
+ onPress: () => void
+}) {
+ return (
+
+ {label}
+ {sublabel ? (
+ {sublabel}
+ ) : null}
+
+ )
+}
+
+export default function CreateLeagueScreen() {
+ const router = useRouter()
+ const insets = useSafeAreaInsets()
+ const userId = useAuthStore((s) => s.session?.user.id)
+ const createLeague = useCreateLeague()
+ const addPair = useAddLeaguePair()
+ const updatePair = useUpdateLeaguePair()
+ const removePair = useRemoveLeaguePair()
+
+ const [step, setStep] = useState<1 | 2>(1)
+ const [leagueId, setLeagueId] = useState(null)
+ const [pairs, setPairs] = useState([])
+ const [pairModalOpen, setPairModalOpen] = useState(false)
+ const [editingPair, setEditingPair] = useState(null)
+
+ const userAlreadyInPair = Boolean(
+ userId && pairs.some((p) => p.player_a_user_id === userId || p.player_b_user_id === userId)
+ )
+
+ const {
+ control,
+ handleSubmit,
+ watch,
+ setValue,
+ reset,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: createDefaultFormValues(),
+ })
+
+ const durationValue = watch('duration_target_games')
+ const visibilityValue = watch('visibility')
+ const formatValue = watch('format')
+
+ const onStep1 = async (values: FormValues) => {
+ try {
+ const row = await createLeague.mutateAsync({
+ data: {
+ title: values.title?.trim() || DEFAULT_LEAGUE_TITLE,
+ description: values.description || null,
+ notes: values.notes || null,
+ start_at: values.start_at,
+ end_at: values.format === LEAGUE_FORMAT.OPEN_ELO ? values.end_at?.trim() || null : null,
+ city: values.city?.trim() || DEFAULT_LEAGUE_CITY,
+ ...leaguePlacePayload(values.place_text),
+ duration_target_games: values.duration_target_games,
+ visibility: values.visibility,
+ location_privacy: 'participants_only',
+ format: values.format,
+ },
+ password: values.visibility === MATCH_VISIBILITY.PRIVATE ? values.password : undefined,
+ })
+ setLeagueId(row.id)
+ setStep(2)
+ } catch (err) {
+ Alert.alert('Error', err instanceof Error ? err.message : 'No se pudo crear la liga')
+ }
+ }
+
+ const handleAddPair = async (values: AddLeaguePairFormValues) => {
+ if (!leagueId || !userId) return
+
+ const useSelfA = values.playerAIsSelf && !userAlreadyInPair
+ const useSelfB = values.playerBIsSelf && !userAlreadyInPair && !useSelfA
+ const playerAUserId = useSelfA ? userId : null
+ const playerAText = useSelfA ? null : values.playerAText.trim() || null
+ const playerBUserId = useSelfB ? userId : null
+ const playerBText = useSelfB ? null : values.playerBText.trim() || null
+
+ if (!playerAUserId && !playerAText && !playerBUserId && !playerBText) {
+ Alert.alert('Error', 'Indica al menos un jugador para la pareja')
+ throw new Error('pair_players_required')
+ }
+
+ try {
+ const row = await addPair.mutateAsync({
+ leagueId,
+ name: values.name.trim() || undefined,
+ playerAUserId,
+ playerAText,
+ playerBUserId,
+ playerBText,
+ })
+ setPairs((prev) => [...prev, row])
+ setPairModalOpen(false)
+ } catch (err) {
+ Alert.alert('Error', err instanceof Error ? err.message : 'No se pudo añadir la pareja')
+ throw err
+ }
+ }
+
+ const handleEditPair = async (values: EditLeaguePairFormValues) => {
+ if (!editingPair || !leagueId) return
+ try {
+ const updated = await updatePair.mutateAsync({
+ pairId: editingPair.id,
+ leagueId,
+ name: values.name.trim() || undefined,
+ playerAText: editingPair.player_a_user_id ? null : values.playerAText.trim() || null,
+ playerBText: editingPair.player_b_user_id ? null : values.playerBText.trim() || null,
+ })
+ setPairs((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
+ setEditingPair(null)
+ } catch (err) {
+ Alert.alert('Error', err instanceof Error ? err.message : 'No se pudo guardar la pareja')
+ throw err
+ }
+ }
+
+ const runDeletePair = async (pairId: string) => {
+ if (!leagueId) return
+ try {
+ await removePair.mutateAsync({ pairId, leagueId })
+ setPairs((prev) => prev.filter((p) => p.id !== pairId))
+ setEditingPair(null)
+ } catch (err) {
+ showAlert('Error', err instanceof Error ? err.message : 'No se pudo eliminar la pareja')
+ }
+ }
+
+ const finish = async () => {
+ if (!leagueId) return
+ await acknowledgeAlert(AUTO_START_LEAGUE_ALERT.title, AUTO_START_LEAGUE_ALERT.message)
+ // Full reset after creation flow completion.
+ setStep(1)
+ setLeagueId(null)
+ setPairs([])
+ setPairModalOpen(false)
+ setEditingPair(null)
+ reset(createDefaultFormValues())
+ router.replace(`/(tabs)/leagues/${leagueId}` as Href)
+ }
+
+ const closeToMyMatches = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
+ router.replace('/(tabs)/matches' as Href)
+ }, [router])
+
+ const closeBar = (
+
+
+
+ ✕
+
+
+ )
+
+ if (step === 1) {
+ return (
+
+ {closeBar}
+ Organizar liga
+ Paso 1 de 2 — Datos de la liga
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+ Formato
+
+ {(Object.keys(LEAGUE_FORMAT_LABELS) as LeagueFormat[]).map((fmt) => (
+ {
+ setValue('format', fmt, { shouldValidate: true })
+ if (fmt === LEAGUE_FORMAT.OPEN_ELO) {
+ setValue('end_at', defaultEndAt())
+ }
+ }}
+ />
+ ))}
+
+
+ (
+
+ )}
+ />
+
+ {formatValue === LEAGUE_FORMAT.OPEN_ELO ? (
+ (
+
+ )}
+ />
+ ) : null}
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+ Juegos a ganar
+
+ {[1, 2, 3, 4, 5, 6].map((n) => (
+ setValue('duration_target_games', n)}
+ />
+ ))}
+
+
+ Visibilidad
+
+ setValue('visibility', MATCH_VISIBILITY.PUBLIC)}
+ />
+ setValue('visibility', MATCH_VISIBILITY.PRIVATE)}
+ />
+
+ {visibilityValue === MATCH_VISIBILITY.PRIVATE ? (
+ (
+
+ )}
+ />
+ ) : null}
+
+ (
+
+ )}
+ />
+
+
+
+ )
+ }
+
+ return (
+
+ {closeBar}
+ Parejas de la liga
+ Paso 2 de 2 — Añade parejas
+
+ Completas: {pairs.filter(isLeaguePairComplete).length} / {pairs.length}
+
+
+ {pairs.length === 0 ? (
+ Todavía no hay parejas en esta liga.
+ ) : (
+ pairs.map((pair) => (
+ setEditingPair(pair)}
+ />
+ ))
+ )}
+
+ 0} onPress={() => setPairModalOpen(true)} />
+ void finish()} />
+
+ setPairModalOpen(false)}
+ onSubmit={handleAddPair}
+ loading={addPair.isPending}
+ defaultSelfSlot={userAlreadyInPair ? null : 'a'}
+ selfJoinDisabled={userAlreadyInPair}
+ />
+ setEditingPair(null)}
+ onSubmit={handleEditPair}
+ canDelete
+ saveLoading={updatePair.isPending}
+ deleteLoading={removePair.isPending}
+ onDelete={async () => {
+ if (!editingPair) return
+ const ok = await confirmAlert('Eliminar pareja', '¿Seguro?', {
+ confirmText: 'Eliminar',
+ destructive: true,
+ })
+ if (ok) await runDeletePair(editingPair.id)
+ }}
+ />
+
+ )
+}
+
+const chip = StyleSheet.create({
+ base: {
+ borderWidth: 1,
+ borderColor: Colors.border,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ backgroundColor: Colors.surface,
+ },
+ selected: { borderColor: Colors.primary, backgroundColor: Colors.background },
+ label: { fontSize: 14, fontFamily: Fonts.medium, color: Colors.textPrimary },
+ labelSelected: { color: Colors.primary },
+ sublabel: { fontSize: 11, color: Colors.textSecondary },
+ sublabelSelected: { color: Colors.primary },
+})
+
+const s = StyleSheet.create({
+ scroll: { flex: 1, backgroundColor: Colors.background },
+ container: { padding: 16, paddingBottom: 48, gap: 12 },
+ closeBar: { flexDirection: 'row', alignItems: 'center' },
+ closeX: { fontSize: 22, color: Colors.textSecondary, padding: 4 },
+ heading: { fontSize: 22, fontFamily: Fonts.bold, color: Colors.textPrimary },
+ step: { fontSize: 14, color: Colors.textSecondary, marginBottom: 4 },
+ hint: { fontSize: 13, color: Colors.textSecondary },
+ empty: { fontSize: 14, color: Colors.textSecondary, fontStyle: 'italic' },
+ label: { fontSize: 14, fontFamily: Fonts.semiBold, color: Colors.textPrimary, marginTop: 4 },
+ chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
+})
diff --git a/src/app/(tabs)/leagues/edit/[id].tsx b/src/app/(tabs)/leagues/edit/[id].tsx
new file mode 100644
index 0000000..fd09e20
--- /dev/null
+++ b/src/app/(tabs)/leagues/edit/[id].tsx
@@ -0,0 +1,334 @@
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useLocalSearchParams, useRouter, type Href } from 'expo-router'
+import { useCallback } from 'react'
+import { Controller, useForm } from 'react-hook-form'
+import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { z } from 'zod'
+
+import { Button } from '@/components/ui/Button'
+import { KeyboardAwareScrollView } from '@/components/ui/KeyboardAwareScrollView'
+import { DateTimePicker } from '@/components/ui/DateTimePicker'
+import { Input } from '@/components/ui/Input'
+import { MunicipalityPicker } from '@/components/ui/MunicipalityPicker'
+import {
+ LEAGUE_FORMAT,
+ LEAGUE_FORMAT_LABELS,
+ LEAGUE_STATUS,
+ MATCH_VISIBILITY,
+ type LeagueFormat,
+} from '@/constants'
+import { useLeague, useUpdateLeague } from '@/hooks/useLeagues'
+import { DEFAULT_LEAGUE_CITY, DEFAULT_LEAGUE_TITLE, leaguePlacePayload } from '@/utils/leagueForm'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+import { screenTopPadding } from '@/theme/layout'
+
+const schema = z
+ .object({
+ title: z.string().trim().max(80).optional().or(z.literal('')),
+ description: z.string().trim().max(300).optional().or(z.literal('')),
+ start_at: z.string().min(1),
+ end_at: z.string().optional().or(z.literal('')),
+ city: z.string().trim().max(120).optional().or(z.literal('')),
+ place_text: z.string().trim().max(150).optional().or(z.literal('')),
+ duration_target_games: z.number().int().min(1).max(6),
+ format: z.enum([
+ LEAGUE_FORMAT.SINGLE_ROUND,
+ LEAGUE_FORMAT.DOUBLE_ROUND,
+ LEAGUE_FORMAT.OPEN_ELO,
+ ]),
+ visibility: z.enum([MATCH_VISIBILITY.PUBLIC, MATCH_VISIBILITY.LINK, MATCH_VISIBILITY.PRIVATE]),
+ password: z.string().max(100).optional().or(z.literal('')),
+ notes: z.string().trim().max(300).optional().or(z.literal('')),
+ })
+ .superRefine((data, ctx) => {
+ if (data.format === LEAGUE_FORMAT.OPEN_ELO) {
+ if (!data.end_at?.trim()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'La liga abierta requiere fecha de fin',
+ path: ['end_at'],
+ })
+ } else if (new Date(data.end_at).getTime() <= new Date(data.start_at).getTime()) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'La fecha de fin debe ser posterior al inicio',
+ path: ['end_at'],
+ })
+ }
+ }
+ })
+
+type FormValues = z.infer
+
+export default function EditLeagueScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>()
+ const router = useRouter()
+ const insets = useSafeAreaInsets()
+ const { data: league, isLoading } = useLeague(id)
+ const updateLeague = useUpdateLeague()
+
+ const {
+ control,
+ handleSubmit,
+ watch,
+ setValue,
+ formState: { errors },
+ } = useForm({
+ resolver: zodResolver(schema),
+ values: league
+ ? {
+ title: league.title,
+ description: league.description ?? '',
+ start_at: league.start_at,
+ end_at: league.end_at ?? '',
+ city: league.city,
+ place_text: league.place_defined ? (league.place_text ?? '') : '',
+ duration_target_games: league.duration_target_games,
+ format: league.format as LeagueFormat,
+ visibility: league.visibility as
+ | typeof MATCH_VISIBILITY.PUBLIC
+ | typeof MATCH_VISIBILITY.LINK
+ | typeof MATCH_VISIBILITY.PRIVATE,
+ password: '',
+ notes: league.notes ?? '',
+ }
+ : undefined,
+ })
+
+ const durationValue = watch('duration_target_games')
+ const visibilityValue = watch('visibility')
+ const formatValue = watch('format')
+
+ const goBack = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
+ if (id) {
+ router.replace(`/(tabs)/leagues/${id}` as Href)
+ return
+ }
+ router.replace('/(tabs)/matches' as Href)
+ }, [id, router])
+
+ if (isLoading || !league) {
+ return (
+
+ Cargando…
+
+ )
+ }
+
+ if (league.status !== LEAGUE_STATUS.REGISTRATION) {
+ return (
+
+ Solo se puede editar durante la inscripción.
+
+
+ )
+ }
+
+ const onSubmit = async (values: FormValues) => {
+ try {
+ await updateLeague.mutateAsync({
+ id,
+ data: {
+ title: values.title?.trim() || DEFAULT_LEAGUE_TITLE,
+ description: values.description || null,
+ notes: values.notes || null,
+ start_at: values.start_at,
+ end_at:
+ values.format === LEAGUE_FORMAT.OPEN_ELO
+ ? values.end_at || null
+ : values.end_at?.trim()
+ ? values.end_at
+ : null,
+ city: values.city?.trim() || DEFAULT_LEAGUE_CITY,
+ ...leaguePlacePayload(values.place_text),
+ duration_target_games: values.duration_target_games,
+ visibility: values.visibility,
+ format: values.format,
+ },
+ password:
+ values.visibility === MATCH_VISIBILITY.PRIVATE && values.password?.trim()
+ ? values.password
+ : undefined,
+ })
+ goBack()
+ } catch (err) {
+ Alert.alert('Error', err instanceof Error ? err.message : 'No se pudo guardar')
+ }
+ }
+
+ return (
+
+
+
+
+ ✕
+
+
+ Editar liga
+
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+ Formato
+
+ {(Object.keys(LEAGUE_FORMAT_LABELS) as LeagueFormat[]).map((fmt) => (
+ setValue('format', fmt, { shouldValidate: true })}>
+
+ {LEAGUE_FORMAT_LABELS[fmt]}
+
+
+ ))}
+
+
+ (
+
+ )}
+ />
+ {formatValue === LEAGUE_FORMAT.OPEN_ELO ? (
+ (
+
+ )}
+ />
+ ) : null}
+ (
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+ Juegos a ganar
+
+ {[1, 2, 3, 4, 5, 6].map((n) => (
+ setValue('duration_target_games', n)}>
+ {n}
+
+ ))}
+
+
+ Visibilidad
+
+ setValue('visibility', MATCH_VISIBILITY.PUBLIC)}>
+
+ Pública
+
+
+ setValue('visibility', MATCH_VISIBILITY.PRIVATE)}>
+
+ Privada
+
+
+
+ {visibilityValue === MATCH_VISIBILITY.PRIVATE ? (
+ (
+
+ )}
+ />
+ ) : null}
+
+
+
+ )
+}
+
+const s = StyleSheet.create({
+ scroll: { flex: 1, backgroundColor: Colors.background },
+ container: { padding: 16, paddingBottom: 48, gap: 12 },
+ centered: { flex: 1, backgroundColor: Colors.background, padding: 16 },
+ closeBar: { flexDirection: 'row' },
+ closeX: { fontSize: 22, color: Colors.textSecondary, padding: 4 },
+ heading: { fontSize: 22, fontFamily: Fonts.bold, color: Colors.textPrimary },
+ meta: { color: Colors.textSecondary },
+ label: { fontSize: 14, fontFamily: Fonts.semiBold, color: Colors.textPrimary },
+ chipRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
+ chip: {
+ borderWidth: 1,
+ borderColor: Colors.border,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ },
+ chipSelected: { borderColor: Colors.primary },
+ chipText: { color: Colors.textPrimary, fontFamily: Fonts.medium },
+ chipTextSelected: { color: Colors.primary },
+})
diff --git a/src/app/(tabs)/matches/[id].tsx b/src/app/(tabs)/matches/[id].tsx
index fd11468..bf5ec0f 100644
--- a/src/app/(tabs)/matches/[id].tsx
+++ b/src/app/(tabs)/matches/[id].tsx
@@ -46,6 +46,7 @@ import {
useStartMatch,
useUpdateMatchTeam,
} from '@/hooks/useMatches'
+import { useLeague, useRecordLeagueMatchAsReferee } from '@/hooks/useLeagues'
import { useTournament, useRecordTournamentMatchAsReferee } from '@/hooks/useTournaments'
import { useMatchResult, useSubmitConfirmation, useSubmitResult } from '@/hooks/useResults'
import {
@@ -505,11 +506,26 @@ export default function MatchDetailScreen() {
const insets = useSafeAreaInsets()
const userId = useAuthStore((s) => s.session?.user.id)
- const closeToMyMatches = useCallback(() => {
+ const closeMatchDetail = useCallback(() => {
clearPendingMatchResultFromScoreboard(id)
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
router.replace('/(tabs)/matches' as Href)
}, [router, id])
+ const openParentCompetition = useCallback(
+ (kind: 'league' | 'tournament', competitionId: string) => {
+ if (kind === 'league') {
+ router.push({ pathname: '/(tabs)/leagues/[id]', params: { id: competitionId } })
+ } else {
+ router.push({ pathname: '/(tabs)/tournaments/[id]', params: { id: competitionId } })
+ }
+ },
+ [router]
+ )
+
const { data: match, isLoading, isError, refetch: refetchMatch } = useMatch(id)
const {
data: resultBundle,
@@ -533,9 +549,12 @@ export default function MatchDetailScreen() {
const submitConfirmationMut = useSubmitConfirmation()
const recordResultDirectMut = useRecordMatchResultDirect()
const recordRefereeMut = useRecordTournamentMatchAsReferee()
+ const recordLeagueRefereeMut = useRecordLeagueMatchAsReferee()
const tournamentId = match?.tournament_id ?? null
const { data: tournamentMeta } = useTournament(tournamentId ?? '')
+ const leagueId = match?.league_id ?? null
+ const { data: leagueMeta } = useLeague(leagueId ?? '')
const [joinModalVisible, setJoinModalVisible] = useState(false)
const [submitResultVisible, setSubmitResultVisible] = useState(false)
@@ -661,7 +680,7 @@ export default function MatchDetailScreen() {
return (
No se pudo cargar la partida.
-
+
)
}
@@ -701,23 +720,31 @@ export default function MatchDetailScreen() {
)
const isPersonalMatch = !match.tournament_id && isCreator && otherRegistered.length === 0
+ // Las partidas de liga round-robin se quedan "planned" hasta que se juegan;
+ // el backend las auto-inicia al recibir el resultado.
+ const isPlannedLeagueMatch = isPlanned && Boolean(match.league_id)
+
const canSubmitResult = Boolean(
userId &&
myParticipation &&
!isPersonalMatch &&
match.status !== MATCH_STATUS.CANCELLED &&
- (match.status === MATCH_STATUS.IN_PROGRESS ||
- match.status === MATCH_STATUS.FINISHED_NO_RESULT) &&
+ (isInProgress || match.status === MATCH_STATUS.FINISHED_NO_RESULT || isPlannedLeagueMatch) &&
!resultBlocksNewSubmit
)
const canRecordDirect = Boolean(
- userId && isPersonalMatch && isInProgress && !resultBlocksNewSubmit && !match.tournament_id
+ userId &&
+ isPersonalMatch &&
+ isInProgress &&
+ !resultBlocksNewSubmit &&
+ !match.tournament_id &&
+ !match.league_id
)
// También permitimos llevar la cuenta en partidos de torneos (no durante validación de resultado).
const canOpenScoreboard = Boolean(
- userId && isParticipant && isInProgress && !resultBlocksNewSubmit
+ userId && isParticipant && (isInProgress || isPlannedLeagueMatch) && !resultBlocksNewSubmit
)
const allTextPlayers =
@@ -729,7 +756,7 @@ export default function MatchDetailScreen() {
match.team_b_player_2?.trim()
)
- const canRecordAsReferee = Boolean(
+ const canRecordAsTournamentReferee = Boolean(
userId &&
match.tournament_id &&
tournamentMeta?.creator_id === userId &&
@@ -739,6 +766,18 @@ export default function MatchDetailScreen() {
!match.tournament_is_bye
)
+ const canRecordAsLeagueReferee = Boolean(
+ userId &&
+ match.league_id &&
+ leagueMeta?.creator_id === userId &&
+ allTextPlayers &&
+ (isInProgress || isPlannedLeagueMatch) &&
+ !resultBlocksNewSubmit &&
+ !isPersonalMatch
+ )
+
+ const canRecordAsReferee = canRecordAsTournamentReferee || canRecordAsLeagueReferee
+
const canValidateResult = Boolean(
userId &&
myParticipation &&
@@ -907,14 +946,25 @@ export default function MatchDetailScreen() {
}
const handleRecordAsReferee = async (payload: { teamAGames: number; teamBGames: number }) => {
- if (!userId || !match.tournament_id) return
+ if (!userId || isPersonalMatch || match.status === MATCH_STATUS.CANCELLED) return
try {
- await recordRefereeMut.mutateAsync({
- matchId: id,
- tournamentId: match.tournament_id,
- teamAGames: payload.teamAGames,
- teamBGames: payload.teamBGames,
- })
+ if (match.tournament_id) {
+ await recordRefereeMut.mutateAsync({
+ matchId: id,
+ tournamentId: match.tournament_id,
+ teamAGames: payload.teamAGames,
+ teamBGames: payload.teamBGames,
+ })
+ } else if (match.league_id) {
+ await recordLeagueRefereeMut.mutateAsync({
+ matchId: id,
+ leagueId: match.league_id,
+ teamAGames: payload.teamAGames,
+ teamBGames: payload.teamBGames,
+ })
+ } else {
+ return
+ }
setRecordRefereeVisible(false)
} catch (err) {
Alert.alert('Error', err instanceof Error ? err.message : 'No se pudo registrar el resultado')
@@ -982,7 +1032,7 @@ export default function MatchDetailScreen() {
@@ -999,13 +1049,22 @@ export default function MatchDetailScreen() {
{match.tournament_id && tournamentMeta ? (
router.push(`/(tabs)/tournaments/${match.tournament_id}` as Href)}
+ onPress={() => openParentCompetition('tournament', match.tournament_id!)}
style={({ pressed }) => [s.tournamentBadge, pressed && s.tournamentBadgePressed]}
accessibilityRole="button"
accessibilityLabel={`Ir al torneo: ${tournamentMeta.title}`}>
🏆 Ir al torneo
) : null}
+ {match.league_id && leagueMeta ? (
+ openParentCompetition('league', match.league_id!)}
+ style={({ pressed }) => [s.tournamentBadge, pressed && s.tournamentBadgePressed]}
+ accessibilityRole="button"
+ accessibilityLabel={`Ir a la liga: ${leagueMeta.title}`}>
+ 🏅 Ir a la liga
+
+ ) : null}
@@ -1374,9 +1433,13 @@ export default function MatchDetailScreen() {
teamAName={teamAName}
teamBName={teamBName}
durationTargetGames={match.duration_target_games}
- hint="Como organizador del torneo, el marcador queda confirmado al guardar."
+ hint={
+ match.league_id
+ ? 'Como organizador de la liga, el marcador queda confirmado al guardar.'
+ : 'Como organizador del torneo, el marcador queda confirmado al guardar.'
+ }
submitLabel="Confirmar marcador"
- loading={recordRefereeMut.isPending}
+ loading={match.league_id ? recordLeagueRefereeMut.isPending : recordRefereeMut.isPending}
onSubmit={handleRecordAsReferee}
/>
diff --git a/src/app/(tabs)/matches/create.tsx b/src/app/(tabs)/matches/create.tsx
index 274ab81..08cca9a 100644
--- a/src/app/(tabs)/matches/create.tsx
+++ b/src/app/(tabs)/matches/create.tsx
@@ -155,6 +155,14 @@ export default function CreateMatchScreen() {
const recordMatchResult = useRecordMatchResultDirect()
const [pastResult, setPastResult] = useState(null)
+ const closeToPrevious = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
+ router.replace('/(tabs)/matches' as Href)
+ }, [router])
+
const {
control,
handleSubmit,
@@ -318,7 +326,7 @@ export default function CreateMatchScreen() {
router.replace('/(tabs)/matches' as Href)}
+ onPress={closeToPrevious}
hitSlop={12}
accessibilityRole="button"
accessibilityLabel="Cerrar">
diff --git a/src/app/(tabs)/matches/edit/[id].tsx b/src/app/(tabs)/matches/edit/[id].tsx
index 46e0908..4c5d9ff 100644
--- a/src/app/(tabs)/matches/edit/[id].tsx
+++ b/src/app/(tabs)/matches/edit/[id].tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo } from 'react'
+import { useCallback, useEffect, useMemo } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useLocalSearchParams, useRouter, type Href } from 'expo-router'
import { Controller, useForm } from 'react-hook-form'
@@ -242,13 +242,17 @@ export default function EditMatchScreen() {
}
}
- const closeToMatch = () => {
- if (!id) {
+ const closeToMatch = useCallback(() => {
+ if (router.canGoBack()) {
router.back()
return
}
- router.replace(`/(tabs)/matches/${id}` as Href)
- }
+ if (id) {
+ router.replace(`/(tabs)/matches/${id}` as Href)
+ return
+ }
+ router.replace('/(tabs)/matches' as Href)
+ }, [router, id])
if (isLoading) {
return (
diff --git a/src/app/(tabs)/matches/index.tsx b/src/app/(tabs)/matches/index.tsx
index a77d369..ed67bc3 100644
--- a/src/app/(tabs)/matches/index.tsx
+++ b/src/app/(tabs)/matches/index.tsx
@@ -20,6 +20,7 @@ import { useAuthStore } from '@/hooks/useAuth'
import { useMyMatchesDashboard } from '@/hooks/useMatches'
import type { MyMatchesDashboard } from '@/services/matches.service'
import type { UserTournamentSummary } from '@/services/tournaments.service'
+import { leagueFormatDisplay, leagueStatusDisplay } from '@/utils/leagueDisplay'
import { Colors } from '@/theme/colors'
import { Fonts } from '@/theme/typography'
import { screenTopPadding } from '@/theme/layout'
@@ -114,12 +115,16 @@ function buildMatchesListItems(data: MyMatchesDashboard): MatchesListItem[] {
const inProgressDeduped = data.inProgress.filter((m) => !awaitingIds.has(m.id))
const tournamentsUpcoming = data.tournamentsUpcoming ?? []
const tournamentsInProgress = data.tournamentsInProgress ?? []
+ const leaguesUpcoming = data.leaguesUpcoming ?? []
+ const leaguesInProgress = data.leaguesInProgress ?? []
const hasAny =
data.upcoming.length > 0 ||
inProgressDeduped.length > 0 ||
data.awaitingResultValidation.length > 0 ||
tournamentsUpcoming.length > 0 ||
- tournamentsInProgress.length > 0
+ tournamentsInProgress.length > 0 ||
+ leaguesUpcoming.length > 0 ||
+ leaguesInProgress.length > 0
const items: MatchesListItem[] = []
if (!hasAny) {
@@ -175,6 +180,20 @@ function buildMatchesListItems(data: MyMatchesDashboard): MatchesListItem[] {
hint: t.isOrganizer ? 'Organizas este torneo' : undefined,
hintTone: t.isOrganizer ? ('info' as const) : undefined,
})),
+ ...leaguesInProgress.map((l) => ({
+ key: `league-${l.id}`,
+ kind: 'row' as const,
+ href: `/(tabs)/leagues/${l.id}`,
+ accessibilityKind: 'Liga',
+ kindLabel: `Liga · ${leagueFormatDisplay(l.format)}`,
+ title: l.title,
+ location: matchLocation(l),
+ startAt: l.start_at,
+ tone: 'active' as const,
+ statusLabel: leagueStatusDisplay(l).text,
+ hint: l.isOrganizer ? 'Organizas esta liga' : undefined,
+ hintTone: l.isOrganizer ? ('info' as const) : undefined,
+ })),
].sort((a, b) => {
if (a.kind !== 'row' || b.kind !== 'row') return 0
return new Date(a.startAt).getTime() - new Date(b.startAt).getTime()
@@ -207,6 +226,20 @@ function buildMatchesListItems(data: MyMatchesDashboard): MatchesListItem[] {
hint: t.isOrganizer ? 'Organizas este torneo' : undefined,
hintTone: t.isOrganizer ? ('info' as const) : undefined,
})),
+ ...leaguesUpcoming.map((l) => ({
+ key: `league-${l.id}`,
+ kind: 'row' as const,
+ href: `/(tabs)/leagues/${l.id}`,
+ accessibilityKind: 'Liga',
+ kindLabel: `Liga · ${leagueFormatDisplay(l.format)}`,
+ title: l.title,
+ location: matchLocation(l),
+ startAt: l.start_at,
+ tone: 'upcoming' as const,
+ statusLabel: leagueStatusDisplay(l).text,
+ hint: l.isOrganizer ? 'Organizas esta liga' : undefined,
+ hintTone: l.isOrganizer ? ('info' as const) : undefined,
+ })),
].sort((a, b) => {
if (a.kind !== 'row' || b.kind !== 'row') return 0
return new Date(a.startAt).getTime() - new Date(b.startAt).getTime()
diff --git a/src/app/(tabs)/matches/scoreboard/[id].tsx b/src/app/(tabs)/matches/scoreboard/[id].tsx
index b1186e6..2c80f8b 100644
--- a/src/app/(tabs)/matches/scoreboard/[id].tsx
+++ b/src/app/(tabs)/matches/scoreboard/[id].tsx
@@ -44,11 +44,15 @@ export default function ScoreboardScreen() {
const teamBName = match ? resolveTeamName(match, TEAM.B, match.participants) : ''
const closeToMatch = useCallback(() => {
- if (!id) {
+ if (router.canGoBack()) {
router.back()
return
}
- router.replace(`/(tabs)/matches/${id}` as Href)
+ if (id) {
+ router.replace(`/(tabs)/matches/${id}` as Href)
+ return
+ }
+ router.replace('/(tabs)/matches' as Href)
}, [id, router])
const handleGameOverConfirm = useCallback(() => {
diff --git a/src/app/(tabs)/profile/[userId].tsx b/src/app/(tabs)/profile/[userId].tsx
index 336e355..ecdce70 100644
--- a/src/app/(tabs)/profile/[userId].tsx
+++ b/src/app/(tabs)/profile/[userId].tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import {
ActivityIndicator,
Alert,
@@ -24,17 +24,9 @@ import { Fonts } from '@/theme/typography'
import { screenTopPadding } from '@/theme/layout'
import { openCreateContactForm } from '@/utils/contacts'
import { formatPhone } from '@/utils/formatters'
+import { buildMatchDetailHref } from '@/utils/navigation'
-function InfoRow({ label, value }: { label: string; value: string }) {
- return (
-
- {label}
- {value}
-
- )
-}
-
-function PhoneContactRow({ displayName, phoneE164 }: { displayName: string; phoneE164: string }) {
+function PhoneUnderName({ displayName, phoneE164 }: { displayName: string; phoneE164: string }) {
const [busy, setBusy] = useState(false)
const handlePress = async () => {
@@ -58,12 +50,9 @@ function PhoneContactRow({ displayName, phoneE164 }: { displayName: string; phon
disabled={busy}
accessibilityRole="button"
accessibilityLabel={`Crear contacto con ${displayName}`}
- style={({ pressed }) => [styles.infoRow, pressed && styles.phoneRowPressed]}>
- Teléfono
-
- {formatPhone(phoneE164)}
-
-
+ style={({ pressed }) => [styles.phoneUnderNameRow, pressed && styles.phoneRowPressed]}>
+ {formatPhone(phoneE164)}
+
)
}
@@ -83,11 +72,19 @@ export default function UserProfileScreen() {
}
}, [userId, sessionUserId, router])
+ const goBack = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
+ router.replace('/(tabs)/matches' as Href)
+ }, [router])
+
if (!userId) {
return (
Perfil no válido.
- router.back()} style={styles.backBtn} />
+
)
}
@@ -112,14 +109,14 @@ export default function UserProfileScreen() {
return (
router.back()}
+ onPress={goBack}
accessibilityRole="button"
accessibilityLabel="Cerrar"
style={styles.closeWrap}>
✕
No se pudo cargar el perfil o no tienes acceso.
- router.back()} style={styles.backBtn} />
+
)
}
@@ -130,7 +127,7 @@ export default function UserProfileScreen() {
router.back()}
+ onPress={goBack}
accessibilityRole="button"
accessibilityLabel="Cerrar">
✕
@@ -144,6 +141,9 @@ export default function UserProfileScreen() {
{profile.display_name}
+ {phone ? (
+
+ ) : null}
{profile.city ? {profile.city} : null}
@@ -151,24 +151,21 @@ export default function UserProfileScreen() {
router.push(`/(tabs)/profile/stats/${userId}` as Href)}
+ onPressRanking={() => router.push('/(tabs)/leaderboard' as Href)}
/>
) : null}
-
- {phone ? (
-
- ) : (
-
- )}
-
-
Historial
router.push(`/(tabs)/matches/${matchId}` as Href)}
+ onMatchPress={(matchId) =>
+ router.push(
+ buildMatchDetailHref(matchId, { from: 'profile', profileUserId: userId })
+ )
+ }
/>
@@ -215,6 +212,16 @@ const styles = StyleSheet.create({
fontSize: 15,
color: Colors.textSecondary,
},
+ phoneUnderNameRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ },
+ phoneUnderNameText: {
+ fontSize: 14,
+ fontFamily: Fonts.medium,
+ color: Colors.textSecondary,
+ },
card: {
backgroundColor: Colors.surface,
borderRadius: 12,
@@ -230,37 +237,5 @@ const styles = StyleSheet.create({
marginBottom: 4,
marginTop: 6,
},
- infoRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingVertical: 12,
- },
phoneRowPressed: { opacity: 0.7 },
- infoLabel: {
- fontSize: 15,
- color: Colors.textPrimary,
- flex: 1,
- paddingRight: 12,
- },
- infoValue: {
- fontSize: 15,
- color: Colors.textSecondary,
- flexShrink: 1,
- textAlign: 'right',
- marginLeft: 8,
- },
- phoneValue: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 8,
- flexShrink: 1,
- marginLeft: 8,
- },
- phoneText: {
- fontSize: 15,
- color: Colors.primary,
- fontFamily: Fonts.medium,
- textAlign: 'right',
- },
})
diff --git a/src/app/(tabs)/profile/edit.tsx b/src/app/(tabs)/profile/edit.tsx
index 272eba8..045895e 100644
--- a/src/app/(tabs)/profile/edit.tsx
+++ b/src/app/(tabs)/profile/edit.tsx
@@ -1,7 +1,7 @@
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import * as ImagePicker from 'expo-image-picker'
-import { useRouter } from 'expo-router'
+import { useRouter, type Href } from 'expo-router'
import { Controller, useForm } from 'react-hook-form'
import { ActivityIndicator, Alert, Image, Pressable, StyleSheet, Text, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -86,6 +86,14 @@ export default function EditProfileScreen() {
}
}
+ const goBack = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
+ router.replace('/(tabs)/profile' as Href)
+ }, [router])
+
const onSubmit = async (values: EditProfileValues) => {
try {
// Upload avatar first if user picked a new one
@@ -99,7 +107,7 @@ export default function EditProfileScreen() {
city: values.city || null,
})
- router.back()
+ goBack()
} catch (e) {
const message = e instanceof Error ? e.message : 'Error al guardar el perfil'
Alert.alert('Error', message)
@@ -221,7 +229,7 @@ export default function EditProfileScreen() {
title="Cancelar"
variant="outline"
disabled={isSaving}
- onPress={() => router.back()}
+ onPress={goBack}
/>
)
diff --git a/src/app/(tabs)/profile/index.tsx b/src/app/(tabs)/profile/index.tsx
index e3489ca..ae57da7 100644
--- a/src/app/(tabs)/profile/index.tsx
+++ b/src/app/(tabs)/profile/index.tsx
@@ -9,6 +9,7 @@ import {
Text,
View,
} from 'react-native'
+import { Ionicons } from '@expo/vector-icons'
import { useRouter, type Href } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { requestAppStoreRating } from '@/lib/storeReview'
@@ -17,6 +18,8 @@ import { FeedbackModal } from '@/components/FeedbackModal'
import { AvatarCircle } from '@/components/profile/AvatarCircle'
import { MatchHistoryList } from '@/components/profile/MatchHistoryList'
import { ProfileStatsCard } from '@/components/stats/ProfileStatsCard'
+import { BadgeUnlockPopup } from '@/components/stats/BadgeUnlockPopup'
+import { useBadgeUnlocks } from '@/hooks/useBadgeUnlocks'
import { SignOutModal } from '@/components/SignOutModal'
import { Button } from '@/components/ui/Button'
import { isRatingPromptSupported } from '@/lib/appRating'
@@ -28,6 +31,8 @@ import { Colors } from '@/theme/colors'
import { useResponsiveLayout } from '@/theme/responsive'
import { Fonts } from '@/theme/typography'
import { screenTopPadding } from '@/theme/layout'
+import { formatPhone } from '@/utils/formatters'
+import { buildMatchDetailHref } from '@/utils/navigation'
import {
buildNotifUpdates,
buildReminderTimingUpdates,
@@ -57,6 +62,7 @@ export default function ProfileScreen() {
const { data: profile, isPending: profilePending, isError } = useProfile(sessionUserId)
const { data: userMatches, isPending: matchesPending } = useUserMatches(sessionUserId)
const updateProfile = useUpdateProfile()
+ const { unlockedBadge, dismiss } = useBadgeUnlocks()
const [signingOut, setSigningOut] = useState(false)
const [savingField, setSavingField] = useState(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
@@ -156,13 +162,25 @@ export default function ProfileScreen() {
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}>
-
+
+
+ router.push('/(tabs)/profile/edit' as Href)}
+ accessibilityRole="button"
+ accessibilityLabel="Editar perfil"
+ style={({ pressed }) => [styles.avatarPencil, pressed && styles.avatarPencilPressed]}>
+
+
+
{profile.display_name}
+ {profile.phone_e164 ? (
+ {formatPhone(profile.phone_e164)}
+ ) : null}
{profile.city ? {profile.city} : null}
@@ -170,11 +188,20 @@ export default function ProfileScreen() {
router.push(`/(tabs)/profile/stats/${sessionUserId}` as Href)}
+ onPressRanking={() => router.push('/(tabs)/leaderboard' as Href)}
/>
) : null}
-
+ Historial
+
+ router.push(buildMatchDetailHref(matchId, { from: 'profile' }))
+ }
+ />
@@ -252,16 +279,6 @@ export default function ProfileScreen() {
-
- Historial
- router.push(`/(tabs)/matches/${matchId}`)}
- />
-
-
{isRatingPromptSupported() ? (
Ayuda
@@ -335,16 +352,9 @@ export default function ProfileScreen() {
loading={deletingAccount}
onConfirm={onDeleteAccount}
/>
-
- )
-}
-function InfoRow({ label, value }: { label: string; value: string }) {
- return (
-
- {label}
- {value}
-
+
+
)
}
@@ -457,6 +467,25 @@ const styles = StyleSheet.create({
gap: 8,
paddingBottom: 8,
},
+ avatarWrap: {
+ position: 'relative',
+ },
+ avatarPencil: {
+ position: 'absolute',
+ right: 2,
+ bottom: 2,
+ width: 28,
+ height: 28,
+ borderRadius: 14,
+ backgroundColor: Colors.primary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 2,
+ borderColor: Colors.white,
+ },
+ avatarPencilPressed: {
+ opacity: 0.85,
+ },
avatar: {
width: 96,
height: 96,
@@ -481,6 +510,11 @@ const styles = StyleSheet.create({
color: Colors.textPrimary,
marginTop: 4,
},
+ phoneUnderName: {
+ fontSize: 14,
+ fontFamily: Fonts.medium,
+ color: Colors.textSecondary,
+ },
city: {
fontSize: 15,
color: Colors.textSecondary,
diff --git a/src/app/(tabs)/profile/stats/[userId].tsx b/src/app/(tabs)/profile/stats/[userId].tsx
index 0ad7fa1..2627b10 100644
--- a/src/app/(tabs)/profile/stats/[userId].tsx
+++ b/src/app/(tabs)/profile/stats/[userId].tsx
@@ -1,3 +1,4 @@
+import { useCallback } from 'react'
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { useLocalSearchParams, useRouter, type Href } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -24,13 +25,20 @@ export default function PlayerStatsScreen() {
const insets = useSafeAreaInsets()
const { data, isPending, isError, refetch } = usePlayerStats(userId)
+ const goBack = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
+ router.replace('/(tabs)/profile' as Href)
+ }, [router])
+
return (
router.back()}
+ onPress={goBack}
accessibilityRole="button"
- accessibilityLabel="Cerrar estadísticas"
hitSlop={8}
style={styles.closeWrap}>
✕
@@ -68,8 +76,8 @@ export default function PlayerStatsScreen() {
]}
/>
- Forma reciente
-
+ Partidas recientes
+
@@ -118,13 +126,6 @@ export default function PlayerStatsScreen() {
Logros
-
- router.push('/(tabs)/leaderboard' as Href)}
- style={styles.leaderboardBtn}
- accessibilityRole="button">
- Ver ranking ELO
-
>
) : null}
@@ -190,17 +191,4 @@ const styles = StyleSheet.create({
color: Colors.danger,
textAlign: 'center',
},
- leaderboardBtn: {
- marginTop: 4,
- marginBottom: 8,
- alignItems: 'center',
- paddingVertical: 14,
- borderRadius: 12,
- backgroundColor: Colors.primary,
- },
- leaderboardText: {
- fontFamily: Fonts.semiBold,
- fontSize: 15,
- color: Colors.white,
- },
})
diff --git a/src/app/(tabs)/tournaments/[id].tsx b/src/app/(tabs)/tournaments/[id].tsx
index 7b978a5..b3e191a 100644
--- a/src/app/(tabs)/tournaments/[id].tsx
+++ b/src/app/(tabs)/tournaments/[id].tsx
@@ -17,6 +17,7 @@ import { BracketCanvas } from '@/components/tournaments/BracketCanvas'
import { CancelTournamentModal } from '@/components/tournaments/CancelTournamentModal'
import { EditPairModal, type EditPairFormValues } from '@/components/tournaments/EditPairModal'
import { PairCard } from '@/components/tournaments/PairCard'
+import { AddPairButton } from '@/components/ui/AddPairButton'
import { Button } from '@/components/ui/Button'
import { ShareInviteButton } from '@/components/ShareInviteButton'
import { formatDisplay } from '@/components/ui/dateTimePickerUtils'
@@ -25,6 +26,7 @@ import { MATCH_STATUS, MATCH_VISIBILITY, TOURNAMENT_STATUS } from '@/constants'
import { useAuthStore } from '@/hooks/useAuth'
import { confirmAlert, showAlert } from '@/utils/alert'
import { formatCityAndPlace } from '@/utils/location'
+
import { formatEntryFee } from '@/utils/tournamentForm'
import {
useAddTournamentPair,
@@ -84,6 +86,14 @@ export default function TournamentDetailScreen() {
const insets = useSafeAreaInsets()
const userId = useAuthStore((s) => s.session?.user.id)
+ const closeTournamentDetail = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
+ router.replace('/(tabs)/matches' as Href)
+ }, [router])
+
const {
data: tournament,
isLoading,
@@ -149,7 +159,7 @@ export default function TournamentDetailScreen() {
return (
No se pudo cargar el torneo.
- router.back()} style={{ marginTop: 16 }} />
+
)
}
@@ -292,7 +302,7 @@ export default function TournamentDetailScreen() {
router.back()}
+ onPress={closeTournamentDetail}
accessibilityRole="button"
accessibilityLabel="Cerrar">
✕
@@ -446,7 +456,6 @@ export default function TournamentDetailScreen() {
? 'Falta un jugador'
: undefined
}
- editLabel={canEditPair ? 'Editar' : undefined}
onEdit={canEditPair ? () => setEditingPair(p) : undefined}
joinLabel={canJoin ? 'Unirme' : undefined}
onJoin={canJoin ? () => void handleJoinPair(p.id, openSlot!) : undefined}
@@ -460,9 +469,8 @@ export default function TournamentDetailScreen() {
{inRegistration ? (
<>
- 0}
onPress={() => setPairModalOpen(true)}
/>
{isCreator ? (
@@ -510,6 +518,9 @@ export default function TournamentDetailScreen() {
onClose={() => setPairModalOpen(false)}
onSubmit={handleAddPair}
loading={addPair.isPending}
+ defaultSelfSlot={
+ userId && userIsInTournamentPair(tournament.pairs, userId) ? null : 'a'
+ }
selfJoinDisabled={Boolean(userId && userIsInTournamentPair(tournament.pairs, userId))}
/>
diff --git a/src/app/(tabs)/tournaments/create.tsx b/src/app/(tabs)/tournaments/create.tsx
index 3658cd5..02cfde6 100644
--- a/src/app/(tabs)/tournaments/create.tsx
+++ b/src/app/(tabs)/tournaments/create.tsx
@@ -10,6 +10,7 @@ import { z } from 'zod'
import { AddPairModal, type AddPairFormValues } from '@/components/tournaments/AddPairModal'
import { EditPairModal, type EditPairFormValues } from '@/components/tournaments/EditPairModal'
import { PairCard } from '@/components/tournaments/PairCard'
+import { AddPairButton } from '@/components/ui/AddPairButton'
import { Button } from '@/components/ui/Button'
import { KeyboardAwareScrollView } from '@/components/ui/KeyboardAwareScrollView'
import { dateToLocalIsoString } from '@/components/ui/dateTimePickerUtils'
@@ -294,9 +295,13 @@ export default function CreateTournamentScreen() {
router.replace(`/(tabs)/tournaments/${tournamentId}` as Href)
}
- const closeToMyMatches = () => {
+ const closeToMyMatches = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
router.replace('/(tabs)/matches' as Href)
- }
+ }, [router])
const closeBar = (
@@ -517,7 +522,7 @@ export default function CreateTournamentScreen() {
{pairs.length === 0 ? (
- Aún no hay parejas. Pulsa «Añadir pareja».
+ Todavía no hay parejas en este torneo.
) : (
pairs.map((p) => (
setEditingPair(p)}
/>
))
)}
- 0}
onPress={() => setPairModalOpen(true)}
style={s.actionBtn}
/>
@@ -544,6 +547,7 @@ export default function CreateTournamentScreen() {
onClose={() => setPairModalOpen(false)}
onSubmit={handleAddPair}
loading={addPair.isPending}
+ defaultSelfSlot={userAlreadyInPair ? null : 'a'}
selfJoinDisabled={userAlreadyInPair}
/>
diff --git a/src/app/(tabs)/tournaments/edit/[id].tsx b/src/app/(tabs)/tournaments/edit/[id].tsx
index 85d4189..05afa2f 100644
--- a/src/app/(tabs)/tournaments/edit/[id].tsx
+++ b/src/app/(tabs)/tournaments/edit/[id].tsx
@@ -1,3 +1,4 @@
+import { useCallback } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useLocalSearchParams, useRouter, type Href } from 'expo-router'
import { Controller, useForm } from 'react-hook-form'
@@ -85,13 +86,17 @@ export default function EditTournamentScreen() {
const durationValue = watch('duration_target_games')
const visibilityValue = watch('visibility')
- const closeToTournament = () => {
- if (!id) {
+ const closeToTournament = useCallback(() => {
+ if (router.canGoBack()) {
router.back()
return
}
- router.replace(`/(tabs)/tournaments/${id}` as Href)
- }
+ if (id) {
+ router.replace(`/(tabs)/tournaments/${id}` as Href)
+ return
+ }
+ router.replace('/(tabs)/matches' as Href)
+ }, [router, id])
if (isLoading || !tournament) {
return (
diff --git a/src/app/l/[id].tsx b/src/app/l/[id].tsx
new file mode 100644
index 0000000..71e9ab8
--- /dev/null
+++ b/src/app/l/[id].tsx
@@ -0,0 +1,12 @@
+import { Redirect, useLocalSearchParams } from 'expo-router'
+
+/** HTTPS App Link stub: `https://host/l/{id}` → league detail. */
+export default function LeagueHttpsInviteScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>()
+
+ if (!id) {
+ return
+ }
+
+ return
+}
diff --git a/src/app/leagues/[id].tsx b/src/app/leagues/[id].tsx
new file mode 100644
index 0000000..597a4f6
--- /dev/null
+++ b/src/app/leagues/[id].tsx
@@ -0,0 +1,12 @@
+import { Redirect, useLocalSearchParams } from 'expo-router'
+
+/** Deep link stub: `jugamus://leagues/{id}` → league detail. */
+export default function LeagueDeepLinkScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>()
+
+ if (!id) {
+ return
+ }
+
+ return
+}
diff --git a/src/components/DeleteAccountModal.tsx b/src/components/DeleteAccountModal.tsx
index 003730f..015bd12 100644
--- a/src/components/DeleteAccountModal.tsx
+++ b/src/components/DeleteAccountModal.tsx
@@ -2,10 +2,13 @@ import { useState } from 'react'
import { Modal, Pressable, SafeAreaView, StyleSheet, Text, View } from 'react-native'
import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Input'
import { ScrollableModalBody } from '@/components/ui/ScrollableModalBody'
import { Colors } from '@/theme/colors'
import { Fonts } from '@/theme/typography'
+const CONFIRM_WORD = 'ELIMINAR'
+
export interface DeleteAccountModalProps {
visible: boolean
onClose: () => void
@@ -19,12 +22,31 @@ export function DeleteAccountModal({
loading,
onConfirm,
}: DeleteAccountModalProps) {
+ const [confirmText, setConfirmText] = useState('')
const [error, setError] = useState(null)
+ const canConfirm = confirmText === CONFIRM_WORD
+
+ const resetForm = () => {
+ setConfirmText('')
+ setError(null)
+ }
+
+ const handleClose = () => {
+ if (loading) return
+ resetForm()
+ onClose()
+ }
+
const handleConfirm = async () => {
+ if (!canConfirm) {
+ setError(`Escribe ${CONFIRM_WORD} para confirmar`)
+ return
+ }
setError(null)
try {
await onConfirm()
+ resetForm()
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'No se pudo eliminar la cuenta')
@@ -36,15 +58,13 @@ export function DeleteAccountModal({
visible={visible}
animationType="slide"
presentationStyle="pageSheet"
- onShow={() => setError(null)}
- onRequestClose={() => {
- if (!loading) onClose()
- }}>
+ onShow={resetForm}
+ onRequestClose={handleClose}>
Eliminar cuenta
@@ -59,18 +79,32 @@ export function DeleteAccountModal({
Si continúas, perderás el acceso de forma permanente y no podrás recuperar tu cuenta.
+ {
+ setConfirmText(text)
+ if (error) setError(null)
+ }}
+ autoCapitalize="characters"
+ autoCorrect={false}
+ editable={!loading}
+ placeholder={CONFIRM_WORD}
+ accessibilityLabel={`Escribe ${CONFIRM_WORD} para confirmar`}
+ />
{error ? {error} : null}
void handleConfirm()}
loading={loading}
+ disabled={!canConfirm || loading}
style={s.btn}
/>
@@ -101,6 +135,6 @@ const s = StyleSheet.create({
lineHeight: 22,
},
message: { fontSize: 15, color: Colors.textPrimary, marginBottom: 16, lineHeight: 22 },
- error: { fontSize: 14, color: Colors.danger, marginBottom: 16 },
- btn: { marginBottom: 12 },
+ error: { fontSize: 14, color: Colors.danger, marginBottom: 16, marginTop: 4 },
+ btn: { marginBottom: 12, marginTop: 8 },
})
diff --git a/src/components/ShareInviteButton.tsx b/src/components/ShareInviteButton.tsx
index e59455d..bf106ce 100644
--- a/src/components/ShareInviteButton.tsx
+++ b/src/components/ShareInviteButton.tsx
@@ -2,11 +2,19 @@ import { useState } from 'react'
import { Alert, type StyleProp, type ViewStyle } from 'react-native'
import { Button } from '@/components/ui/Button'
-import { buildMatchHttpsInviteUrl, buildTournamentHttpsInviteUrl } from '@/lib/inviteLinks'
-import { buildInviteShareMessage, shareInviteViaWhatsApp } from '@/lib/shareInvite'
+import {
+ buildLeagueHttpsInviteUrl,
+ buildMatchHttpsInviteUrl,
+ buildTournamentHttpsInviteUrl,
+} from '@/lib/inviteLinks'
+import {
+ buildInviteShareMessage,
+ shareInviteViaWhatsApp,
+ type InviteShareKind,
+} from '@/lib/shareInvite'
type ShareInviteButtonProps = {
- kind: 'match' | 'tournament'
+ kind: InviteShareKind
id: string
title: string
meta?: string
@@ -19,8 +27,12 @@ export function ShareInviteButton({ kind, id, title, meta, style }: ShareInviteB
const handleShare = async () => {
setSharing(true)
try {
- const url =
- kind === 'match' ? buildMatchHttpsInviteUrl(id) : buildTournamentHttpsInviteUrl(id)
+ const urlByKind: Record = {
+ match: buildMatchHttpsInviteUrl(id),
+ tournament: buildTournamentHttpsInviteUrl(id),
+ league: buildLeagueHttpsInviteUrl(id),
+ }
+ const url = urlByKind[kind]
const message = buildInviteShareMessage({ kind, title, meta, url })
await shareInviteViaWhatsApp(message)
} catch (err) {
diff --git a/src/components/leagues/AddLeaguePairModal.tsx b/src/components/leagues/AddLeaguePairModal.tsx
new file mode 100644
index 0000000..c8d3f83
--- /dev/null
+++ b/src/components/leagues/AddLeaguePairModal.tsx
@@ -0,0 +1,230 @@
+import { useState } from 'react'
+import {
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ SafeAreaView,
+ ScrollView,
+ StyleSheet,
+ Switch,
+ Text,
+ View,
+} from 'react-native'
+
+import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Input'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+export type AddLeaguePairFormValues = {
+ name: string
+ playerAIsSelf: boolean
+ playerAText: string
+ playerBIsSelf: boolean
+ playerBText: string
+}
+
+type AddLeaguePairModalProps = {
+ visible: boolean
+ onClose: () => void
+ onSubmit: (values: AddLeaguePairFormValues) => void | Promise
+ loading?: boolean
+ defaultSelfSlot?: 'a' | 'b' | null
+ selfJoinDisabled?: boolean
+ title?: string
+}
+
+export function AddLeaguePairModal({
+ visible,
+ onClose,
+ onSubmit,
+ loading,
+ defaultSelfSlot = null,
+ selfJoinDisabled = false,
+ title = 'Añadir pareja',
+}: AddLeaguePairModalProps) {
+ const [name, setName] = useState('')
+ const [playerAIsSelf, setPlayerAIsSelf] = useState(false)
+ const [playerAText, setPlayerAText] = useState('')
+ const [playerBIsSelf, setPlayerBIsSelf] = useState(false)
+ const [playerBText, setPlayerBText] = useState('')
+
+ // Resetear el formulario cuando el modal abre o cambian las reglas de auto-join.
+ // Patrón "adjust state during render" (evita setState dentro de effect).
+ const [lastOpenKey, setLastOpenKey] = useState(null)
+ const openKey = visible ? `${defaultSelfSlot ?? ''}|${selfJoinDisabled ? '1' : '0'}` : null
+ if (openKey !== lastOpenKey) {
+ setLastOpenKey(openKey)
+ if (visible) {
+ setName('')
+ setPlayerAText('')
+ setPlayerBText('')
+ if (selfJoinDisabled) {
+ setPlayerAIsSelf(false)
+ setPlayerBIsSelf(false)
+ } else {
+ setPlayerAIsSelf(defaultSelfSlot === 'a')
+ setPlayerBIsSelf(defaultSelfSlot === 'b')
+ }
+ }
+ }
+
+ const resetForm = () => {
+ setName('')
+ setPlayerAText('')
+ setPlayerBText('')
+ setPlayerAIsSelf(false)
+ setPlayerBIsSelf(false)
+ }
+
+ const handleClose = () => {
+ resetForm()
+ onClose()
+ }
+
+ const handleSubmit = async () => {
+ try {
+ await onSubmit({
+ name,
+ playerAIsSelf: selfJoinDisabled ? false : playerAIsSelf,
+ playerAText,
+ playerBIsSelf: selfJoinDisabled ? false : playerBIsSelf,
+ playerBText,
+ })
+ resetForm()
+ } catch {
+ /* keep form */
+ }
+ }
+
+ return (
+
+
+
+ {title}
+
+ ✕
+
+
+
+
+
+ Jugador 1
+
+ Soy yo
+ {
+ setPlayerAIsSelf(v)
+ if (v) {
+ setPlayerAText('')
+ setPlayerBIsSelf(false)
+ }
+ }}
+ disabled={selfJoinDisabled}
+ trackColor={{ true: Colors.primary, false: Colors.switchTrackOff }}
+ thumbColor={Colors.white}
+ ios_backgroundColor={Colors.switchTrackOff}
+ />
+
+ {!playerAIsSelf ? (
+
+ ) : null}
+
+
+
+ Jugador 2 (opcional)
+
+ Soy yo
+ {
+ setPlayerBIsSelf(v)
+ if (v) {
+ setPlayerBText('')
+ setPlayerAIsSelf(false)
+ }
+ }}
+ disabled={selfJoinDisabled}
+ trackColor={{ true: Colors.primary, false: Colors.switchTrackOff }}
+ thumbColor={Colors.white}
+ ios_backgroundColor={Colors.switchTrackOff}
+ />
+
+ {!playerBIsSelf ? (
+
+ ) : null}
+
+
+
+
+
+
+ void handleSubmit()} loading={loading} />
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ wrap: { flex: 1, backgroundColor: Colors.background },
+ keyboard: { flex: 1 },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 20,
+ backgroundColor: Colors.surface,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: Colors.border,
+ },
+ title: { fontSize: 17, fontFamily: Fonts.bold, color: Colors.textPrimary },
+ close: { fontSize: 18, color: Colors.textSecondary, padding: 4 },
+ body: { padding: 20, paddingBottom: 40 },
+ slot: { marginBottom: 16 },
+ slotLabel: { fontSize: 14, fontFamily: Fonts.bold, color: Colors.primary, marginBottom: 8 },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ backgroundColor: Colors.surface,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ marginBottom: 8,
+ },
+ rowLabel: { fontSize: 15, color: Colors.textPrimary },
+ pairNameField: { marginBottom: 24 },
+})
diff --git a/src/components/leagues/CancelLeagueModal.tsx b/src/components/leagues/CancelLeagueModal.tsx
new file mode 100644
index 0000000..0612cbf
--- /dev/null
+++ b/src/components/leagues/CancelLeagueModal.tsx
@@ -0,0 +1,91 @@
+import { useState } from 'react'
+import { Modal, Pressable, SafeAreaView, StyleSheet, Text, View } from 'react-native'
+
+import { Button } from '@/components/ui/Button'
+import { ScrollableModalBody } from '@/components/ui/ScrollableModalBody'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+export interface CancelLeagueModalProps {
+ visible: boolean
+ onClose: () => void
+ hasFixturesOrInProgress: boolean
+ loading: boolean
+ onConfirm: () => Promise
+}
+
+export function CancelLeagueModal({
+ visible,
+ onClose,
+ hasFixturesOrInProgress,
+ loading,
+ onConfirm,
+}: CancelLeagueModalProps) {
+ const [error, setError] = useState(null)
+
+ const message = hasFixturesOrInProgress
+ ? 'Se cancelará la liga y todas las partidas pendientes. Esta acción no se puede deshacer.'
+ : '¿Seguro que quieres cancelar esta liga? Esta acción no se puede deshacer.'
+
+ const handleConfirm = async () => {
+ setError(null)
+ try {
+ await onConfirm()
+ onClose()
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'No se pudo cancelar')
+ }
+ }
+
+ return (
+ setError(null)}
+ onRequestClose={() => {
+ if (!loading) onClose()
+ }}>
+
+
+ Cancelar liga
+
+ ✕
+
+
+
+ {message}
+ {error ? {error} : null}
+ void handleConfirm()}
+ />
+
+
+
+
+ )
+}
+
+const s = StyleSheet.create({
+ wrap: { flex: 1, backgroundColor: Colors.background },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.border,
+ },
+ title: { fontSize: 18, fontFamily: Fonts.bold, color: Colors.textPrimary },
+ close: { fontSize: 20, color: Colors.textSecondary, padding: 4 },
+ message: { fontSize: 15, color: Colors.textSecondary, lineHeight: 22, marginBottom: 16 },
+ error: { color: Colors.danger, marginBottom: 12 },
+})
diff --git a/src/components/leagues/ChallengeList.tsx b/src/components/leagues/ChallengeList.tsx
new file mode 100644
index 0000000..4d6981c
--- /dev/null
+++ b/src/components/leagues/ChallengeList.tsx
@@ -0,0 +1,81 @@
+import { StyleSheet, Text, View } from 'react-native'
+
+import { Button } from '@/components/ui/Button'
+import type { LeagueChallengeRow } from '@/services/leagues.service'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+type ChallengeListProps = {
+ challenges: LeagueChallengeRow[]
+ userPairId: string | null
+ isOrganizer?: boolean
+ onAccept: (challengeId: string) => void
+ onReject: (challengeId: string) => void
+ actionLoadingId?: string | null
+}
+
+export function ChallengeList({
+ challenges,
+ userPairId,
+ isOrganizer = false,
+ onAccept,
+ onReject,
+ actionLoadingId,
+}: ChallengeListProps) {
+ const pending = challenges.filter((c) => c.status === 'pending')
+
+ if (pending.length === 0) {
+ return No hay desafíos pendientes
+ }
+
+ return (
+
+ {pending.map((ch) => {
+ const canRespond =
+ isOrganizer || (userPairId !== null && ch.challenged_pair_id === userPairId)
+ const loading = actionLoadingId === ch.id
+ return (
+
+
+ {ch.challenger_name ?? 'Pareja'} → {ch.challenged_name ?? 'Pareja'}
+
+ Pendiente de aceptación
+ {canRespond ? (
+
+ onAccept(ch.id)}
+ loading={loading}
+ style={styles.btn}
+ />
+ onReject(ch.id)}
+ disabled={loading}
+ style={styles.btn}
+ />
+
+ ) : null}
+
+ )
+ })}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ empty: { color: Colors.textSecondary, fontStyle: 'italic', paddingVertical: 8 },
+ list: { gap: 10 },
+ card: {
+ backgroundColor: Colors.surface,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ padding: 12,
+ },
+ title: { fontSize: 15, fontFamily: Fonts.semiBold, color: Colors.textPrimary },
+ status: { fontSize: 13, color: Colors.textSecondary, marginTop: 4 },
+ actions: { flexDirection: 'row', gap: 8, marginTop: 10 },
+ btn: { flex: 1 },
+})
diff --git a/src/components/leagues/ChallengeModal.tsx b/src/components/leagues/ChallengeModal.tsx
new file mode 100644
index 0000000..d226f84
--- /dev/null
+++ b/src/components/leagues/ChallengeModal.tsx
@@ -0,0 +1,126 @@
+import { useState } from 'react'
+import { Modal, Pressable, SafeAreaView, StyleSheet, Text, View } from 'react-native'
+
+import { Button } from '@/components/ui/Button'
+import { ScrollableModalBody } from '@/components/ui/ScrollableModalBody'
+import {
+ displayLeaguePairName,
+ type LeaguePairRow,
+} from '@/services/leagues.service'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+type ChallengeModalProps = {
+ visible: boolean
+ onClose: () => void
+ opponents: LeaguePairRow[]
+ loading?: boolean
+ onChallenge: (pairId: string) => Promise
+}
+
+export function ChallengeModal({
+ visible,
+ onClose,
+ opponents,
+ loading,
+ onChallenge,
+}: ChallengeModalProps) {
+ const [selectedId, setSelectedId] = useState(null)
+ const [error, setError] = useState(null)
+
+ const handleSubmit = async () => {
+ if (!selectedId) {
+ setError('Selecciona una pareja')
+ return
+ }
+ setError(null)
+ try {
+ await onChallenge(selectedId)
+ setSelectedId(null)
+ onClose()
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'No se pudo crear el desafío')
+ }
+ }
+
+ return (
+ {
+ setError(null)
+ setSelectedId(null)
+ }}
+ onRequestClose={onClose}>
+
+
+ Desafiar pareja
+
+ ✕
+
+
+
+ Elige la pareja a la que quieres desafiar.
+ {opponents.length === 0 ? (
+ No hay otras parejas disponibles
+ ) : (
+ opponents.map((pair) => {
+ const selected = selectedId === pair.id
+ return (
+ setSelectedId(pair.id)}
+ accessibilityRole="button"
+ accessibilityState={{ selected }}>
+
+ {displayLeaguePairName(pair)}
+
+ Elo {pair.current_elo}
+
+ )
+ })
+ )}
+ {error ? {error} : null}
+ void handleSubmit()}
+ loading={loading}
+ disabled={!selectedId}
+ />
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ wrap: { flex: 1, backgroundColor: Colors.background },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: Colors.border,
+ },
+ title: { fontSize: 18, fontFamily: Fonts.bold, color: Colors.textPrimary },
+ close: { fontSize: 20, color: Colors.textSecondary, padding: 4 },
+ hint: { fontSize: 14, color: Colors.textSecondary, marginBottom: 12 },
+ empty: { color: Colors.textSecondary, fontStyle: 'italic', marginBottom: 12 },
+ option: {
+ borderWidth: 1,
+ borderColor: Colors.border,
+ borderRadius: 10,
+ padding: 12,
+ marginBottom: 8,
+ backgroundColor: Colors.surface,
+ },
+ optionSelected: { borderColor: Colors.primary, backgroundColor: Colors.surface },
+ optionText: { fontSize: 15, fontFamily: Fonts.semiBold, color: Colors.textPrimary },
+ optionTextSelected: { color: Colors.primary },
+ elo: { fontSize: 12, color: Colors.textSecondary, marginTop: 4 },
+ error: { color: Colors.danger, marginBottom: 8 },
+})
diff --git a/src/components/leagues/EditLeaguePairModal.tsx b/src/components/leagues/EditLeaguePairModal.tsx
new file mode 100644
index 0000000..4a76036
--- /dev/null
+++ b/src/components/leagues/EditLeaguePairModal.tsx
@@ -0,0 +1,240 @@
+import { useState } from 'react'
+import {
+ Alert,
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ SafeAreaView,
+ ScrollView,
+ StyleSheet,
+ Text,
+ View,
+} from 'react-native'
+
+import { Button } from '@/components/ui/Button'
+import { Input } from '@/components/ui/Input'
+import type { LeaguePairRow } from '@/services/leagues.service'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+export type EditLeaguePairFormValues = {
+ name: string
+ playerAText: string
+ playerBText: string
+}
+
+type EditLeaguePairModalProps = {
+ visible: boolean
+ pair: LeaguePairRow | null
+ onClose: () => void
+ onSubmit: (values: EditLeaguePairFormValues) => void | Promise
+ onDelete: () => void | Promise
+ canDelete?: boolean
+ saveLoading?: boolean
+ deleteLoading?: boolean
+}
+
+function initialForm(pair: LeaguePairRow): EditLeaguePairFormValues {
+ return {
+ name: pair.name_is_custom ? (pair.name?.trim() ?? '') : '',
+ playerAText: pair.player_a_text?.trim() ?? '',
+ playerBText: pair.player_b_text?.trim() ?? '',
+ }
+}
+
+type EditPairFormProps = {
+ pair: LeaguePairRow
+ onClose: () => void
+ onSubmit: (values: EditLeaguePairFormValues) => void | Promise
+ onDelete: () => void | Promise
+ canDelete: boolean
+ saveLoading?: boolean
+ deleteLoading?: boolean
+}
+
+function EditPairForm({
+ pair,
+ onClose,
+ onSubmit,
+ onDelete,
+ canDelete,
+ saveLoading,
+ deleteLoading,
+}: EditPairFormProps) {
+ const initial = initialForm(pair)
+ const [name, setName] = useState(initial.name)
+ const [playerAText, setPlayerAText] = useState(initial.playerAText)
+ const [playerBText, setPlayerBText] = useState(initial.playerBText)
+
+ const playerALocked = Boolean(pair.player_a_user_id)
+ const playerBLocked = Boolean(pair.player_b_user_id)
+ const playerADisplay =
+ pair.player_a_display_name?.trim() || (playerALocked ? 'Jugador registrado' : '')
+ const playerBDisplay =
+ pair.player_b_display_name?.trim() || (playerBLocked ? 'Jugador registrado' : '')
+
+ const handleSubmit = async () => {
+ if (!playerALocked && pair.player_a_text?.trim() && !playerAText.trim()) {
+ Alert.alert(
+ 'Nombre obligatorio',
+ 'No puedes quitar jugadores de la pareja. Solo puedes editar el nombre.'
+ )
+ return
+ }
+ if (!playerBLocked && pair.player_b_text?.trim() && !playerBText.trim()) {
+ Alert.alert(
+ 'Nombre obligatorio',
+ 'No puedes quitar jugadores de la pareja. Solo puedes editar el nombre.'
+ )
+ return
+ }
+ try {
+ await onSubmit({ name, playerAText, playerBText })
+ } catch {
+ /* el padre muestra el error; mantenemos el formulario */
+ }
+ }
+
+ return (
+
+
+ Editar pareja
+
+ ✕
+
+
+
+
+
+ Jugador 1
+ {playerALocked ? (
+
+ {playerADisplay}
+ Inscrito con cuenta (no editable)
+
+ ) : (
+
+ )}
+
+
+
+ Jugador 2
+ {playerBLocked ? (
+
+ {playerBDisplay}
+ Inscrito con cuenta (no editable)
+
+ ) : (
+
+ )}
+
+
+
+
+ void handleSubmit()}
+ loading={saveLoading}
+ />
+ {canDelete ? (
+ {
+ void onDelete()
+ }}
+ loading={deleteLoading}
+ style={styles.deleteBtn}
+ />
+ ) : null}
+
+
+
+ )
+}
+
+export function EditLeaguePairModal({
+ visible,
+ pair,
+ onClose,
+ onSubmit,
+ onDelete,
+ canDelete = true,
+ saveLoading,
+ deleteLoading,
+}: EditLeaguePairModalProps) {
+ return (
+
+ {visible && pair ? (
+
+ ) : null}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ wrap: { flex: 1, backgroundColor: Colors.background },
+ keyboard: { flex: 1 },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 20,
+ backgroundColor: Colors.surface,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: Colors.border,
+ },
+ title: { fontSize: 17, fontFamily: Fonts.bold, color: Colors.textPrimary },
+ close: { fontSize: 18, color: Colors.textSecondary, padding: 4 },
+ body: { padding: 20, paddingBottom: 40 },
+ slot: { marginBottom: 16 },
+ slotLabel: { fontSize: 14, fontFamily: Fonts.bold, color: Colors.primary, marginBottom: 8 },
+ locked: {
+ backgroundColor: Colors.surface,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ },
+ lockedName: { fontSize: 15, fontFamily: Fonts.semiBold, color: Colors.textPrimary },
+ lockedHint: { fontSize: 12, color: Colors.textSecondary, marginTop: 4 },
+ deleteBtn: { marginTop: 12 },
+})
diff --git a/src/components/leagues/EloRanking.tsx b/src/components/leagues/EloRanking.tsx
new file mode 100644
index 0000000..d8c7061
--- /dev/null
+++ b/src/components/leagues/EloRanking.tsx
@@ -0,0 +1,74 @@
+import { StyleSheet, Text, View } from 'react-native'
+
+import type { LeagueStandingRow } from '@/services/leagues.service'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+type EloRankingProps = {
+ rows: LeagueStandingRow[]
+ emptyLabel?: string
+}
+
+/** Ranking by Elo (open league). Rows should already be sorted by current_elo desc. */
+export function EloRanking({ rows, emptyLabel = 'Sin clasificados aún' }: EloRankingProps) {
+ const sorted = [...rows].sort((a, b) => {
+ if (b.current_elo !== a.current_elo) return b.current_elo - a.current_elo
+ if (b.wins !== a.wins) return b.wins - a.wins
+ return a.pair_name.localeCompare(b.pair_name)
+ })
+
+ if (sorted.length === 0) {
+ return {emptyLabel}
+ }
+
+ return (
+
+ {sorted.map((row, index) => {
+ const rank = index + 1
+ const isPodium = rank <= 3
+ return (
+
+ {rank}
+
+
+ {row.pair_name}
+
+
+ {row.played} partidas · {row.wins}V / {row.losses}D
+
+
+ {row.current_elo}
+
+ )
+ })}
+
+ )
+}
+
+const styles = StyleSheet.create({
+ empty: { color: Colors.textSecondary, fontStyle: 'italic', paddingVertical: 12, textAlign: 'center' },
+ list: { gap: 8 },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: Colors.surface,
+ borderRadius: 10,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: Colors.border,
+ padding: 12,
+ gap: 10,
+ },
+ rowPodium: { borderColor: Colors.primary, borderWidth: 1 },
+ rank: {
+ width: 28,
+ fontSize: 16,
+ fontFamily: Fonts.bold,
+ color: Colors.textSecondary,
+ textAlign: 'center',
+ },
+ rankPodium: { color: Colors.primary, fontSize: 18 },
+ info: { flex: 1 },
+ name: { fontSize: 15, fontFamily: Fonts.semiBold, color: Colors.textPrimary },
+ meta: { fontSize: 12, color: Colors.textSecondary, marginTop: 2 },
+ elo: { fontSize: 18, fontFamily: Fonts.bold, color: Colors.primary },
+})
diff --git a/src/components/leagues/LeaguePairCard.tsx b/src/components/leagues/LeaguePairCard.tsx
new file mode 100644
index 0000000..4f7db43
--- /dev/null
+++ b/src/components/leagues/LeaguePairCard.tsx
@@ -0,0 +1,102 @@
+import { StyleSheet, Text, View } from 'react-native'
+
+import { Button } from '@/components/ui/Button'
+import { IconButton } from '@/components/ui/IconButton'
+import {
+ displayLeaguePairName,
+ leaguePairMemberLabels,
+ type LeaguePairRow,
+} from '@/services/leagues.service'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+type LeaguePairCardProps = {
+ pair: LeaguePairRow
+ subtitle?: string
+ eloLabel?: string
+ joinLabel?: string
+ onJoin?: () => void
+ joinLoading?: boolean
+ onEdit?: () => void
+ challengeLabel?: string
+ onChallenge?: () => void
+}
+
+export function LeaguePairCard({
+ pair,
+ subtitle,
+ eloLabel,
+ joinLabel,
+ onJoin,
+ joinLoading,
+ onEdit,
+ challengeLabel,
+ onChallenge,
+}: LeaguePairCardProps) {
+ const members = leaguePairMemberLabels(pair)
+
+ return (
+
+
+
+ {members.length > 0 ? (
+ {members.join(' · ')}
+ ) : (
+ Sin jugadores
+ )}
+ {displayLeaguePairName(pair)}
+
+ {onEdit ? (
+
+ ) : null}
+
+ {eloLabel ? {eloLabel} : null}
+ {subtitle ? {subtitle} : null}
+
+ {onJoin && joinLabel ? (
+
+ ) : null}
+ {onChallenge && challengeLabel ? (
+
+ ) : null}
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ card: {
+ backgroundColor: Colors.surface,
+ borderRadius: 12,
+ padding: 14,
+ marginBottom: 10,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ },
+ cardTop: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 },
+ cardMain: { flex: 1 },
+ editBtn: { marginTop: -4 },
+ name: { fontSize: 16, fontFamily: Fonts.bold, color: Colors.textPrimary, marginTop: 6 },
+ members: { fontSize: 14, color: Colors.textSecondary },
+ empty: { fontSize: 14, color: Colors.textSecondary, marginTop: 4, fontStyle: 'italic' },
+ elo: { fontSize: 13, fontFamily: Fonts.semiBold, color: Colors.primary, marginTop: 6 },
+ subtitle: { fontSize: 13, color: Colors.textSecondary, marginTop: 4 },
+ actions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10 },
+ actionBtn: { flexGrow: 1, minWidth: 100 },
+})
diff --git a/src/components/leagues/StandingsTable.tsx b/src/components/leagues/StandingsTable.tsx
new file mode 100644
index 0000000..a8d90f8
--- /dev/null
+++ b/src/components/leagues/StandingsTable.tsx
@@ -0,0 +1,120 @@
+import { StyleSheet, Text, View } from 'react-native'
+
+import type { LeagueStandingRow } from '@/services/leagues.service'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+type StandingsTableProps = {
+ rows: LeagueStandingRow[]
+ emptyLabel?: string
+}
+
+const STAT_COLUMNS = ['PJ', 'PG', 'PP'] as const
+
+export function StandingsTable({
+ rows,
+ emptyLabel = 'Aún no hay resultados',
+}: StandingsTableProps) {
+ if (rows.length === 0) {
+ return {emptyLabel}
+ }
+
+ return (
+
+
+ #
+ Pareja
+ {STAT_COLUMNS.map((label) => (
+
+ {label}
+
+ ))}
+
+
+ {rows.map((row) => {
+ const isPodium = row.rank <= 3
+ return (
+
+
+ {row.rank}
+
+
+ {row.pair_name}
+
+ {row.played}
+ {row.wins}
+ {row.losses}
+
+ )
+ })}
+
+ )
+}
+
+const STAT_WIDTH = 34
+
+const styles = StyleSheet.create({
+ empty: {
+ color: Colors.textSecondary,
+ fontStyle: 'italic',
+ paddingVertical: 12,
+ textAlign: 'center',
+ },
+ list: { gap: 6 },
+ headerRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 6,
+ paddingHorizontal: 12,
+ borderRadius: 10,
+ backgroundColor: Colors.surface,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: Colors.border,
+ },
+ headerCell: {
+ fontSize: 11,
+ fontFamily: Fonts.bold,
+ color: Colors.textSecondary,
+ textAlign: 'center',
+ textTransform: 'uppercase',
+ letterSpacing: 0.2,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 10,
+ paddingHorizontal: 12,
+ borderRadius: 10,
+ backgroundColor: Colors.surface,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: Colors.border,
+ },
+ rowPodium: { borderColor: Colors.primary, borderWidth: 1 },
+ cell: {
+ fontSize: 14,
+ color: Colors.textPrimary,
+ fontFamily: Fonts.regular,
+ textAlign: 'center',
+ },
+ rankCell: { width: 28 },
+ statCell: { width: STAT_WIDTH },
+ rankText: { fontFamily: Fonts.bold, color: Colors.textSecondary },
+ rankPodium: { color: Colors.primary, fontSize: 16 },
+ nameCell: {
+ flex: 1,
+ minWidth: 0,
+ textAlign: 'left',
+ paddingHorizontal: 8,
+ fontFamily: Fonts.semiBold,
+ fontSize: 14,
+ color: Colors.textPrimary,
+ },
+ wins: { color: Colors.primary, fontFamily: Fonts.semiBold },
+ losses: { color: Colors.danger, fontFamily: Fonts.semiBold },
+})
diff --git a/src/components/legal/LegalScreenLayout.tsx b/src/components/legal/LegalScreenLayout.tsx
index d4c9146..1371c5a 100644
--- a/src/components/legal/LegalScreenLayout.tsx
+++ b/src/components/legal/LegalScreenLayout.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
+import { useCallback } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
-import { useRouter } from 'expo-router'
+import { useRouter, type Href } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Button } from '@/components/ui/Button'
@@ -19,6 +20,14 @@ export function LegalScreenLayout({ title, children }: Props) {
const router = useRouter()
const insets = useSafeAreaInsets()
+ const goBack = useCallback(() => {
+ if (router.canGoBack()) {
+ router.back()
+ return
+ }
+ router.replace('/(tabs)/profile' as Href)
+ }, [router])
+
return (
@@ -28,7 +37,7 @@ export function LegalScreenLayout({ title, children }: Props) {
{children}
- router.back()} />
+
)
diff --git a/src/components/stats/BadgeList.tsx b/src/components/stats/BadgeList.tsx
index 3c62059..40932c3 100644
--- a/src/components/stats/BadgeList.tsx
+++ b/src/components/stats/BadgeList.tsx
@@ -1,47 +1,194 @@
-import { StyleSheet, Text, View } from 'react-native'
+import { useMemo, useState } from 'react'
+import { Pressable, StyleSheet, Text, View } from 'react-native'
-import { BADGE_LABELS, type PlayerBadge } from '@/services/stats.service'
+import {
+ BADGE_CATALOG,
+ BADGE_LABELS,
+ type PlayerBadge,
+} from '@/services/stats.service'
import { Colors } from '@/theme/colors'
import { Fonts } from '@/theme/typography'
+const COLLAPSED_COUNT = 4
+
+function formatEarnedAt(iso: string): string {
+ if (!iso) return ''
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return ''
+ return d.toLocaleDateString('es-ES', { day: '2-digit', month: 'short', year: 'numeric' })
+}
+
export function BadgeList({ badges }: { badges: PlayerBadge[] }) {
- if (!badges.length) {
- return Aún no hay logros
- }
+ const [expanded, setExpanded] = useState(false)
+ const [selectedKey, setSelectedKey] = useState(null)
+ const earnedByKey = useMemo(() => new Map(badges.map((b) => [b.key, b])), [badges])
+
+ const ordered = useMemo(() => {
+ const earned = BADGE_CATALOG.filter((b) => earnedByKey.has(b.key))
+ const locked = BADGE_CATALOG.filter((b) => !earnedByKey.has(b.key))
+ return [...earned, ...locked]
+ }, [earnedByKey])
+
+ const total = BADGE_CATALOG.length
+ const earnedCount = BADGE_CATALOG.filter((b) => earnedByKey.has(b.key)).length
+ const visible = expanded ? ordered : ordered.slice(0, COLLAPSED_COUNT)
+ const canToggle = ordered.length > COLLAPSED_COUNT
return (
- {badges.map((badge) => (
-
- {BADGE_LABELS[badge.key] ?? badge.key}
-
- ))}
+ {`${earnedCount} de ${total} logros`}
+
+
+ {visible.map((meta) => {
+ const earned = earnedByKey.get(meta.key)
+ const locked = !earned
+ const active = selectedKey === meta.key
+ return (
+ setSelectedKey(active ? null : meta.key)}
+ accessibilityRole="button"
+ style={({ pressed }) => [
+ styles.card,
+ locked ? styles.cardLocked : styles.cardEarned,
+ active && styles.cardActive,
+ pressed && styles.cardPressed,
+ ]}>
+
+ {meta.emoji}
+
+
+ {BADGE_LABELS[meta.key] ?? meta.key}
+
+ {active ? (
+ {meta.hint}
+ ) : earned ? (
+ {formatEarnedAt(earned.earned_at)}
+ ) : (
+ Bloqueado
+ )}
+
+ )
+ })}
+
+
+ {canToggle ? (
+ setExpanded((v) => !v)}
+ accessibilityRole="button"
+ style={({ pressed }) => [styles.toggleBtn, pressed && styles.toggleBtnPressed]}>
+ {expanded ? 'Ver menos' : 'Ver más'}
+
+ ) : null}
)
}
const styles = StyleSheet.create({
wrap: {
+ gap: 12,
+ },
+ progress: {
+ fontFamily: Fonts.semiBold,
+ fontSize: 13,
+ color: Colors.primary,
+ },
+ grid: {
flexDirection: 'row',
flexWrap: 'wrap',
- gap: 8,
+ gap: 10,
+ },
+ card: {
+ width: '47%',
+ flexGrow: 1,
+ minWidth: 140,
+ borderRadius: 14,
+ padding: 12,
+ gap: 6,
+ borderWidth: 1.5,
+ },
+ cardEarned: {
+ backgroundColor: Colors.wonBackground,
+ borderColor: Colors.primary,
},
- chip: {
+ cardLocked: {
backgroundColor: Colors.surface,
- borderRadius: 8,
- paddingHorizontal: 10,
- paddingVertical: 6,
+ borderColor: Colors.border,
+ opacity: 0.72,
+ },
+ cardActive: {
+ borderColor: Colors.primary,
+ borderWidth: 2.5,
+ },
+ cardPressed: {
+ opacity: 0.9,
+ },
+ iconWrap: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: Colors.white,
borderWidth: 1,
+ borderColor: Colors.primary,
+ marginBottom: 2,
+ },
+ iconWrapLocked: {
borderColor: Colors.border,
+ backgroundColor: Colors.background,
},
- chipText: {
- fontFamily: Fonts.medium,
- fontSize: 12,
+ icon: {
+ fontSize: 22,
+ },
+ iconLocked: {
+ opacity: 0.45,
+ },
+ title: {
+ fontFamily: Fonts.bold,
+ fontSize: 14,
color: Colors.textPrimary,
},
- empty: {
+ titleLocked: {
+ color: Colors.textSecondary,
+ fontFamily: Fonts.semiBold,
+ },
+ earnedAt: {
+ marginTop: 2,
+ fontFamily: Fonts.medium,
+ fontSize: 11,
+ color: Colors.primary,
+ },
+ hint: {
+ marginTop: 2,
fontFamily: Fonts.regular,
- fontSize: 13,
+ fontSize: 12,
color: Colors.textSecondary,
+ lineHeight: 16,
+ },
+ lockedTag: {
+ marginTop: 2,
+ fontFamily: Fonts.medium,
+ fontSize: 11,
+ color: Colors.textSecondary,
+ textTransform: 'uppercase',
+ letterSpacing: 0.4,
+ },
+ toggleBtn: {
+ alignSelf: 'center',
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ borderRadius: 10,
+ backgroundColor: Colors.surface,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ },
+ toggleBtnPressed: {
+ opacity: 0.85,
+ },
+ toggleText: {
+ fontFamily: Fonts.semiBold,
+ fontSize: 14,
+ color: Colors.primary,
},
})
diff --git a/src/components/stats/BadgeShowcase.tsx b/src/components/stats/BadgeShowcase.tsx
new file mode 100644
index 0000000..d973b14
--- /dev/null
+++ b/src/components/stats/BadgeShowcase.tsx
@@ -0,0 +1,312 @@
+import { useMemo, useState } from 'react'
+import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
+import { Ionicons } from '@expo/vector-icons'
+
+import { BADGE_CATALOG, BADGE_LABELS, type PlayerBadge } from '@/services/stats.service'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+const SLOT_COUNT = 3
+
+export function BadgeShowcase({
+ showcase,
+ earnedBadges,
+ canEdit,
+ saving,
+ onChange,
+}: {
+ showcase: string[]
+ earnedBadges: PlayerBadge[]
+ canEdit: boolean
+ saving?: boolean
+ onChange: (next: string[]) => Promise
+}) {
+ const [pickerSlot, setPickerSlot] = useState(null)
+ const [pickerError, setPickerError] = useState(null)
+
+ const earnedKeys = useMemo(() => new Set(earnedBadges.map((b) => b.key)), [earnedBadges])
+
+ const slots = useMemo(() => {
+ const valid = showcase.filter((k) => earnedKeys.has(k))
+ return Array.from({ length: SLOT_COUNT }, (_, i) => valid[i] ?? null)
+ }, [showcase, earnedKeys])
+
+ const selectable = useMemo(() => {
+ const used = new Set(slots.filter(Boolean) as string[])
+ return earnedBadges.filter((b) => !used.has(b.key))
+ }, [earnedBadges, slots])
+
+ const badgeMeta = (key: string) => BADGE_CATALOG.find((b) => b.key === key)
+
+ const handlePick = async (key: string | null) => {
+ if (pickerSlot === null) return
+ const next = slots.map((s, i) => (i === pickerSlot ? key : s)).filter((k): k is string => !!k)
+ setPickerError(null)
+ try {
+ await onChange(next)
+ setPickerSlot(null)
+ } catch (e) {
+ setPickerError(e instanceof Error ? e.message : 'No se pudo guardar')
+ }
+ }
+
+ return (
+
+
+ {slots.map((key, i) => {
+ const meta = key ? badgeMeta(key) : null
+ const label = key ? (BADGE_LABELS[key] ?? key) : null
+ const content = (
+
+
+ {meta ? (
+ {meta.emoji}
+ ) : (
+
+ )}
+
+
+ {label ?? (canEdit ? 'Elegir' : '—')}
+
+
+ )
+
+ return (
+
+ {canEdit ? (
+ setPickerSlot(i)}
+ disabled={saving}
+ accessibilityRole="button"
+ accessibilityLabel={
+ key ? `Cambiar logro ${label ?? i + 1}` : `Elegir logro ${i + 1}`
+ }
+ style={({ pressed }) => [pressed && styles.slotPressed]}>
+ {content}
+
+ ) : (
+ content
+ )}
+
+ )
+ })}
+
+
+ {canEdit ? (
+ Toca un hueco para elegir hasta 3 logros.
+ ) : null}
+
+ setPickerSlot(null)}>
+ setPickerSlot(null)}>
+ e.stopPropagation()}>
+ Elige un logro
+
+ {selectable.length === 0 && pickerSlot !== null && slots[pickerSlot] === null ? (
+ Aún no tienes más logros disponibles.
+ ) : null}
+ {selectable.map((badge) => {
+ const meta = badgeMeta(badge.key)
+ return (
+ void handlePick(badge.key)}
+ accessibilityRole="button"
+ style={({ pressed }) => [
+ styles.pickerItem,
+ pressed && styles.pickerItemPressed,
+ ]}>
+ {meta?.emoji ?? '🏅'}
+
+
+ {BADGE_LABELS[badge.key] ?? badge.key}
+
+ {meta?.hint}
+
+
+ )
+ })}
+
+ {pickerError ? {pickerError} : null}
+
+ {pickerSlot !== null && slots[pickerSlot] !== null ? (
+ void handlePick(null)}
+ disabled={saving}
+ accessibilityRole="button"
+ style={({ pressed }) => [styles.removeBtn, pressed && styles.pressed]}>
+ Quitar
+
+ ) : null}
+ setPickerSlot(null)}
+ disabled={saving}
+ accessibilityRole="button"
+ style={({ pressed }) => [styles.closeBtn, pressed && styles.pressed]}>
+ Cerrar
+
+
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ gap: 8,
+ },
+ row: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ gap: 8,
+ },
+ slotWrap: {
+ flex: 1,
+ alignItems: 'center',
+ },
+ slotColumn: {
+ alignItems: 'center',
+ gap: 6,
+ width: '100%',
+ },
+ slot: {
+ width: 56,
+ height: 56,
+ borderRadius: 28,
+ borderWidth: 1.5,
+ borderStyle: 'dashed',
+ borderColor: Colors.border,
+ backgroundColor: Colors.surface,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ slotFilled: {
+ borderStyle: 'solid',
+ borderColor: Colors.primary,
+ backgroundColor: Colors.wonBackground,
+ },
+ slotEditable: {
+ borderColor: Colors.primary,
+ },
+ slotPressed: {
+ opacity: 0.75,
+ },
+ slotEmoji: {
+ fontSize: 26,
+ },
+ slotLabel: {
+ fontFamily: Fonts.medium,
+ fontSize: 11,
+ color: Colors.textPrimary,
+ textAlign: 'center',
+ lineHeight: 14,
+ minHeight: 28,
+ },
+ slotLabelEmpty: {
+ color: Colors.textSecondary,
+ },
+ pressed: {
+ opacity: 0.8,
+ },
+ helper: {
+ fontFamily: Fonts.regular,
+ fontSize: 12,
+ color: Colors.textSecondary,
+ },
+ pickerBackdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.45)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 24,
+ },
+ pickerCard: {
+ width: '100%',
+ maxWidth: 360,
+ maxHeight: '70%',
+ backgroundColor: Colors.white,
+ borderRadius: 16,
+ padding: 16,
+ gap: 10,
+ },
+ pickerTitle: {
+ fontFamily: Fonts.bold,
+ fontSize: 17,
+ color: Colors.textPrimary,
+ },
+ pickerList: {
+ flexGrow: 0,
+ },
+ pickerEmpty: {
+ fontFamily: Fonts.regular,
+ fontSize: 14,
+ color: Colors.textSecondary,
+ paddingVertical: 12,
+ textAlign: 'center',
+ },
+ pickerItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ paddingVertical: 10,
+ paddingHorizontal: 8,
+ borderRadius: 10,
+ },
+ pickerItemPressed: {
+ backgroundColor: Colors.wonBackground,
+ },
+ pickerEmoji: {
+ fontSize: 24,
+ },
+ pickerItemText: {
+ flex: 1,
+ gap: 2,
+ },
+ pickerItemTitle: {
+ fontFamily: Fonts.semiBold,
+ fontSize: 14,
+ color: Colors.textPrimary,
+ },
+ pickerItemHint: {
+ fontFamily: Fonts.regular,
+ fontSize: 12,
+ color: Colors.textSecondary,
+ },
+ pickerError: {
+ fontFamily: Fonts.regular,
+ fontSize: 13,
+ color: Colors.danger,
+ },
+ pickerActions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: 10,
+ },
+ removeBtn: {
+ paddingVertical: 10,
+ paddingHorizontal: 14,
+ },
+ removeBtnText: {
+ fontFamily: Fonts.semiBold,
+ fontSize: 14,
+ color: Colors.danger,
+ },
+ closeBtn: {
+ paddingVertical: 10,
+ paddingHorizontal: 14,
+ borderRadius: 10,
+ backgroundColor: Colors.surface,
+ borderWidth: 1,
+ borderColor: Colors.border,
+ },
+ closeBtnText: {
+ fontFamily: Fonts.semiBold,
+ fontSize: 14,
+ color: Colors.textPrimary,
+ },
+})
diff --git a/src/components/stats/BadgeShowcaseSection.tsx b/src/components/stats/BadgeShowcaseSection.tsx
new file mode 100644
index 0000000..1e50c8a
--- /dev/null
+++ b/src/components/stats/BadgeShowcaseSection.tsx
@@ -0,0 +1,66 @@
+import { Alert, StyleSheet, Text, View } from 'react-native'
+
+import { BadgeShowcase } from '@/components/stats/BadgeShowcase'
+import { useAuthStore } from '@/hooks/useAuth'
+import { useProfile, useUpdateProfile, useViewableUserProfile } from '@/hooks/useProfile'
+import { usePlayerStats } from '@/hooks/useStats'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+/** Featured badges block meant to sit under the podium inside ProfileStatsCard. */
+export function BadgeShowcaseSection({ userId }: { userId: string }) {
+ const sessionUserId = useAuthStore((s) => s.session?.user.id)
+ const isOwn = sessionUserId === userId
+
+ const { data: ownProfile } = useProfile(isOwn ? sessionUserId : undefined)
+ const { data: viewableProfile } = useViewableUserProfile(isOwn ? undefined : userId)
+ const { data: stats } = usePlayerStats(userId)
+ const updateProfile = useUpdateProfile()
+
+ const profile = isOwn ? ownProfile : viewableProfile
+ if (!profile || !stats) return null
+
+ const badges = stats.badges ?? []
+ const showcase = profile.badge_showcase ?? []
+
+ // Own profile always shows empty slots; others only if they pinned badges
+ if (!isOwn && showcase.filter((k) => badges.some((b) => b.key === k)).length === 0) {
+ return null
+ }
+
+ return (
+
+ Logros destacados
+ {
+ 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
+ }
+ }}
+ />
+
+ )
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ gap: 10,
+ paddingTop: 2,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: Colors.border,
+ },
+ title: {
+ fontFamily: Fonts.medium,
+ fontSize: 12,
+ color: Colors.textSecondary,
+ textTransform: 'uppercase',
+ letterSpacing: 0.4,
+ },
+})
diff --git a/src/components/stats/BadgeUnlockPopup.tsx b/src/components/stats/BadgeUnlockPopup.tsx
new file mode 100644
index 0000000..32b47ca
--- /dev/null
+++ b/src/components/stats/BadgeUnlockPopup.tsx
@@ -0,0 +1,133 @@
+import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'
+import ConfettiCannon from 'react-native-confetti-cannon'
+
+import { BADGE_LABELS } from '@/services/stats.service'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+export type BadgeUnlockInfo = {
+ key: string
+ emoji: string
+}
+
+export function BadgeUnlockPopup({
+ badge,
+ onClose,
+}: {
+ badge: BadgeUnlockInfo | null
+ onClose: () => void
+}) {
+ if (!badge) return null
+
+ return (
+
+
+
+
+
+
+
+
+ {badge.emoji}
+
+ ¡Nuevo logro!
+ {BADGE_LABELS[badge.key] ?? badge.key}
+
+ Ya puedes mostrarlo en tu perfil desde la sección de logros.
+
+ [styles.btn, pressed && styles.btnPressed]}>
+ Genial
+
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ backdrop: {
+ flex: 1,
+ backgroundColor: 'rgba(0,0,0,0.45)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 24,
+ },
+ confettiWrap: {
+ ...StyleSheet.absoluteFillObject,
+ overflow: 'hidden',
+ },
+ card: {
+ width: '100%',
+ maxWidth: 340,
+ backgroundColor: Colors.white,
+ borderRadius: 20,
+ padding: 24,
+ alignItems: 'center',
+ gap: 10,
+ },
+ emojiWrap: {
+ width: 84,
+ height: 84,
+ borderRadius: 42,
+ backgroundColor: Colors.wonBackground,
+ borderWidth: 2,
+ borderColor: Colors.primary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 4,
+ },
+ emoji: {
+ fontSize: 44,
+ },
+ title: {
+ fontFamily: Fonts.bold,
+ fontSize: 22,
+ color: Colors.primary,
+ },
+ badgeName: {
+ fontFamily: Fonts.bold,
+ fontSize: 17,
+ color: Colors.textPrimary,
+ textAlign: 'center',
+ },
+ subtitle: {
+ fontFamily: Fonts.regular,
+ fontSize: 14,
+ color: Colors.textSecondary,
+ textAlign: 'center',
+ lineHeight: 20,
+ },
+ btn: {
+ marginTop: 8,
+ minWidth: 140,
+ minHeight: 44,
+ borderRadius: 12,
+ backgroundColor: Colors.primary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ btnPressed: {
+ opacity: 0.85,
+ },
+ btnText: {
+ fontFamily: Fonts.semiBold,
+ fontSize: 15,
+ color: Colors.white,
+ },
+})
diff --git a/src/components/stats/ELOBadge.tsx b/src/components/stats/ELOBadge.tsx
index 705dfe6..73cab5c 100644
--- a/src/components/stats/ELOBadge.tsx
+++ b/src/components/stats/ELOBadge.tsx
@@ -7,7 +7,7 @@ import { Fonts } from '@/theme/typography'
const ELO_HELP_TITLE = '¿Qué es el ELO?'
const ELO_HELP_MESSAGE =
- 'Es tu nivel en JugaMUS. Empiezas con 1200. Ganas más puntos si derrotas a rivales más fuertes, y pierdes más si caes contra rivales peores. Solo cambia al jugar contra rivales con cuenta en la app; jugar contra rivales introducidos por texto no lo afecta.'
+ 'Empiezas con 1200. Ganas más puntos si derrotas a rivales más fuertes y pierdes más si caes contra rivales peores. Solo cambia al jugar contra rivales con cuenta en la app; jugar contra rivales introducidos por texto no afecta.'
export function ELOBadge({ rating }: { rating: number }) {
return (
@@ -29,7 +29,7 @@ export function ELOBadge({ rating }: { rating: number }) {
const styles = StyleSheet.create({
wrap: {
flexDirection: 'row',
- alignItems: 'baseline',
+ alignItems: 'center',
gap: 4,
},
value: {
diff --git a/src/components/stats/FormBadges.tsx b/src/components/stats/FormBadges.tsx
index 5d4203a..730e2a8 100644
--- a/src/components/stats/FormBadges.tsx
+++ b/src/components/stats/FormBadges.tsx
@@ -4,20 +4,49 @@ import type { FormOutcome } from '@/services/stats.service'
import { Colors } from '@/theme/colors'
import { Fonts } from '@/theme/typography'
-export function FormBadges({ form }: { form: FormOutcome[] }) {
- if (!form.length) {
- return Sin forma reciente
+export function FormBadges({
+ form,
+ showTimeline = false,
+}: {
+ form: FormOutcome[]
+ /** When true, draw arrows oldest → newest and mark the latest match. */
+ showTimeline?: boolean
+}) {
+ const recent = form.slice(-5)
+
+ if (!recent.length) {
+ return {showTimeline ? 'Sin partidas recientes' : 'Sin forma reciente'}
}
+ const lastIndex = recent.length - 1
+
return (
- {form.map((outcome, index) => (
-
- {outcome === 'won' ? 'G' : 'P'}
-
- ))}
+ {recent.map((outcome, index) => {
+ const isLatest = showTimeline && index === lastIndex
+ return (
+
+ {index > 0 ? (
+
+ →
+
+ ) : null}
+
+
+
+ {outcome === 'won' ? 'V' : 'D'}
+
+
+ {isLatest ? Última : null}
+
+
+ )
+ })}
)
}
@@ -25,13 +54,33 @@ export function FormBadges({ form }: { form: FormOutcome[] }) {
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
- gap: 6,
+ flexWrap: 'wrap',
+ alignItems: 'flex-start',
+ gap: 0,
+ },
+ item: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ },
+ arrow: {
+ fontFamily: Fonts.medium,
+ fontSize: 14,
+ color: Colors.textSecondary,
+ paddingHorizontal: 4,
+ paddingTop: 4,
+ },
+ arrowLatest: {
+ color: Colors.primary,
+ },
+ dotWrap: {
alignItems: 'center',
+ gap: 2,
+ minWidth: 28,
},
dot: {
- width: 24,
- height: 24,
- borderRadius: 12,
+ width: 28,
+ height: 28,
+ borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
},
@@ -41,11 +90,28 @@ const styles = StyleSheet.create({
lost: {
backgroundColor: Colors.historyLostBackground,
},
+ dotLatest: {
+ borderWidth: 2,
+ borderColor: Colors.primary,
+ width: 32,
+ height: 32,
+ borderRadius: 16,
+ },
dotText: {
fontFamily: Fonts.semiBold,
- fontSize: 11,
+ fontSize: 12,
color: Colors.textPrimary,
},
+ dotTextLatest: {
+ fontFamily: Fonts.bold,
+ },
+ latestLabel: {
+ fontFamily: Fonts.medium,
+ fontSize: 9,
+ color: Colors.primary,
+ textTransform: 'uppercase',
+ letterSpacing: 0.3,
+ },
empty: {
fontFamily: Fonts.regular,
fontSize: 12,
diff --git a/src/components/stats/ProfileStatsCard.tsx b/src/components/stats/ProfileStatsCard.tsx
index 75ae79c..306b484 100644
--- a/src/components/stats/ProfileStatsCard.tsx
+++ b/src/components/stats/ProfileStatsCard.tsx
@@ -1,7 +1,9 @@
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
+import { BadgeShowcaseSection } from '@/components/stats/BadgeShowcaseSection'
import { ELOBadge } from '@/components/stats/ELOBadge'
-import { PodiumMedalsRow } from '@/components/stats/TournamentPodiumSection'
+import { RankingSection } from '@/components/stats/RankingSection'
+import { VisualPodium } from '@/components/stats/TournamentPodiumSection'
import { usePlayerStats } from '@/hooks/useStats'
import { Colors } from '@/theme/colors'
import { Fonts } from '@/theme/typography'
@@ -9,11 +11,14 @@ import { Fonts } from '@/theme/typography'
export function ProfileStatsCard({
userId,
onPressDetails,
+ onPressRanking,
}: {
userId: string
onPressDetails: () => void
+ onPressRanking: () => void
}) {
const { data, isPending, isError, refetch } = usePlayerStats(userId)
+ const statsReady = Boolean(data) && !isPending
if (isPending) {
return (
@@ -59,18 +64,21 @@ export function ProfileStatsCard({
Podio
{medalTotal > 0 ? (
-
+
) : (
- Sin medallas aún
+ Gana torneos y ligas para conseguir medallas
)}
+
+
+
+
[styles.detailsBtn, pressed && styles.detailsBtnPressed]}>
- Ver estadísticas detalladas
- ›
+ Ver detalles
)
@@ -141,10 +149,8 @@ const styles = StyleSheet.create({
paddingVertical: 4,
},
detailsBtn: {
- flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
- gap: 6,
minHeight: 44,
borderRadius: 10,
backgroundColor: Colors.primary,
@@ -157,12 +163,6 @@ const styles = StyleSheet.create({
fontSize: 15,
color: Colors.white,
},
- detailsBtnChevron: {
- fontFamily: Fonts.semiBold,
- fontSize: 20,
- color: Colors.white,
- lineHeight: 20,
- },
errorText: {
fontFamily: Fonts.regular,
fontSize: 14,
diff --git a/src/components/stats/RankingSection.tsx b/src/components/stats/RankingSection.tsx
new file mode 100644
index 0000000..30d897f
--- /dev/null
+++ b/src/components/stats/RankingSection.tsx
@@ -0,0 +1,119 @@
+import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
+
+import { usePlayerRanking } from '@/hooks/useStats'
+import { Colors } from '@/theme/colors'
+import { Fonts } from '@/theme/typography'
+
+function formatRank(rank: number | null | undefined, total?: number | null): string {
+ if (rank == null || rank <= 0) return '—'
+ if (total != null && total > 0) return `#${rank} / ${total}`
+ return `#${rank}`
+}
+
+export function RankingSection({
+ userId,
+ onPressRanking,
+ statsReady,
+}: {
+ userId: string
+ onPressRanking: () => void
+ statsReady: boolean
+}) {
+ const { data, isPending, isError } = usePlayerRanking(userId, { enabled: statsReady })
+
+ return (
+
+ Ranking
+
+ {isPending || !statsReady ? : null}
+
+ {statsReady && isError ? (
+ No se pudo cargar el ranking.
+ ) : null}
+
+ {statsReady && !isPending && !isError && data ? (
+
+ {data.city ? (
+
+
+ {data.city}
+
+ {formatRank(data.city_rank, data.city_total)}
+
+ ) : (
+ Sin ciudad para el ranking local
+ )}
+
+ Global
+ {formatRank(data.global_rank, data.global_total)}
+
+
+ ) : null}
+
+ [styles.rankingBtn, pressed && styles.rankingBtnPressed]}>
+ Ranking
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ wrap: {
+ gap: 10,
+ paddingTop: 2,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: Colors.border,
+ },
+ title: {
+ fontFamily: Fonts.medium,
+ fontSize: 12,
+ color: Colors.textSecondary,
+ textTransform: 'uppercase',
+ letterSpacing: 0.4,
+ },
+ rows: {
+ gap: 8,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 12,
+ },
+ label: {
+ flex: 1,
+ fontFamily: Fonts.medium,
+ fontSize: 14,
+ color: Colors.textPrimary,
+ },
+ value: {
+ fontFamily: Fonts.bold,
+ fontSize: 15,
+ color: Colors.primary,
+ },
+ empty: {
+ fontFamily: Fonts.regular,
+ fontSize: 13,
+ color: Colors.textSecondary,
+ },
+ rankingBtn: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ minHeight: 40,
+ borderRadius: 10,
+ borderWidth: 1,
+ borderColor: Colors.primary,
+ backgroundColor: Colors.surface,
+ },
+ rankingBtnPressed: {
+ opacity: 0.85,
+ },
+ rankingBtnText: {
+ fontFamily: Fonts.semiBold,
+ fontSize: 14,
+ color: Colors.primary,
+ },
+})
diff --git a/src/components/stats/TournamentPodiumSection.tsx b/src/components/stats/TournamentPodiumSection.tsx
index eb1ae79..0900257 100644
--- a/src/components/stats/TournamentPodiumSection.tsx
+++ b/src/components/stats/TournamentPodiumSection.tsx
@@ -5,9 +5,21 @@ import { Colors } from '@/theme/colors'
import { Fonts } from '@/theme/typography'
const MEDAL = {
- gold: { emoji: '🥇', label: 'Oro', color: '#B8860B' },
- silver: { emoji: '🥈', label: 'Plata', color: '#8A8A8A' },
- bronze: { emoji: '🥉', label: 'Bronce', color: '#A0622E' },
+ gold: { emoji: '🥇', label: 'Oro', color: '#B8860B', bg: '#FFF8E1' },
+ silver: { emoji: '🥈', label: 'Plata', color: '#8A8A8A', bg: '#F5F5F5' },
+ bronze: { emoji: '🥉', label: 'Bronce', color: '#A0622E', bg: '#FFF0E8' },
+} as const
+
+const PODIUM_HEIGHTS = {
+ gold: 116,
+ silver: 96,
+ bronze: 78,
+} as const
+
+const PODIUM_HEIGHTS_COMPACT = {
+ gold: 88,
+ silver: 72,
+ bronze: 58,
} as const
const SOURCE_LABEL: Record = {
@@ -50,6 +62,62 @@ function SourceChip({ source }: { source: PodiumSource }) {
)
}
+function PodiumStep({
+ kind,
+ count,
+ compact,
+}: {
+ kind: keyof typeof MEDAL
+ count: number
+ compact?: boolean
+}) {
+ const meta = MEDAL[kind]
+ const heights = compact ? PODIUM_HEIGHTS_COMPACT : PODIUM_HEIGHTS
+ const height = heights[kind]
+ const stepWidth = compact ? 72 : 90
+
+ return (
+
+
+
+ {meta.emoji}
+
+
+ {count}
+
+
+ {meta.label}
+
+ )
+}
+
+export function VisualPodium({
+ podium,
+ compact,
+}: {
+ podium: Podium
+ compact?: boolean
+}) {
+ const gold = podium.gold.length
+ const silver = podium.silver.length
+ const bronze = podium.bronze.length
+
+ return (
+
+
+
+
+
+
+
+ )
+}
+
function PodiumList({
kind,
entries,
@@ -68,7 +136,7 @@ function PodiumList({
{meta.emoji}
{meta.label}
- Sin podios
+ Compite para conseguir tu primera medalla
)
}
@@ -136,18 +204,16 @@ export function PodiumSection({
const total = podium.gold.length + podium.silver.length + podium.bronze.length
if (total === 0 && !showMedalCounts) {
- return Aún no hay podios en torneos ni ligas
+ return (
+
+ Gana torneos y ligas para conseguir medallas
+
+ )
}
return (
- {showMedalCounts ? (
-
-
-
-
-
- ) : null}
+ {showMedalCounts ? : null}
@@ -167,12 +233,6 @@ const styles = StyleSheet.create({
justifyContent: 'space-around',
gap: 8,
},
- medalsRowExpanded: {
- flexDirection: 'row',
- justifyContent: 'space-around',
- gap: 8,
- paddingBottom: 4,
- },
medalBadge: {
alignItems: 'center',
flex: 1,
@@ -195,6 +255,66 @@ const styles = StyleSheet.create({
fontSize: 11,
color: Colors.textSecondary,
},
+ // ── Visual podium ──
+ podiumWrap: {
+ alignItems: 'center',
+ paddingVertical: 8,
+ },
+ podiumWrapCompact: {
+ paddingVertical: 4,
+ },
+ podiumRow: {
+ flexDirection: 'row',
+ alignItems: 'flex-end',
+ justifyContent: 'center',
+ gap: 6,
+ },
+ podiumStep: {
+ alignItems: 'center',
+ width: 90,
+ },
+ podiumBase: {
+ width: '100%',
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
+ borderWidth: 1.5,
+ borderBottomWidth: 0,
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ gap: 2,
+ paddingTop: 10,
+ paddingBottom: 8,
+ paddingHorizontal: 4,
+ },
+ podiumBaseCompact: {
+ borderWidth: 1,
+ paddingTop: 7,
+ paddingBottom: 4,
+ },
+ podiumEmoji: {
+ fontSize: 26,
+ },
+ podiumEmojiCompact: {
+ fontSize: 20,
+ },
+ podiumCount: {
+ fontFamily: Fonts.bold,
+ fontSize: 22,
+ },
+ podiumCountCompact: {
+ fontSize: 16,
+ },
+ podiumLabel: {
+ marginTop: 4,
+ fontFamily: Fonts.medium,
+ fontSize: 11,
+ color: Colors.textSecondary,
+ },
+ podiumLabelCompact: {
+ marginTop: 2,
+ fontSize: 10,
+ },
+ // ── Lists ──
section: {
gap: 4,
},
diff --git a/src/components/tournaments/AddPairModal.tsx b/src/components/tournaments/AddPairModal.tsx
index 2416739..b80eabb 100644
--- a/src/components/tournaments/AddPairModal.tsx
+++ b/src/components/tournaments/AddPairModal.tsx
@@ -32,7 +32,7 @@ type AddPairModalProps = {
onClose: () => void
onSubmit: (values: AddPairFormValues) => void | Promise
loading?: boolean
- /** When true, slot A defaults to "soy yo" and cannot add second self */
+ /** When set, that slot defaults to "soy yo" on open (unless self join is disabled). */
defaultSelfSlot?: 'a' | 'b' | null
/** When true, «Soy yo» is disabled (player already in another pair). */
selfJoinDisabled?: boolean
@@ -49,23 +49,44 @@ export function AddPairModal({
title = 'Añadir pareja',
}: AddPairModalProps) {
const [name, setName] = useState('')
- const [playerAIsSelf, setPlayerAIsSelf] = useState(defaultSelfSlot === 'a')
+ const [playerAIsSelf, setPlayerAIsSelf] = useState(false)
const [playerAText, setPlayerAText] = useState('')
- const [playerBIsSelf, setPlayerBIsSelf] = useState(defaultSelfSlot === 'b')
+ const [playerBIsSelf, setPlayerBIsSelf] = useState(false)
const [playerBText, setPlayerBText] = useState('')
const [entryFeePaid, setEntryFeePaid] = useState(false)
- const reset = () => {
+ // Resetear el formulario cuando el modal abre o cambian las reglas de auto-join.
+ // Patrón "adjust state during render" (evita setState dentro de effect).
+ const [lastOpenKey, setLastOpenKey] = useState(null)
+ const openKey = visible ? `${defaultSelfSlot ?? ''}|${selfJoinDisabled ? '1' : '0'}` : null
+ if (openKey !== lastOpenKey) {
+ setLastOpenKey(openKey)
+ if (visible) {
+ setName('')
+ setPlayerAText('')
+ setPlayerBText('')
+ setEntryFeePaid(false)
+ if (selfJoinDisabled) {
+ setPlayerAIsSelf(false)
+ setPlayerBIsSelf(false)
+ } else {
+ setPlayerAIsSelf(defaultSelfSlot === 'a')
+ setPlayerBIsSelf(defaultSelfSlot === 'b')
+ }
+ }
+ }
+
+ const resetForm = () => {
setName('')
- setPlayerAIsSelf(defaultSelfSlot === 'a')
setPlayerAText('')
- setPlayerBIsSelf(defaultSelfSlot === 'b')
setPlayerBText('')
setEntryFeePaid(false)
+ setPlayerAIsSelf(false)
+ setPlayerBIsSelf(false)
}
const handleClose = () => {
- reset()
+ resetForm()
onClose()
}
@@ -73,14 +94,13 @@ export function AddPairModal({
try {
await onSubmit({
name,
- playerAIsSelf,
+ playerAIsSelf: selfJoinDisabled ? false : playerAIsSelf,
playerAText,
- playerBIsSelf,
+ playerBIsSelf: selfJoinDisabled ? false : playerBIsSelf,
playerBText,
entryFeePaid,
})
- // Solo limpiamos el formulario si el alta se ha completado con éxito.
- reset()
+ resetForm()
} catch {
// OnSubmit se encarga de mostrar el error (si aplica). No reiniciamos aquí para que el usuario pueda corregir.
}
@@ -119,7 +139,7 @@ export function AddPairModal({
setPlayerBIsSelf(false)
}
}}
- disabled={defaultSelfSlot === 'a' || selfJoinDisabled}
+ disabled={selfJoinDisabled}
trackColor={{ true: Colors.primary, false: Colors.switchTrackOff }}
thumbColor={Colors.white}
ios_backgroundColor={Colors.switchTrackOff}
@@ -149,7 +169,7 @@ export function AddPairModal({
setPlayerAIsSelf(false)
}
}}
- disabled={defaultSelfSlot === 'b' || selfJoinDisabled}
+ disabled={selfJoinDisabled}
trackColor={{ true: Colors.primary, false: Colors.switchTrackOff }}
thumbColor={Colors.white}
ios_backgroundColor={Colors.switchTrackOff}
diff --git a/src/components/tournaments/PairCard.tsx b/src/components/tournaments/PairCard.tsx
index da7e107..f0e03f9 100644
--- a/src/components/tournaments/PairCard.tsx
+++ b/src/components/tournaments/PairCard.tsx
@@ -1,6 +1,7 @@
import { StyleSheet, Text, View } from 'react-native'
import { Button } from '@/components/ui/Button'
+import { IconButton } from '@/components/ui/IconButton'
import type { TournamentPairRow } from '@/services/tournaments.service'
import { displayPairName, pairMemberLabels } from '@/services/tournaments.service'
import { Colors } from '@/theme/colors'
@@ -14,7 +15,6 @@ type PairCardProps = {
joinLabel?: string
onJoin?: () => void
joinLoading?: boolean
- editLabel?: string
onEdit?: () => void
}
@@ -25,7 +25,6 @@ export function PairCard({
joinLabel,
onJoin,
joinLoading,
- editLabel,
onEdit,
}: PairCardProps) {
const members = pairMemberLabels(pair)
@@ -33,12 +32,24 @@ export function PairCard({
return (
- {members.length > 0 ? (
- {members.join(' · ')}
- ) : (
- Sin jugadores
- )}
- {displayPairName(pair)}
+
+
+ {members.length > 0 ? (
+ {members.join(' · ')}
+ ) : (
+ Sin jugadores
+ )}
+ {displayPairName(pair)}
+
+ {onEdit ? (
+
+ ) : null}
+
{hasEntryFee ? (
{entryFeePaid ? 'Inscripción pagada' : 'Inscripción pendiente'}
@@ -46,9 +57,6 @@ export function PairCard({
) : null}
{subtitle ? {subtitle} : null}
- {onEdit && editLabel ? (
-
- ) : null}
{onJoin && joinLabel ? (
void
+ /** When false, show the same primary CTA as other screens; when true, a compact +. */
+ hasPairs?: boolean
+ accessibilityLabel?: string
+ style?: StyleProp
+}
+
+export function AddPairButton({
+ onPress,
+ hasPairs = false,
+ accessibilityLabel = 'Añadir pareja',
+ style,
+}: AddPairButtonProps) {
+ if (!hasPairs) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ iconWrap: {
+ alignItems: 'center',
+ alignSelf: 'stretch',
+ width: '100%',
+ marginTop: 4,
+ },
+ iconBtn: {
+ width: 44,
+ height: 44,
+ borderRadius: 22,
+ },
+})
diff --git a/src/components/ui/CreateFab.tsx b/src/components/ui/CreateFab.tsx
index 558d000..a503595 100644
--- a/src/components/ui/CreateFab.tsx
+++ b/src/components/ui/CreateFab.tsx
@@ -40,12 +40,19 @@ export function CreateFab({ bottom, right = 20 }: CreateFabProps) {
Crear partida
navigate('/(tabs)/tournaments/create')}
accessibilityRole="button"
accessibilityLabel="Organizar torneo">
Organizar torneo
+ navigate('/(tabs)/leagues/create')}
+ accessibilityRole="button"
+ accessibilityLabel="Organizar liga">
+ Organizar liga
+
diff --git a/src/components/ui/IconButton.tsx b/src/components/ui/IconButton.tsx
new file mode 100644
index 0000000..1c3c472
--- /dev/null
+++ b/src/components/ui/IconButton.tsx
@@ -0,0 +1,69 @@
+import { Ionicons } from '@expo/vector-icons'
+import { type ComponentProps } from 'react'
+import { Pressable, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
+
+import { Colors } from '@/theme/colors'
+
+type IconButtonProps = {
+ name: ComponentProps['name']
+ onPress: () => void
+ accessibilityLabel: string
+ size?: number
+ color?: string
+ variant?: 'ghost' | 'outline' | 'primary'
+ disabled?: boolean
+ style?: StyleProp
+}
+
+export function IconButton({
+ name,
+ onPress,
+ accessibilityLabel,
+ size = 22,
+ color,
+ variant = 'ghost',
+ disabled,
+ style,
+}: IconButtonProps) {
+ const iconColor =
+ color ??
+ (variant === 'primary' ? Colors.white : variant === 'outline' ? Colors.primary : Colors.textSecondary)
+
+ return (
+
+
+
+ )
+}
+
+const styles = StyleSheet.create({
+ base: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ outline: {
+ borderWidth: 1,
+ borderColor: Colors.primary,
+ backgroundColor: Colors.surface,
+ },
+ primary: {
+ backgroundColor: Colors.primary,
+ },
+ disabled: { opacity: 0.45 },
+})
diff --git a/src/constants/index.ts b/src/constants/index.ts
index ce801be..cf64b59 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -27,7 +27,34 @@ export const TOURNAMENT_STATUS = {
CANCELLED: 'cancelled',
} as const
-export type ExploreContentType = 'all' | 'matches' | 'tournaments'
+export const LEAGUE_STATUS = {
+ REGISTRATION: 'registration',
+ IN_PROGRESS: 'in_progress',
+ FINISHED: 'finished',
+ CANCELLED: 'cancelled',
+} as const
+
+export const LEAGUE_FORMAT = {
+ SINGLE_ROUND: 'single_round',
+ DOUBLE_ROUND: 'double_round',
+ OPEN_ELO: 'open_elo',
+} as const
+
+export type LeagueFormat = (typeof LEAGUE_FORMAT)[keyof typeof LEAGUE_FORMAT]
+
+export const LEAGUE_FORMAT_LABELS: Record = {
+ single_round: 'Solo ida',
+ double_round: 'Ida y vuelta',
+ open_elo: 'Liga abierta',
+}
+
+export const DEFAULT_ELO_INITIAL = 1000
+export const DEFAULT_ELO_K_FACTOR = 32
+
+/** Ligas: refresco frecuente (datos compartidos entre dispositivos). */
+export const LEAGUE_QUERY_STALE_TIME = 30 * 1000 // 30 segundos
+
+export type ExploreContentType = 'all' | 'matches' | 'tournaments' | 'leagues'
export const BRACKET_ROUND_LABELS: Record = {
2: 'Final',
diff --git a/src/hooks/useBadgeUnlocks.ts b/src/hooks/useBadgeUnlocks.ts
new file mode 100644
index 0000000..f4e403c
--- /dev/null
+++ b/src/hooks/useBadgeUnlocks.ts
@@ -0,0 +1,57 @@
+import { useEffect, useRef, useState } from 'react'
+
+import { useAuthStore } from '@/hooks/useAuth'
+import { usePlayerStats } from '@/hooks/useStats'
+import { BADGE_CATALOG, type PlayerBadge } from '@/services/stats.service'
+
+const BADGE_EMOJIS = new Map(BADGE_CATALOG.map((b) => [b.key, b.emoji]))
+
+export type UnlockedBadge = { key: string; emoji: string }
+
+/**
+ * Watches the current user's badges and exposes newly earned ones
+ * (one at a time) so a celebration popup can be shown.
+ */
+export function useBadgeUnlocks() {
+ const sessionUserId = useAuthStore((s) => s.session?.user.id)
+ const { data: stats } = usePlayerStats(sessionUserId)
+ const knownKeysRef = useRef | null>(null)
+ const queueRef = useRef([])
+ const [current, setCurrent] = useState(null)
+
+ useEffect(() => {
+ // Reset badge-tracking when switching accounts so we don't carry over unlocks.
+ knownKeysRef.current = null
+ queueRef.current = []
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setCurrent(null)
+ }, [sessionUserId])
+
+ useEffect(() => {
+ if (!stats) return
+ const keys = stats.badges.map((b: PlayerBadge) => b.key)
+
+ // First load: treat existing badges as known, don't celebrate them.
+ if (knownKeysRef.current === null) {
+ knownKeysRef.current = new Set(keys)
+ return
+ }
+
+ const fresh = keys.filter((k) => !knownKeysRef.current!.has(k))
+ if (fresh.length === 0) return
+
+ fresh.forEach((key) => {
+ knownKeysRef.current!.add(key)
+ queueRef.current.push({ key, emoji: BADGE_EMOJIS.get(key) ?? '🏅' })
+ })
+
+ const nextBadge = current == null ? (queueRef.current.shift() ?? null) : null
+ setCurrent((prev) => prev ?? nextBadge)
+ }, [stats, current])
+
+ const dismiss = () => {
+ setCurrent(queueRef.current.shift() ?? null)
+ }
+
+ return { unlockedBadge: current, dismiss }
+}
diff --git a/src/hooks/useLeagues.ts b/src/hooks/useLeagues.ts
new file mode 100644
index 0000000..36810ea
--- /dev/null
+++ b/src/hooks/useLeagues.ts
@@ -0,0 +1,299 @@
+import type { QueryClient } from '@tanstack/react-query'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+
+import { useAuthStore } from '@/hooks/useAuth'
+import { invalidateMyMatchesDashboard, invalidatePublicExplore } from '@/hooks/useMatches'
+import { LEAGUE_QUERY_STALE_TIME } from '@/constants'
+import {
+ acceptLeagueChallenge,
+ addLeaguePair,
+ cancelLeague,
+ createLeague,
+ createLeagueChallenge,
+ getLeague,
+ grantLeaguePasswordAccess,
+ joinLeaguePair,
+ listLeagueChallenges,
+ listLeagueMatches,
+ listLeagueStandings,
+ recordLeagueMatchAsReferee,
+ rejectLeagueChallenge,
+ removeLeaguePair,
+ startLeague,
+ updateLeague,
+ updateLeaguePair,
+ type AddLeaguePairInput,
+ type LeagueInsert,
+ type LeagueUpdate,
+ type UpdateLeaguePairInput,
+} from '@/services/leagues.service'
+
+export function leagueQueryKey(id: string) {
+ return ['league', id] as const
+}
+
+export function leagueStandingsQueryKey(id: string) {
+ return ['league-standings', id] as const
+}
+
+export function leagueMatchesQueryKey(id: string) {
+ return ['league-matches', id] as const
+}
+
+export function leagueChallengesQueryKey(id: string) {
+ return ['league-challenges', id] as const
+}
+
+export function invalidateLeagueQueries(queryClient: QueryClient, leagueId: string) {
+ queryClient.invalidateQueries({ queryKey: leagueQueryKey(leagueId) })
+ queryClient.invalidateQueries({ queryKey: leagueStandingsQueryKey(leagueId) })
+ queryClient.invalidateQueries({ queryKey: leagueMatchesQueryKey(leagueId) })
+ queryClient.invalidateQueries({ queryKey: leagueChallengesQueryKey(leagueId) })
+}
+
+export function useLeague(id: string) {
+ return useQuery({
+ queryKey: leagueQueryKey(id),
+ queryFn: () => getLeague(id),
+ enabled: Boolean(id),
+ staleTime: LEAGUE_QUERY_STALE_TIME,
+ refetchOnWindowFocus: true,
+ })
+}
+
+export function useLeagueStandings(id: string, enabled = true) {
+ return useQuery({
+ queryKey: leagueStandingsQueryKey(id),
+ queryFn: () => listLeagueStandings(id),
+ enabled: Boolean(id) && enabled,
+ staleTime: LEAGUE_QUERY_STALE_TIME,
+ refetchOnWindowFocus: true,
+ })
+}
+
+export function useLeagueMatches(id: string, enabled = true) {
+ return useQuery({
+ queryKey: leagueMatchesQueryKey(id),
+ queryFn: () => listLeagueMatches(id),
+ enabled: Boolean(id) && enabled,
+ staleTime: LEAGUE_QUERY_STALE_TIME,
+ refetchOnWindowFocus: true,
+ })
+}
+
+export function useLeagueChallenges(id: string, enabled = true) {
+ return useQuery({
+ queryKey: leagueChallengesQueryKey(id),
+ queryFn: () => listLeagueChallenges(id),
+ enabled: Boolean(id) && enabled,
+ staleTime: LEAGUE_QUERY_STALE_TIME,
+ refetchOnWindowFocus: true,
+ })
+}
+
+export function useCreateLeague() {
+ const queryClient = useQueryClient()
+ const sessionUserId = useAuthStore((s) => s.session?.user.id)
+
+ return useMutation({
+ mutationFn: ({ data, password }: { data: LeagueInsert; password?: string }) => {
+ if (!sessionUserId) throw new Error('No autenticado')
+ return createLeague(sessionUserId, data, password)
+ },
+ onSuccess: (row) => {
+ queryClient.invalidateQueries({ queryKey: leagueQueryKey(row.id) })
+ invalidatePublicExplore(queryClient)
+ invalidateMyMatchesDashboard(queryClient, sessionUserId)
+ },
+ })
+}
+
+export function useUpdateLeague() {
+ const queryClient = useQueryClient()
+ const sessionUserId = useAuthStore((s) => s.session?.user.id)
+
+ return useMutation({
+ mutationFn: ({
+ id,
+ data,
+ password,
+ }: {
+ id: string
+ data: LeagueUpdate
+ password?: string
+ }) => updateLeague(id, data, password),
+ onSuccess: (row) => {
+ invalidateLeagueQueries(queryClient, row.id)
+ invalidatePublicExplore(queryClient)
+ invalidateMyMatchesDashboard(queryClient, sessionUserId)
+ },
+ })
+}
+
+export function useCancelLeague() {
+ const queryClient = useQueryClient()
+ const sessionUserId = useAuthStore((s) => s.session?.user.id)
+
+ return useMutation({
+ mutationFn: (id: string) => cancelLeague(id),
+ onSuccess: (row) => {
+ invalidateLeagueQueries(queryClient, row.id)
+ invalidatePublicExplore(queryClient)
+ invalidateMyMatchesDashboard(queryClient, sessionUserId)
+ queryClient.invalidateQueries({ queryKey: ['match'], exact: false })
+ },
+ })
+}
+
+export function useGrantLeaguePasswordAccess() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({ leagueId, password }: { leagueId: string; password: string }) =>
+ grantLeaguePasswordAccess(leagueId, password),
+ onSuccess: (_void, { leagueId }) => {
+ invalidateLeagueQueries(queryClient, leagueId)
+ invalidatePublicExplore(queryClient)
+ },
+ })
+}
+
+export function useAddLeaguePair() {
+ const queryClient = useQueryClient()
+ const sessionUserId = useAuthStore((s) => s.session?.user.id)
+
+ return useMutation({
+ mutationFn: (input: AddLeaguePairInput) => addLeaguePair(input),
+ onSuccess: (_pair, input) => {
+ invalidateLeagueQueries(queryClient, input.leagueId)
+ invalidateMyMatchesDashboard(queryClient, sessionUserId)
+ },
+ })
+}
+
+export function useJoinLeaguePair() {
+ const queryClient = useQueryClient()
+ const sessionUserId = useAuthStore((s) => s.session?.user.id)
+
+ return useMutation({
+ mutationFn: ({
+ pairId,
+ slot,
+ asText,
+ leagueId: _leagueId,
+ }: {
+ pairId: string
+ slot: 'a' | 'b'
+ asText?: string | null
+ leagueId: string
+ }) => joinLeaguePair(pairId, slot, asText),
+ onSuccess: (_pair, { leagueId }) => {
+ invalidateLeagueQueries(queryClient, leagueId)
+ invalidateMyMatchesDashboard(queryClient, sessionUserId)
+ },
+ })
+}
+
+export function useUpdateLeaguePair() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (input: UpdateLeaguePairInput & { leagueId: string }) => updateLeaguePair(input),
+ onSuccess: (_pair, input) => {
+ invalidateLeagueQueries(queryClient, input.leagueId)
+ },
+ })
+}
+
+export function useRemoveLeaguePair() {
+ const queryClient = useQueryClient()
+ const sessionUserId = useAuthStore((s) => s.session?.user.id)
+
+ return useMutation({
+ mutationFn: ({ pairId, leagueId: _leagueId }: { pairId: string; leagueId: string }) =>
+ removeLeaguePair(pairId),
+ onSuccess: (_void, { leagueId }) => {
+ invalidateLeagueQueries(queryClient, leagueId)
+ invalidateMyMatchesDashboard(queryClient, sessionUserId)
+ },
+ })
+}
+
+export function useStartLeague() {
+ const queryClient = useQueryClient()
+ const sessionUserId = useAuthStore((s) => s.session?.user.id)
+
+ return useMutation({
+ mutationFn: ({ leagueId, format }: { leagueId: string; format: string }) =>
+ startLeague(leagueId, format),
+ onSuccess: (_void, { leagueId }) => {
+ invalidateLeagueQueries(queryClient, leagueId)
+ invalidatePublicExplore(queryClient)
+ invalidateMyMatchesDashboard(queryClient, sessionUserId)
+ },
+ })
+}
+
+export function useCreateLeagueChallenge() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({
+ leagueId,
+ challengedPairId,
+ }: {
+ leagueId: string
+ challengedPairId: string
+ }) => createLeagueChallenge(leagueId, challengedPairId),
+ onSuccess: (_ch, { leagueId }) => {
+ invalidateLeagueQueries(queryClient, leagueId)
+ },
+ })
+}
+
+export function useAcceptLeagueChallenge() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({ challengeId, leagueId: _leagueId }: { challengeId: string; leagueId: string }) =>
+ acceptLeagueChallenge(challengeId),
+ onSuccess: (_ch, { leagueId }) => {
+ invalidateLeagueQueries(queryClient, leagueId)
+ queryClient.invalidateQueries({ queryKey: ['match'], exact: false })
+ },
+ })
+}
+
+export function useRejectLeagueChallenge() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({ challengeId, leagueId: _leagueId }: { challengeId: string; leagueId: string }) =>
+ rejectLeagueChallenge(challengeId),
+ onSuccess: (_ch, { leagueId }) => {
+ invalidateLeagueQueries(queryClient, leagueId)
+ },
+ })
+}
+
+export function useRecordLeagueMatchAsReferee() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: ({
+ matchId,
+ teamAGames,
+ teamBGames,
+ leagueId: _leagueId,
+ }: {
+ matchId: string
+ teamAGames: number
+ teamBGames: number
+ leagueId: string
+ }) => recordLeagueMatchAsReferee(matchId, teamAGames, teamBGames),
+ onSuccess: (_void, { leagueId, matchId }) => {
+ invalidateLeagueQueries(queryClient, leagueId)
+ queryClient.invalidateQueries({ queryKey: ['match', matchId] })
+ },
+ })
+}
diff --git a/src/hooks/useMatches.ts b/src/hooks/useMatches.ts
index c4d1acc..e295650 100644
--- a/src/hooks/useMatches.ts
+++ b/src/hooks/useMatches.ts
@@ -28,6 +28,10 @@ import {
listPublicTournamentsFiltered,
type PublicTournamentsListFilters,
} from '@/services/tournaments.service'
+import {
+ listPublicLeaguesFiltered,
+ type PublicLeaguesListFilters,
+} from '@/services/leagues.service'
import { useAuthStore } from '@/hooks/useAuth'
import { invalidateTournamentQueries } from '@/hooks/useTournaments'
import { invalidatePlayerStatsCaches } from '@/hooks/useStats'
@@ -69,6 +73,7 @@ export function invalidateMyMatchesDashboard(queryClient: QueryClient, userId?:
export const PUBLIC_MATCHES_EXPLORE_ROOT = 'public-matches-explore' as const
export const PUBLIC_TOURNAMENTS_EXPLORE_ROOT = 'public-tournaments-explore' as const
+export const PUBLIC_LEAGUES_EXPLORE_ROOT = 'public-leagues-explore' as const
export function publicTournamentsExploreQueryKey(filters: PublicTournamentsListFilters) {
return [
@@ -85,6 +90,21 @@ export function publicTournamentsExploreQueryKey(filters: PublicTournamentsListF
] as const
}
+export function publicLeaguesExploreQueryKey(filters: PublicLeaguesListFilters) {
+ return [
+ PUBLIC_LEAGUES_EXPLORE_ROOT,
+ filters.contentType,
+ filters.search.trim(),
+ filters.city.trim(),
+ filters.status ?? '',
+ filters.hideCelebrated,
+ filters.startAfter ?? '',
+ filters.startBefore ?? '',
+ filters.minFreeSlots,
+ filters.visibility ?? 'all',
+ ] as const
+}
+
function invalidatePublicMatchesExplore(queryClient: QueryClient) {
queryClient.invalidateQueries({
queryKey: [PUBLIC_MATCHES_EXPLORE_ROOT],
@@ -99,9 +119,17 @@ function invalidatePublicTournamentsExplore(queryClient: QueryClient) {
})
}
+function invalidatePublicLeaguesExplore(queryClient: QueryClient) {
+ queryClient.invalidateQueries({
+ queryKey: [PUBLIC_LEAGUES_EXPLORE_ROOT],
+ exact: false,
+ })
+}
+
export function invalidatePublicExplore(queryClient: QueryClient) {
invalidatePublicMatchesExplore(queryClient)
invalidatePublicTournamentsExplore(queryClient)
+ invalidatePublicLeaguesExplore(queryClient)
}
export function publicMatchesExploreQueryKey(filters: PublicMatchesListFilters) {
@@ -180,7 +208,7 @@ export function useInfinitePublicMatches(filters: PublicMatchesListFilters) {
if (lastPage.total <= 0 || loaded >= lastPage.total) return undefined
return loaded
},
- enabled: filters.contentType !== 'tournaments',
+ enabled: filters.contentType !== 'tournaments' && filters.contentType !== 'leagues',
staleTime: QUERY_STALE_TIME,
})
}
@@ -189,7 +217,16 @@ export function usePublicTournamentsExplore(filters: PublicTournamentsListFilter
return useQuery({
queryKey: publicTournamentsExploreQueryKey(filters),
queryFn: () => listPublicTournamentsFiltered(filters),
- enabled: filters.contentType !== 'matches',
+ enabled: filters.contentType !== 'matches' && filters.contentType !== 'leagues',
+ ...TAB_SCREEN_QUERY_OPTIONS,
+ })
+}
+
+export function usePublicLeaguesExplore(filters: PublicLeaguesListFilters) {
+ return useQuery({
+ queryKey: publicLeaguesExploreQueryKey(filters),
+ queryFn: () => listPublicLeaguesFiltered(filters),
+ enabled: filters.contentType !== 'matches' && filters.contentType !== 'tournaments',
...TAB_SCREEN_QUERY_OPTIONS,
})
}
@@ -327,6 +364,8 @@ export function useStartMatch() {
),
tournamentsUpcoming: prev.tournamentsUpcoming ?? [],
tournamentsInProgress: prev.tournamentsInProgress ?? [],
+ leaguesUpcoming: prev.leaguesUpcoming ?? [],
+ leaguesInProgress: prev.leaguesInProgress ?? [],
}
}
)
@@ -342,6 +381,11 @@ export function useStartMatch() {
if (updated.tournament_id) {
invalidateTournamentQueries(queryClient, updated.tournament_id)
}
+ if (updated.league_id) {
+ queryClient.invalidateQueries({ queryKey: ['league', updated.league_id] })
+ queryClient.invalidateQueries({ queryKey: ['league-standings', updated.league_id] })
+ queryClient.invalidateQueries({ queryKey: ['league-matches', updated.league_id] })
+ }
invalidatePublicExplore(queryClient)
},
})
diff --git a/src/hooks/useResults.ts b/src/hooks/useResults.ts
index 72767a9..1e0a046 100644
--- a/src/hooks/useResults.ts
+++ b/src/hooks/useResults.ts
@@ -11,6 +11,7 @@ import {
} from '@/hooks/useMatches'
import { invalidatePlayerStatsCaches } from '@/hooks/useStats'
import { invalidateTournamentQueries } from '@/hooks/useTournaments'
+import { invalidateLeagueQueries } from '@/hooks/useLeagues'
import {
fetchMatchResultBundle,
submitConfirmation,
@@ -41,22 +42,26 @@ export function useSubmitResult() {
queryClient.invalidateQueries({
queryKey: matchResultQueryKey(variables.matchId, sessionUserId),
})
- const cached = queryClient.getQueryData<{ tournament_id?: string | null }>(
+ const cached = queryClient.getQueryData<{ tournament_id?: string | null; league_id?: string | null }>(
matchQueryKey(variables.matchId)
)
const tournamentId = cached?.tournament_id ?? null
+ const leagueId = cached?.league_id ?? null
if (tournamentId && row.status === 'confirmed') {
invalidateTournamentQueries(queryClient, tournamentId)
invalidateMyMatchesDashboard(queryClient, sessionUserId)
invalidatePublicExplore(queryClient)
}
+ if (leagueId && row.status === 'confirmed') {
+ invalidateLeagueQueries(queryClient, leagueId)
+ invalidateMyMatchesDashboard(queryClient, sessionUserId)
+ invalidatePublicExplore(queryClient)
+ }
if (sessionUserId) {
queryClient.invalidateQueries({ queryKey: userMatchesQueryKey(sessionUserId) })
invalidateMyMatchesDashboard(queryClient, sessionUserId)
}
- if (row.status === 'confirmed') {
- invalidatePlayerStatsCaches(queryClient)
- }
+ invalidatePlayerStatsCaches(queryClient)
},
})
}
@@ -72,7 +77,7 @@ export function useSubmitConfirmation() {
queryClient.invalidateQueries({
queryKey: matchResultQueryKey(variables.matchId, sessionUserId),
})
- const cached = queryClient.getQueryData<{ tournament_id?: string | null }>(
+ const cached = queryClient.getQueryData<{ tournament_id?: string | null; league_id?: string | null }>(
matchQueryKey(variables.matchId)
)
if (cached?.tournament_id) {
@@ -80,6 +85,11 @@ export function useSubmitConfirmation() {
invalidateMyMatchesDashboard(queryClient, sessionUserId)
invalidatePublicExplore(queryClient)
}
+ if (cached?.league_id) {
+ invalidateLeagueQueries(queryClient, cached.league_id)
+ invalidateMyMatchesDashboard(queryClient, sessionUserId)
+ invalidatePublicExplore(queryClient)
+ }
if (sessionUserId) {
queryClient.invalidateQueries({ queryKey: userMatchesQueryKey(sessionUserId) })
invalidateMyMatchesDashboard(queryClient, sessionUserId)
diff --git a/src/hooks/useStats.ts b/src/hooks/useStats.ts
index 69e1767..4ffc6a3 100644
--- a/src/hooks/useStats.ts
+++ b/src/hooks/useStats.ts
@@ -3,7 +3,12 @@ import { useQuery } from '@tanstack/react-query'
import { QUERY_STALE_TIME } from '@/constants'
import { useAuthStore } from '@/hooks/useAuth'
-import { getLeaderboard, getMatchInsights, getPlayerStats } from '@/services/stats.service'
+import {
+ getLeaderboard,
+ getMatchInsights,
+ getPlayerRanking,
+ getPlayerStats,
+} from '@/services/stats.service'
export function playerStatsQueryKey(userId: string) {
return ['player-stats', userId] as const
@@ -17,10 +22,15 @@ export function leaderboardQueryKey(city?: string | null) {
return ['leaderboard', city?.trim() || 'all'] as const
}
+export function playerRankingQueryKey(userId: string) {
+ return ['player-ranking', userId] as const
+}
+
export function invalidatePlayerStatsCaches(queryClient: QueryClient) {
queryClient.invalidateQueries({ queryKey: ['player-stats'], exact: false })
queryClient.invalidateQueries({ queryKey: ['match-insights'], exact: false })
queryClient.invalidateQueries({ queryKey: ['leaderboard'], exact: false })
+ queryClient.invalidateQueries({ queryKey: ['player-ranking'], exact: false })
}
export function usePlayerStats(userId?: string | null) {
@@ -28,7 +38,9 @@ export function usePlayerStats(userId?: string | null) {
queryKey: playerStatsQueryKey(userId ?? ''),
queryFn: () => getPlayerStats(userId!),
enabled: Boolean(userId),
- staleTime: QUERY_STALE_TIME,
+ // get_player_stats recalcula ELO + agregados en cada lectura
+ staleTime: 0,
+ refetchOnMount: 'always',
})
}
@@ -49,3 +61,17 @@ export function useLeaderboard(city?: string | null) {
staleTime: QUERY_STALE_TIME,
})
}
+
+export function usePlayerRanking(
+ userId?: string | null,
+ options?: { enabled?: boolean }
+) {
+ const enabled = options?.enabled ?? true
+ return useQuery({
+ queryKey: playerRankingQueryKey(userId ?? ''),
+ queryFn: () => getPlayerRanking(userId!),
+ enabled: Boolean(userId) && enabled,
+ staleTime: 0,
+ refetchOnMount: 'always',
+ })
+}
diff --git a/src/lib/inviteLinks.ts b/src/lib/inviteLinks.ts
index ec2d806..0d91e44 100644
--- a/src/lib/inviteLinks.ts
+++ b/src/lib/inviteLinks.ts
@@ -14,3 +14,7 @@ export function buildMatchHttpsInviteUrl(matchId: string): string {
export function buildTournamentHttpsInviteUrl(tournamentId: string): string {
return `https://${getInviteHost()}/t/${tournamentId}`
}
+
+export function buildLeagueHttpsInviteUrl(leagueId: string): string {
+ return `https://${getInviteHost()}/l/${leagueId}`
+}
diff --git a/src/lib/shareInvite.ts b/src/lib/shareInvite.ts
index 5f5a56a..85efb2f 100644
--- a/src/lib/shareInvite.ts
+++ b/src/lib/shareInvite.ts
@@ -1,6 +1,6 @@
import { Linking, Platform, Share } from 'react-native'
-export type InviteShareKind = 'match' | 'tournament'
+export type InviteShareKind = 'match' | 'tournament' | 'league'
export type InviteShareMessageInput = {
kind: InviteShareKind
@@ -10,7 +10,8 @@ export type InviteShareMessageInput = {
}
export function buildInviteShareMessage(input: InviteShareMessageInput): string {
- const label = input.kind === 'match' ? 'partida' : 'torneo'
+ const label =
+ input.kind === 'match' ? 'partida' : input.kind === 'tournament' ? 'torneo' : 'liga'
const lines = [`¡Únete a esta ${label} en jugaMUS!`, input.title.trim()]
if (input.meta?.trim()) {
lines.push(input.meta.trim())
diff --git a/src/services/leagues.service.ts b/src/services/leagues.service.ts
new file mode 100644
index 0000000..c78ee47
--- /dev/null
+++ b/src/services/leagues.service.ts
@@ -0,0 +1,729 @@
+import { trackMatchCompletedOnce } from '@/lib/analytics'
+import { supabase } from '@/lib/supabase'
+import { mapResultRpcError } from '@/services/results.service'
+import {
+ DEFAULT_ELO_INITIAL,
+ DEFAULT_ELO_K_FACTOR,
+ LEAGUE_FORMAT,
+ LEAGUE_STATUS,
+ MATCH_STATUS,
+ MATCH_VISIBILITY,
+ type ExploreContentType,
+ type LeagueFormat,
+} from '@/constants'
+import type { Tables, TablesInsert, TablesUpdate } from '@/types/database.types'
+
+/** Mirrors matches.service VisibilityFilter — kept local to avoid circular imports. */
+type VisibilityFilter = 'all' | 'public' | 'private'
+import { formatTeamNameFromPlayers } from '@/utils/matchTeamNames'
+
+function startAtToTimestamptzIso(startAt: string): string {
+ const d = new Date(startAt)
+ if (Number.isNaN(d.getTime())) {
+ throw new Error('Fecha no válida')
+ }
+ return d.toISOString()
+}
+
+export type LeagueRow = Tables<'leagues'>
+export type LeaguePairRow = Tables<'league_pairs'> & {
+ player_a_display_name?: string | null
+ player_b_display_name?: string | null
+}
+export type LeagueChallengeRow = Tables<'league_challenges'> & {
+ challenger_name?: string | null
+ challenged_name?: string | null
+}
+
+export type LeagueInsert = Pick<
+ TablesInsert<'leagues'>,
+ | 'title'
+ | 'description'
+ | 'notes'
+ | 'start_at'
+ | 'end_at'
+ | 'city'
+ | 'place_defined'
+ | 'place_text'
+ | 'duration_target_games'
+ | 'visibility'
+ | 'location_privacy'
+ | 'format'
+> & {
+ elo_initial?: number
+ elo_k_factor?: number
+}
+
+export type LeagueUpdate = Pick<
+ TablesUpdate<'leagues'>,
+ | 'title'
+ | 'description'
+ | 'notes'
+ | 'start_at'
+ | 'end_at'
+ | 'city'
+ | 'place_defined'
+ | 'place_text'
+ | 'duration_target_games'
+ | 'visibility'
+ | 'format'
+>
+
+export type LeagueWithPairs = LeagueRow & {
+ pairs: LeaguePairRow[]
+ organizer_display_name?: string | null
+ viewer_has_full_access?: boolean
+}
+
+export type LeagueStandingRow = {
+ pair_id: string
+ pair_name: string
+ played: number
+ wins: number
+ losses: number
+ games_for: number
+ games_against: number
+ games_diff: number
+ h2h_wins: number
+ current_elo: number
+ rank: number
+}
+
+export type LeagueMatchRow = {
+ match_id: string
+ title: string
+ start_at: string
+ status: string
+ pair_a_id: string | null
+ pair_a_name: string | null
+ pair_b_id: string | null
+ pair_b_name: string | null
+ round_number: number | null
+ is_second_leg: boolean
+ team_a_games: number | null
+ team_b_games: number | null
+}
+
+export type AddLeaguePairInput = {
+ leagueId: string
+ name?: string
+ playerAUserId?: string | null
+ playerAText?: string | null
+ playerBUserId?: string | null
+ playerBText?: string | null
+}
+
+export type UpdateLeaguePairInput = {
+ pairId: string
+ name?: string
+ playerAText?: string | null
+ playerBText?: string | null
+}
+
+export async function createLeague(
+ _userId: string,
+ data: LeagueInsert,
+ password?: string
+): Promise {
+ const format = (data.format ?? LEAGUE_FORMAT.SINGLE_ROUND) as LeagueFormat
+ if (format === LEAGUE_FORMAT.OPEN_ELO && !data.end_at) {
+ throw new Error('La liga abierta requiere fecha de fin')
+ }
+
+ const { data: row, error } = await supabase.rpc('create_league', {
+ p_title: data.title,
+ p_start_at: startAtToTimestamptzIso(data.start_at),
+ p_city: data.city,
+ p_duration_target_games: data.duration_target_games,
+ p_format: format,
+ p_end_at: data.end_at ? startAtToTimestamptzIso(data.end_at) : undefined,
+ p_description: data.description ?? undefined,
+ p_notes: data.notes ?? undefined,
+ p_place_defined: data.place_defined,
+ p_place_text: data.place_text ?? undefined,
+ p_visibility: data.visibility,
+ p_location_privacy: data.location_privacy,
+ p_elo_initial: data.elo_initial ?? DEFAULT_ELO_INITIAL,
+ p_elo_k_factor: data.elo_k_factor ?? DEFAULT_ELO_K_FACTOR,
+ })
+
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+ if (!row) throw new Error('No se pudo crear la liga')
+
+ const league = row as LeagueRow
+ if (data.visibility === MATCH_VISIBILITY.PRIVATE && password?.trim()) {
+ await setLeaguePassword(league.id, password.trim())
+ }
+
+ return league
+}
+
+export async function getLeague(id: string): Promise {
+ const { data: league, error } = await supabase
+ .from('leagues')
+ .select(
+ `*,
+ creator_profile:profiles!leagues_creator_id_fkey(display_name)`
+ )
+ .eq('id', id)
+ .single()
+
+ if (error) throw new Error(error.message)
+
+ const leagueRow = league as LeagueRow & {
+ creator_profile?: { display_name: string } | null
+ }
+
+ let viewerHasFullAccess = leagueRow.visibility !== MATCH_VISIBILITY.PRIVATE
+ if (leagueRow.visibility === MATCH_VISIBILITY.PRIVATE) {
+ const { data: canAccess, error: accessError } = await supabase.rpc('viewer_can_access_league', {
+ p_league_id: id,
+ })
+ if (accessError) throw new Error(accessError.message)
+ viewerHasFullAccess = Boolean(canAccess)
+ }
+
+ if (!viewerHasFullAccess) {
+ return {
+ ...(leagueRow as LeagueRow),
+ organizer_display_name: leagueRow.creator_profile?.display_name ?? null,
+ pairs: [],
+ viewer_has_full_access: false,
+ }
+ }
+
+ const { data: pairs, error: pairsError } = await supabase
+ .from('league_pairs')
+ .select(
+ `*,
+ player_a_profile:profiles!league_pairs_player_a_user_id_fkey(display_name),
+ player_b_profile:profiles!league_pairs_player_b_user_id_fkey(display_name)`
+ )
+ .eq('league_id', id)
+ .order('created_at', { ascending: true })
+
+ if (pairsError) throw new Error(pairsError.message)
+
+ const mappedPairs = (pairs ?? []).map((row) => {
+ const r = row as LeaguePairRow & {
+ player_a_profile?: { display_name: string } | null
+ player_b_profile?: { display_name: string } | null
+ }
+ return {
+ ...r,
+ player_a_display_name: r.player_a_profile?.display_name ?? null,
+ player_b_display_name: r.player_b_profile?.display_name ?? null,
+ }
+ })
+
+ return {
+ ...(leagueRow as LeagueRow),
+ organizer_display_name: leagueRow.creator_profile?.display_name ?? null,
+ pairs: mappedPairs,
+ viewer_has_full_access: true,
+ }
+}
+
+export async function updateLeague(
+ id: string,
+ data: LeagueUpdate,
+ password?: string
+): Promise {
+ const payload: LeagueUpdate = { ...data }
+ if (data.start_at !== undefined) {
+ payload.start_at = startAtToTimestamptzIso(data.start_at)
+ }
+ if (data.end_at !== undefined && data.end_at) {
+ payload.end_at = startAtToTimestamptzIso(data.end_at)
+ }
+
+ const { data: row, error } = await supabase
+ .from('leagues')
+ .update(payload)
+ .eq('id', id)
+ .select()
+ .single()
+
+ if (error) throw new Error(error.message)
+
+ if (data.visibility === MATCH_VISIBILITY.PRIVATE && password?.trim()) {
+ await setLeaguePassword(id, password.trim())
+ }
+
+ return row as LeagueRow
+}
+
+export async function cancelLeague(id: string): Promise {
+ const { data, error } = await supabase.rpc('cancel_league', { p_league_id: id })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+ return data as LeagueRow
+}
+
+export async function setLeaguePassword(leagueId: string, password: string): Promise {
+ const { error } = await supabase.rpc('set_league_password', {
+ p_league_id: leagueId,
+ p_password: password,
+ })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+}
+
+export async function grantLeaguePasswordAccess(leagueId: string, password: string): Promise {
+ const { error } = await supabase.rpc('grant_league_password_access', {
+ p_league_id: leagueId,
+ p_password: password,
+ })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+}
+
+export async function addLeaguePair(input: AddLeaguePairInput): Promise {
+ const { data, error } = await supabase.rpc('add_league_pair', {
+ p_league_id: input.leagueId,
+ p_name: input.name?.trim() ?? '',
+ p_player_a_user_id: input.playerAUserId ?? undefined,
+ p_player_a_text: input.playerAText ?? undefined,
+ p_player_b_user_id: input.playerBUserId ?? undefined,
+ p_player_b_text: input.playerBText ?? undefined,
+ })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+ return data as LeaguePairRow
+}
+
+export async function joinLeaguePair(
+ pairId: string,
+ slot: 'a' | 'b',
+ asText?: string | null
+): Promise {
+ const { data, error } = await supabase.rpc('join_league_pair', {
+ p_pair_id: pairId,
+ p_slot: slot,
+ p_as_text: asText ?? undefined,
+ })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+ return data as LeaguePairRow
+}
+
+export async function updateLeaguePair(input: UpdateLeaguePairInput): Promise {
+ const { data, error } = await supabase.rpc('update_league_pair', {
+ p_pair_id: input.pairId,
+ p_name: input.name?.trim() ?? '',
+ p_player_a_text: input.playerAText ?? '',
+ p_player_b_text: input.playerBText ?? '',
+ })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+ return data as LeaguePairRow
+}
+
+export async function removeLeaguePair(pairId: string): Promise {
+ const { error } = await supabase.rpc('remove_league_pair', { p_pair_id: pairId })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+}
+
+export async function generateLeagueFixtures(leagueId: string): Promise {
+ const { error } = await supabase.rpc('generate_league_fixtures', { p_league_id: leagueId })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+}
+
+export async function startOpenLeague(leagueId: string): Promise {
+ const { error } = await supabase.rpc('start_open_league', { p_league_id: leagueId })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+}
+
+export async function startLeague(leagueId: string, format: string): Promise {
+ if (format === LEAGUE_FORMAT.OPEN_ELO) {
+ await startOpenLeague(leagueId)
+ } else {
+ await generateLeagueFixtures(leagueId)
+ }
+}
+
+export async function listLeagueStandings(leagueId: string): Promise {
+ const { data, error } = await supabase.rpc('list_league_standings', { p_league_id: leagueId })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+ return (data ?? []) as LeagueStandingRow[]
+}
+
+export async function listLeagueMatches(leagueId: string): Promise {
+ const { data, error } = await supabase.rpc('list_league_matches', { p_league_id: leagueId })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+ return (data ?? []) as LeagueMatchRow[]
+}
+
+export async function listLeagueChallenges(leagueId: string): Promise {
+ const { data, error } = await supabase
+ .from('league_challenges')
+ .select('*')
+ .eq('league_id', leagueId)
+ .order('created_at', { ascending: false })
+
+ if (error) throw new Error(error.message)
+
+ const challenges = (data ?? []) as Tables<'league_challenges'>[]
+ if (challenges.length === 0) return []
+
+ const pairIds = Array.from(
+ new Set(challenges.flatMap((c) => [c.challenger_pair_id, c.challenged_pair_id]))
+ )
+ const { data: pairs, error: pairsError } = await supabase
+ .from('league_pairs')
+ .select('id, name')
+ .in('id', pairIds)
+
+ if (pairsError) throw new Error(pairsError.message)
+
+ const nameById = new Map((pairs ?? []).map((p) => [p.id, p.name]))
+
+ return challenges.map((row) => ({
+ ...row,
+ challenger_name: nameById.get(row.challenger_pair_id) ?? null,
+ challenged_name: nameById.get(row.challenged_pair_id) ?? null,
+ }))
+}
+
+export async function createLeagueChallenge(
+ leagueId: string,
+ challengedPairId: string
+): Promise {
+ const { data, error } = await supabase.rpc('create_league_challenge', {
+ p_league_id: leagueId,
+ p_challenged_pair_id: challengedPairId,
+ })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+ return data as LeagueChallengeRow
+}
+
+export async function acceptLeagueChallenge(challengeId: string): Promise {
+ const { data, error } = await supabase.rpc('accept_league_challenge', {
+ p_challenge_id: challengeId,
+ })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+ return data as LeagueChallengeRow
+}
+
+export async function rejectLeagueChallenge(challengeId: string): Promise {
+ const { data, error } = await supabase.rpc('reject_league_challenge', {
+ p_challenge_id: challengeId,
+ })
+ if (error) throw new Error(mapLeagueRpcError(error.message))
+ return data as LeagueChallengeRow
+}
+
+export async function recordLeagueMatchAsReferee(
+ matchId: string,
+ teamAGames: number,
+ teamBGames: number
+): Promise {
+ const { error } = await supabase.rpc('record_league_match_result_as_referee', {
+ p_match_id: matchId,
+ p_team_a_games: teamAGames,
+ p_team_b_games: teamBGames,
+ })
+ if (error) throw new Error(mapResultRpcError(error.message))
+ void trackMatchCompletedOnce(matchId).catch(() => {
+ /* analytics must not block */
+ })
+}
+
+export type PublicLeaguesListFilters = {
+ search: string
+ city: string
+ status: string | null
+ hideCelebrated: boolean
+ startAfter: string | null
+ startBefore: string | null
+ minFreeSlots: number
+ contentType: ExploreContentType
+ visibility: VisibilityFilter
+}
+
+export type UserLeagueSummary = {
+ id: string
+ title: string
+ start_at: string
+ end_at: string | null
+ city: string
+ place_defined: boolean
+ place_text: string | null
+ status: string
+ format: string
+ creator_id: string
+ fixtures_generated_at: string | null
+ isOrganizer: boolean
+}
+
+function leagueStatusesFromExploreFilter(matchStatus: string | null): string[] | null {
+ if (!matchStatus) return null
+ switch (matchStatus) {
+ case MATCH_STATUS.PLANNED:
+ return [LEAGUE_STATUS.REGISTRATION]
+ case MATCH_STATUS.IN_PROGRESS:
+ return [LEAGUE_STATUS.IN_PROGRESS]
+ case MATCH_STATUS.FINISHED:
+ case MATCH_STATUS.FINISHED_NO_RESULT:
+ return [LEAGUE_STATUS.FINISHED]
+ default:
+ return []
+ }
+}
+
+export async function listPublicLeaguesFiltered(
+ filters: PublicLeaguesListFilters,
+ limit = 50
+): Promise {
+ if (filters.minFreeSlots > 0) return []
+
+ const statuses = leagueStatusesFromExploreFilter(filters.status)
+ if (statuses !== null && statuses.length === 0) return []
+
+ // Only expose public/safe league columns to the explore endpoint.
+ 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',
+ ].join(',')
+ )
+ .neq('status', LEAGUE_STATUS.CANCELLED)
+
+ const visibility = filters.visibility ?? 'all'
+ if (visibility === 'public') {
+ query = query.eq('visibility', 'public')
+ } else if (visibility === 'private') {
+ query = query.eq('visibility', 'private')
+ } else {
+ query = query.in('visibility', ['public', 'private'])
+ }
+
+ const city = filters.city.trim()
+ if (city) query = query.ilike('city', `%${city}%`)
+
+ const search = filters.search.trim()
+ if (search) query = query.ilike('title', `%${search}%`)
+
+ if (filters.hideCelebrated) {
+ query = query.neq('status', LEAGUE_STATUS.FINISHED)
+ }
+
+ if (filters.startAfter) {
+ if (filters.hideCelebrated) {
+ const cutoff = filters.startAfter
+ query = query.or(
+ `start_at.gte."${cutoff}",and(start_at.lt."${cutoff}",status.in.(${LEAGUE_STATUS.REGISTRATION},${LEAGUE_STATUS.IN_PROGRESS}))`
+ )
+ } else {
+ query = query.gte('start_at', filters.startAfter)
+ }
+ }
+ if (filters.startBefore) query = query.lte('start_at', filters.startBefore)
+ if (statuses) query = query.in('status', statuses)
+
+ const { data, error } = await query.order('start_at', { ascending: true }).limit(limit)
+ if (error) throw new Error(error.message)
+ const rows = (data ?? []) as unknown as LeagueRow[]
+
+ return rows.map((row) => ({
+ ...row,
+ place_text: row.location_privacy === 'participants_only' ? null : row.place_text,
+ })) as LeagueRow[]
+}
+
+export async function getUserLeaguesDashboard(userId: string): Promise<{
+ upcoming: UserLeagueSummary[]
+ inProgress: UserLeagueSummary[]
+}> {
+ const [createdRes, pairsRes] = await Promise.all([
+ supabase
+ .from('leagues')
+ .select(
+ 'id, title, start_at, end_at, city, place_defined, place_text, status, format, creator_id, fixtures_generated_at'
+ )
+ .eq('creator_id', userId)
+ .neq('status', LEAGUE_STATUS.CANCELLED),
+ supabase
+ .from('league_pairs')
+ .select(
+ `league:leagues(id, title, start_at, end_at, city, place_defined, place_text, status, format, creator_id, fixtures_generated_at)`
+ )
+ .or(`player_a_user_id.eq.${userId},player_b_user_id.eq.${userId}`),
+ ])
+
+ if (createdRes.error) throw new Error(createdRes.error.message)
+ if (pairsRes.error) throw new Error(pairsRes.error.message)
+
+ type LeagueBrief = Omit
+ const byId = new Map()
+
+ for (const row of (createdRes.data ?? []) as LeagueBrief[]) {
+ byId.set(row.id, { ...row, isOrganizer: true })
+ }
+
+ for (const row of pairsRes.data ?? []) {
+ const l = row.league as LeagueBrief | null
+ if (!l || l.status === LEAGUE_STATUS.CANCELLED) continue
+ if (!byId.has(l.id)) {
+ byId.set(l.id, { ...l, isOrganizer: l.creator_id === userId })
+ }
+ }
+
+ const all = Array.from(byId.values())
+ const upcoming = all
+ .filter((l) => l.status === LEAGUE_STATUS.REGISTRATION)
+ .sort((a, b) => new Date(a.start_at).getTime() - new Date(b.start_at).getTime())
+ const inProgress = all
+ .filter((l) => l.status === LEAGUE_STATUS.IN_PROGRESS)
+ .sort((a, b) => new Date(a.start_at).getTime() - new Date(b.start_at).getTime())
+
+ return { upcoming, inProgress }
+}
+
+export function isLeaguePairComplete(pair: LeaguePairRow): boolean {
+ const hasA = Boolean(pair.player_a_user_id || pair.player_a_text?.trim())
+ const hasB = Boolean(pair.player_b_user_id || pair.player_b_text?.trim())
+ return hasA && hasB
+}
+
+export function displayLeaguePairName(pair: LeaguePairRow): string {
+ const stored = pair.name?.trim() || ''
+ if (pair.name_is_custom && stored) return stored
+ const fromMembers = formatTeamNameFromPlayers(leaguePairMemberLabels(pair))
+ if (fromMembers) return fromMembers
+ return stored || 'Pareja'
+}
+
+export function leaguePairMemberLabels(pair: LeaguePairRow): string[] {
+ const members: string[] = []
+ if (pair.player_a_user_id) {
+ members.push(pair.player_a_display_name ?? 'Jugador registrado')
+ } else if (pair.player_a_text) {
+ members.push(pair.player_a_text)
+ }
+ if (pair.player_b_user_id) {
+ members.push(pair.player_b_display_name ?? 'Jugador registrado')
+ } else if (pair.player_b_text) {
+ members.push(pair.player_b_text)
+ }
+ return members
+}
+
+export function leaguePairHasOpenSlot(pair: LeaguePairRow): 'a' | 'b' | null {
+ const aFree = !pair.player_a_user_id && !pair.player_a_text
+ const bFree = !pair.player_b_user_id && !pair.player_b_text
+ if (aFree) return 'a'
+ if (bFree) return 'b'
+ return null
+}
+
+export function findUserLeaguePairId(pairs: LeaguePairRow[], userId: string): string | null {
+ for (const pair of pairs) {
+ if (pair.player_a_user_id === userId || pair.player_b_user_id === userId) {
+ return pair.id
+ }
+ }
+ return null
+}
+
+export function userIsInLeaguePair(pairs: LeaguePairRow[], userId: string): boolean {
+ return findUserLeaguePairId(pairs, userId) !== null
+}
+
+export function userIsLeaguePairMember(pair: LeaguePairRow, userId: string | undefined): boolean {
+ if (!userId) return false
+ return pair.player_a_user_id === userId || pair.player_b_user_id === userId
+}
+
+export function canEditLeaguePair(
+ pair: LeaguePairRow,
+ userId: string | undefined,
+ isCreator: boolean,
+ leagueStatus: string
+): boolean {
+ if (!userId) return false
+ if (leagueStatus !== LEAGUE_STATUS.REGISTRATION && leagueStatus !== LEAGUE_STATUS.IN_PROGRESS) {
+ return false
+ }
+ return isCreator || userIsLeaguePairMember(pair, userId)
+}
+
+export function canJoinLeaguePair(
+ pair: LeaguePairRow,
+ userId: string | undefined,
+ pairs: LeaguePairRow[],
+ leagueStatus: string
+): { canJoin: boolean; openSlot: 'a' | 'b' | null } {
+ const accepting =
+ leagueStatus === LEAGUE_STATUS.REGISTRATION || leagueStatus === LEAGUE_STATUS.IN_PROGRESS
+ const openSlot = accepting ? leaguePairHasOpenSlot(pair) : null
+ if (!userId || !openSlot) return { canJoin: false, openSlot }
+ if (pair.player_a_user_id === userId || pair.player_b_user_id === userId) {
+ return { canJoin: false, openSlot }
+ }
+ const userPairId = findUserLeaguePairId(pairs, userId)
+ if (userPairId !== null && userPairId !== pair.id) {
+ return { canJoin: false, openSlot }
+ }
+ return { canJoin: true, openSlot }
+}
+
+function mapLeagueRpcError(message: string): string {
+ if (message.includes('not_authenticated')) return 'Debes iniciar sesión'
+ if (message.includes('end_at_required_for_open_elo')) {
+ return 'La liga abierta requiere fecha de fin'
+ }
+ if (message.includes('end_at_must_be_after_start_at')) {
+ return 'La fecha de fin debe ser posterior al inicio'
+ }
+ if (message.includes('need_at_least_two_complete_pairs')) {
+ return 'Se necesitan al menos 2 parejas completas para iniciar la liga'
+ }
+ if (message.includes('fixtures_already_generated')) {
+ return 'Los enfrentamientos ya están generados'
+ }
+ if (message.includes('fixtures_only_for_round_robin')) {
+ return 'Solo las ligas de ida o ida y vuelta generan calendario'
+ }
+ if (message.includes('league_ended')) return 'La liga ha finalizado'
+ if (message.includes('league_not_in_progress')) return 'La liga no está en curso'
+ if (message.includes('league_not_accepting_pairs')) {
+ return 'La liga ya no acepta parejas'
+ }
+ if (message.includes('already_in_pair')) {
+ return 'Ya estás inscrito en otra pareja de esta liga'
+ }
+ if (message.includes('slot_taken')) return 'Esa plaza ya está ocupada'
+ if (message.includes('challenge_already_pending')) {
+ return 'Ya hay un desafío pendiente entre estas parejas'
+ }
+ if (message.includes('cannot_challenge_self')) return 'No puedes desafiar a tu propia pareja'
+ if (message.includes('not_in_league_pair')) {
+ return 'Debes pertenecer a una pareja de la liga para desafiar'
+ }
+ if (message.includes('forbidden')) return 'No tienes permiso para esta acción'
+ if (message.includes('wrong_password')) return 'Contraseña incorrecta'
+ if (message.includes('password_empty')) return 'La contraseña no puede estar vacía'
+ if (message.includes('league_not_cancellable')) return 'Esta liga ya no se puede cancelar'
+ if (message.includes('cannot_remove_pair_after_start')) {
+ return 'No se pueden eliminar parejas una vez iniciada la liga'
+ }
+ if (message.includes('cannot_clear_text_player')) {
+ return 'No puedes quitar jugadores de la pareja; solo editar el nombre'
+ }
+ if (message.includes('pair_not_found')) return 'La pareja ya no existe'
+ if (message.includes('league_not_found')) return 'Liga no encontrada'
+ return message
+}
diff --git a/src/services/matches.service.ts b/src/services/matches.service.ts
index 44825cb..ada71a5 100644
--- a/src/services/matches.service.ts
+++ b/src/services/matches.service.ts
@@ -16,6 +16,7 @@ import {
getUserTournamentsDashboard,
type UserTournamentSummary,
} from '@/services/tournaments.service'
+import { getUserLeaguesDashboard, type UserLeagueSummary } from '@/services/leagues.service'
import { resolveMatchOutcome, type MatchOutcome } from '@/utils/matchDisplay'
/** `timestamptz` must receive an explicit instant; bare local strings are parsed as UTC on Supabase. */
@@ -72,6 +73,11 @@ export type MatchRow = {
tournament_pair_b_id: string | null
tournament_winner_pair_id: string | null
tournament_is_bye: boolean
+ league_id: string | null
+ league_pair_a_id: string | null
+ league_pair_b_id: string | null
+ league_round_number: number | null
+ league_is_second_leg: boolean
created_at: string
updated_at: string
}
@@ -511,7 +517,7 @@ export async function getMatch(id: string): Promise {
`*,
participants:match_participants(
id, match_id, user_id, team, state, joined_at, left_at,
- profile:profiles(id, display_name, photo_url, city)
+ profile:profiles(id, display_name, photo_url, city, phone_e164)
)`
)
.eq('id', id)
@@ -1107,6 +1113,9 @@ export type MyMatchesDashboard = {
/** Torneos donde el usuario organiza o participa (inscripción / en curso). */
tournamentsUpcoming: UserTournamentSummary[]
tournamentsInProgress: UserTournamentSummary[]
+ /** Ligas donde el usuario organiza o participa (inscripción / en curso). */
+ leaguesUpcoming: UserLeagueSummary[]
+ leaguesInProgress: UserLeagueSummary[]
}
/**
@@ -1189,10 +1198,11 @@ async function listAwaitingResultValidationClientFallback(
* and matches where the user must approve or dispute a submitted result.
*/
export async function getMyMatchesDashboard(userId: string): Promise {
- const [awaitingRes, matchSummaries, tournaments] = await Promise.all([
+ const [awaitingRes, matchSummaries, tournaments, leagues] = await Promise.all([
supabase.rpc('list_matches_awaiting_my_result_action'),
listUserMatchSummariesForDashboard(userId),
getUserTournamentsDashboard(userId),
+ getUserLeaguesDashboard(userId),
])
let awaitingResultValidation: AwaitingResultMatchRow[]
@@ -1229,6 +1239,8 @@ export async function getMyMatchesDashboard(userId: string): Promise {
const { data, error } = await supabase
.from('profiles')
.select(
- 'id, display_name, city, photo_url, role, status, notify_push, notify_on_join, notify_on_match_start, notify_on_match_edit, notify_on_match_cancel, notify_on_result, notify_on_reminder_24h, notify_on_reminder_2h, notify_on_reminder_in_progress, created_at, updated_at'
+ 'id, display_name, city, photo_url, badge_showcase, role, status, notify_push, notify_on_join, notify_on_match_start, notify_on_match_edit, notify_on_match_cancel, notify_on_result, notify_on_reminder_24h, notify_on_reminder_2h, notify_on_reminder_in_progress, created_at, updated_at'
)
.eq('id', userId)
.single()
diff --git a/src/services/stats.service.ts b/src/services/stats.service.ts
index 893e02e..16ead34 100644
--- a/src/services/stats.service.ts
+++ b/src/services/stats.service.ts
@@ -127,6 +127,16 @@ export type LeaderboardEntry = {
win_rate: number
}
+export type PlayerRanking = {
+ user_id: string
+ city: string | null
+ elo_rating: number
+ global_rank: number | null
+ city_rank: number | null
+ global_total: number
+ city_total: number | null
+}
+
function asForm(value: unknown): FormOutcome[] {
if (!Array.isArray(value)) return []
return value.filter((v): v is FormOutcome => v === 'won' || v === 'lost')
@@ -369,6 +379,29 @@ export async function getLeaderboard(
return parseLeaderboard(data)
}
+function parsePlayerRanking(raw: unknown): PlayerRanking | null {
+ if (!raw || typeof raw !== 'object') return null
+ const row = raw as Record
+ if (typeof row.user_id !== 'string') return null
+ return {
+ user_id: row.user_id,
+ city: typeof row.city === 'string' && row.city.trim() ? row.city.trim() : null,
+ elo_rating: Number(row.elo_rating ?? 1200),
+ global_rank: row.global_rank == null ? null : Number(row.global_rank),
+ city_rank: row.city_rank == null ? null : Number(row.city_rank),
+ global_total: Number(row.global_total ?? 0),
+ city_total: row.city_total == null ? null : Number(row.city_total),
+ }
+}
+
+export async function getPlayerRanking(userId: string): Promise {
+ const { data, error } = await supabase.rpc('get_player_ranking', { p_user_id: userId })
+ if (error) throw new Error(error.message)
+ const parsed = parsePlayerRanking(data)
+ if (!parsed) throw new Error('No se pudo cargar el ranking')
+ return parsed
+}
+
export const BADGE_LABELS: Record = {
first_win: 'Primera victoria',
wins_10: '10 victorias',
@@ -377,14 +410,64 @@ export const BADGE_LABELS: Record = {
wins_100: '100 victorias',
tournament_winner: 'Campeón de torneo',
tournament_finalist: 'Finalista',
+ league_winner: 'Campeón de liga',
+ league_runner_up: 'Subcampeón de liga',
+ league_podium: 'Podio de liga',
+ league_regular: 'Habitual de ligas',
streak_5: 'Racha de 5',
streak_10: 'Racha de 10',
+ streak_breaker: 'Romperracha',
+ double_champion: 'Doblete',
veteran_50: 'Veterano (50)',
veteran_100: 'Veterano (100)',
explorer_5: 'Explorador',
+ explorer_20: 'La vuelta al mundo',
nemesis_confirmed: 'Cazador de rivales',
+ crown_5: 'Pentacampeón',
+ crown_10: 'Leyenda (10 títulos)',
+ league_crown_3: 'Rey de ligas',
+ rivalry_10: 'Rival de manual',
}
+export const BADGE_CATALOG: ReadonlyArray<{
+ key: keyof typeof BADGE_LABELS
+ emoji: string
+ hint: string
+}> = [
+ { key: 'first_win', emoji: '🎉', hint: 'Gana tu primera partida' },
+ { key: 'wins_10', emoji: '🔟', hint: 'Acumula 10 victorias' },
+ { key: 'wins_25', emoji: '💪', hint: 'Acumula 25 victorias' },
+ { key: 'wins_50', emoji: '🔥', hint: 'Acumula 50 victorias' },
+ { key: 'wins_100', emoji: '💯', hint: 'Acumula 100 victorias' },
+ { key: 'tournament_winner', emoji: '🏆', hint: 'Gana un torneo' },
+ { key: 'tournament_finalist', emoji: '🥈', hint: 'Juega una final de torneo' },
+ { key: 'league_winner', emoji: '🥇', hint: 'Queda 1º en una liga' },
+ { key: 'league_runner_up', emoji: '🎗️', hint: 'Queda 2º en una liga' },
+ { key: 'league_podium', emoji: '🏅', hint: 'Termina entre los 3 primeros de una liga' },
+ { key: 'league_regular', emoji: '📅', hint: 'Completa 3 ligas' },
+ { key: 'streak_5', emoji: '⚡', hint: 'Encadena 5 victorias' },
+ { key: 'streak_10', emoji: '🚀', hint: 'Encadena 10 victorias' },
+ {
+ key: 'streak_breaker',
+ emoji: '🪓',
+ hint: 'Derrota a un rival con 9 victorias seguidas (le impides la Racha de 10)',
+ },
+ {
+ key: 'double_champion',
+ emoji: '💎',
+ hint: 'Sé campeón de un torneo y de una liga',
+ },
+ { key: 'veteran_50', emoji: '🛡️', hint: 'Juega 50 partidas' },
+ { key: 'veteran_100', emoji: '📜', hint: 'Juega 100 partidas' },
+ { key: 'explorer_5', emoji: '🗺️', hint: 'Juega en 5 sitios distintos' },
+ { key: 'explorer_20', emoji: '🌍', hint: 'Juega en 20 sitios distintos' },
+ { key: 'nemesis_confirmed', emoji: '🎯', hint: 'Gana 5 veces al mismo rival' },
+ { key: 'rivalry_10', emoji: '🤜', hint: 'Gana 10 veces al mismo rival' },
+ { key: 'crown_5', emoji: '🏰', hint: 'Gana 5 torneos' },
+ { key: 'crown_10', emoji: '🌟', hint: 'Gana 10 torneos' },
+ { key: 'league_crown_3', emoji: '🔱', hint: 'Gana 3 ligas' },
+]
+
export function formatStreak(streak: number): string {
if (streak === 0) return '—'
if (streak > 0) return `${streak}V`
diff --git a/src/types/database.types.ts b/src/types/database.types.ts
index f4c78c9..6c087b4 100644
--- a/src/types/database.types.ts
+++ b/src/types/database.types.ts
@@ -242,6 +242,11 @@ export type Database = {
team_b_player_1: string | null
team_b_player_2: string | null
title: string
+ league_id: string | null
+ league_is_second_leg: boolean
+ league_pair_a_id: string | null
+ league_pair_b_id: string | null
+ league_round_number: number | null
tournament_bracket_position: number | null
tournament_id: string | null
tournament_is_bye: boolean
@@ -260,6 +265,11 @@ export type Database = {
description?: string | null
duration_target_games: number
id?: string
+ league_id?: string | null
+ league_is_second_leg?: boolean
+ league_pair_a_id?: string | null
+ league_pair_b_id?: string | null
+ league_round_number?: number | null
location_privacy?: string
password_hash?: string | null
place_defined?: boolean
@@ -291,6 +301,11 @@ export type Database = {
description?: string | null
duration_target_games?: number
id?: string
+ league_id?: string | null
+ league_is_second_leg?: boolean
+ league_pair_a_id?: string | null
+ league_pair_b_id?: string | null
+ league_round_number?: number | null
location_privacy?: string
password_hash?: string | null
place_defined?: boolean
@@ -330,6 +345,27 @@ export type Database = {
referencedRelation: 'profiles_public'
referencedColumns: ['id']
},
+ {
+ foreignKeyName: 'matches_league_id_fkey'
+ columns: ['league_id']
+ isOneToOne: false
+ referencedRelation: 'leagues'
+ referencedColumns: ['id']
+ },
+ {
+ foreignKeyName: 'matches_league_pair_a_id_fkey'
+ columns: ['league_pair_a_id']
+ isOneToOne: false
+ referencedRelation: 'league_pairs'
+ referencedColumns: ['id']
+ },
+ {
+ foreignKeyName: 'matches_league_pair_b_id_fkey'
+ columns: ['league_pair_b_id']
+ isOneToOne: false
+ referencedRelation: 'league_pairs'
+ referencedColumns: ['id']
+ },
{
foreignKeyName: 'matches_tournament_id_fkey'
columns: ['tournament_id']
@@ -485,6 +521,7 @@ export type Database = {
}
profiles: {
Row: {
+ badge_showcase: string[]
city: string | null
created_at: string
display_name: string
@@ -506,6 +543,7 @@ export type Database = {
updated_at: string
}
Insert: {
+ badge_showcase?: string[]
city?: string | null
created_at?: string
display_name: string
@@ -527,6 +565,7 @@ export type Database = {
updated_at?: string
}
Update: {
+ badge_showcase?: string[]
city?: string | null
created_at?: string
display_name?: string
@@ -711,6 +750,281 @@ export type Database = {
},
]
}
+ league_challenges: {
+ Row: {
+ challenged_pair_id: string
+ challenger_pair_id: string
+ created_at: string
+ created_by_user_id: string
+ id: string
+ league_id: string
+ match_id: string | null
+ responded_at: string | null
+ status: string
+ }
+ Insert: {
+ challenged_pair_id: string
+ challenger_pair_id: string
+ created_at?: string
+ created_by_user_id: string
+ id?: string
+ league_id: string
+ match_id?: string | null
+ responded_at?: string | null
+ status?: string
+ }
+ Update: {
+ challenged_pair_id?: string
+ challenger_pair_id?: string
+ created_at?: string
+ created_by_user_id?: string
+ id?: string
+ league_id?: string
+ match_id?: string | null
+ responded_at?: string | null
+ status?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: 'league_challenges_league_id_fkey'
+ columns: ['league_id']
+ isOneToOne: false
+ referencedRelation: 'leagues'
+ referencedColumns: ['id']
+ },
+ ]
+ }
+ league_pairs: {
+ Row: {
+ created_at: string
+ created_by_user_id: string
+ current_elo: number
+ id: string
+ joined_at: string
+ league_id: string
+ name: string
+ name_is_custom: boolean
+ player_a_text: string | null
+ player_a_user_id: string | null
+ player_b_text: string | null
+ player_b_user_id: string | null
+ updated_at: string
+ }
+ Insert: {
+ created_at?: string
+ created_by_user_id: string
+ current_elo?: number
+ id?: string
+ joined_at?: string
+ league_id: string
+ name: string
+ name_is_custom?: boolean
+ player_a_text?: string | null
+ player_a_user_id?: string | null
+ player_b_text?: string | null
+ player_b_user_id?: string | null
+ updated_at?: string
+ }
+ Update: {
+ created_at?: string
+ created_by_user_id?: string
+ current_elo?: number
+ id?: string
+ joined_at?: string
+ league_id?: string
+ name?: string
+ name_is_custom?: boolean
+ player_a_text?: string | null
+ player_a_user_id?: string | null
+ player_b_text?: string | null
+ player_b_user_id?: string | null
+ updated_at?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: 'league_pairs_created_by_user_id_fkey'
+ columns: ['created_by_user_id']
+ isOneToOne: false
+ referencedRelation: 'profiles'
+ referencedColumns: ['id']
+ },
+ {
+ foreignKeyName: 'league_pairs_league_id_fkey'
+ columns: ['league_id']
+ isOneToOne: false
+ referencedRelation: 'leagues'
+ referencedColumns: ['id']
+ },
+ {
+ foreignKeyName: 'league_pairs_player_a_user_id_fkey'
+ columns: ['player_a_user_id']
+ isOneToOne: false
+ referencedRelation: 'profiles'
+ referencedColumns: ['id']
+ },
+ {
+ foreignKeyName: 'league_pairs_player_b_user_id_fkey'
+ columns: ['player_b_user_id']
+ isOneToOne: false
+ referencedRelation: 'profiles'
+ referencedColumns: ['id']
+ },
+ ]
+ }
+ league_password_grants: {
+ Row: {
+ granted_at: string
+ league_id: string
+ user_id: string
+ }
+ Insert: {
+ granted_at?: string
+ league_id: string
+ user_id: string
+ }
+ Update: {
+ granted_at?: string
+ league_id?: string
+ user_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: 'league_password_grants_league_id_fkey'
+ columns: ['league_id']
+ isOneToOne: false
+ referencedRelation: 'leagues'
+ referencedColumns: ['id']
+ },
+ ]
+ }
+ league_rating_history: {
+ Row: {
+ created_at: string
+ elo_after: number
+ elo_before: number
+ elo_delta: number
+ id: string
+ league_id: string
+ match_id: string
+ pair_id: string
+ }
+ Insert: {
+ created_at?: string
+ elo_after: number
+ elo_before: number
+ elo_delta: number
+ id?: string
+ league_id: string
+ match_id: string
+ pair_id: string
+ }
+ Update: {
+ created_at?: string
+ elo_after?: number
+ elo_before?: number
+ elo_delta?: number
+ id?: string
+ league_id?: string
+ match_id?: string
+ pair_id?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: 'league_rating_history_league_id_fkey'
+ columns: ['league_id']
+ isOneToOne: false
+ referencedRelation: 'leagues'
+ referencedColumns: ['id']
+ },
+ {
+ foreignKeyName: 'league_rating_history_pair_id_fkey'
+ columns: ['pair_id']
+ isOneToOne: false
+ referencedRelation: 'league_pairs'
+ referencedColumns: ['id']
+ },
+ ]
+ }
+ leagues: {
+ Row: {
+ city: string
+ created_at: string
+ creator_id: string
+ description: string | null
+ duration_target_games: number
+ elo_initial: number
+ elo_k_factor: number
+ end_at: string | null
+ fixtures_generated_at: string | null
+ format: string
+ id: string
+ location_privacy: string
+ notes: string | null
+ password_hash: string | null
+ place_defined: boolean
+ place_text: string | null
+ start_at: string
+ status: string
+ title: string
+ updated_at: string
+ visibility: string
+ }
+ Insert: {
+ city: string
+ created_at?: string
+ creator_id: string
+ description?: string | null
+ duration_target_games: number
+ elo_initial?: number
+ elo_k_factor?: number
+ end_at?: string | null
+ fixtures_generated_at?: string | null
+ format: string
+ id?: string
+ location_privacy?: string
+ notes?: string | null
+ password_hash?: string | null
+ place_defined?: boolean
+ place_text?: string | null
+ start_at: string
+ status?: string
+ title: string
+ updated_at?: string
+ visibility?: string
+ }
+ Update: {
+ city?: string
+ created_at?: string
+ creator_id?: string
+ description?: string | null
+ duration_target_games?: number
+ elo_initial?: number
+ elo_k_factor?: number
+ end_at?: string | null
+ fixtures_generated_at?: string | null
+ format?: string
+ id?: string
+ location_privacy?: string
+ notes?: string | null
+ password_hash?: string | null
+ place_defined?: boolean
+ place_text?: string | null
+ start_at?: string
+ status?: string
+ title?: string
+ updated_at?: string
+ visibility?: string
+ }
+ Relationships: [
+ {
+ foreignKeyName: 'leagues_creator_id_fkey'
+ columns: ['creator_id']
+ isOneToOne: false
+ referencedRelation: 'profiles'
+ referencedColumns: ['id']
+ },
+ ]
+ }
tournament_pairs: {
Row: {
created_at: string
@@ -916,6 +1230,57 @@ export type Database = {
}
}
Functions: {
+ accept_league_challenge: {
+ Args: { p_challenge_id: string }
+ Returns: {
+ challenged_pair_id: string
+ challenger_pair_id: string
+ created_at: string
+ created_by_user_id: string
+ id: string
+ league_id: string
+ match_id: string | null
+ responded_at: string | null
+ status: string
+ }
+ SetofOptions: {
+ from: '*'
+ to: 'league_challenges'
+ isOneToOne: true
+ isSetofReturn: false
+ }
+ }
+ add_league_pair: {
+ Args: {
+ p_league_id: string
+ p_name?: string
+ p_player_a_text?: string
+ p_player_a_user_id?: string
+ p_player_b_text?: string
+ p_player_b_user_id?: string
+ }
+ Returns: {
+ created_at: string
+ created_by_user_id: string
+ current_elo: number
+ id: string
+ joined_at: string
+ league_id: string
+ name: string
+ name_is_custom: boolean
+ player_a_text: string | null
+ player_a_user_id: string | null
+ player_b_text: string | null
+ player_b_user_id: string | null
+ updated_at: string
+ }
+ SetofOptions: {
+ from: '*'
+ to: 'league_pairs'
+ isOneToOne: true
+ isSetofReturn: false
+ }
+ }
add_tournament_pair: {
Args: {
p_entry_fee_paid?: boolean
@@ -985,6 +1350,7 @@ export type Database = {
Args: { p_match_id: string }
Returns: undefined
}
+ auth_can_read_league: { Args: { p_league_id: string }; Returns: boolean }
auth_can_read_match: { Args: { p_match_id: string }; Returns: boolean }
auth_can_read_tournament: {
Args: { p_tournament_id: string }
@@ -995,6 +1361,38 @@ export type Database = {
Args: { p_match_id: string }
Returns: boolean
}
+ cancel_league: {
+ Args: { p_league_id: string }
+ Returns: {
+ city: string
+ created_at: string
+ creator_id: string
+ description: string | null
+ duration_target_games: number
+ elo_initial: number
+ elo_k_factor: number
+ end_at: string | null
+ fixtures_generated_at: string | null
+ format: string
+ id: string
+ location_privacy: string
+ notes: string | null
+ password_hash: string | null
+ place_defined: boolean
+ place_text: string | null
+ start_at: string
+ status: string
+ title: string
+ updated_at: string
+ visibility: string
+ }
+ SetofOptions: {
+ from: '*'
+ to: 'leagues'
+ isOneToOne: true
+ isSetofReturn: false
+ }
+ }
cancel_tournament: {
Args: { p_tournament_id: string }
Returns: {
@@ -1026,6 +1424,202 @@ export type Database = {
isSetofReturn: false
}
}
+ create_league: {
+ Args: {
+ p_city: string
+ p_description?: string
+ p_duration_target_games: number
+ p_elo_initial?: number
+ p_elo_k_factor?: number
+ p_end_at?: string
+ p_format: string
+ p_location_privacy?: string
+ p_notes?: string
+ p_place_defined?: boolean
+ p_place_text?: string
+ p_start_at: string
+ p_title: string
+ p_visibility?: string
+ }
+ Returns: {
+ city: string
+ created_at: string
+ creator_id: string
+ description: string | null
+ duration_target_games: number
+ elo_initial: number
+ elo_k_factor: number
+ end_at: string | null
+ fixtures_generated_at: string | null
+ format: string
+ id: string
+ location_privacy: string
+ notes: string | null
+ password_hash: string | null
+ place_defined: boolean
+ place_text: string | null
+ start_at: string
+ status: string
+ title: string
+ updated_at: string
+ visibility: string
+ }
+ SetofOptions: {
+ from: '*'
+ to: 'leagues'
+ isOneToOne: true
+ isSetofReturn: false
+ }
+ }
+ create_league_challenge: {
+ Args: { p_challenged_pair_id: string; p_league_id: string }
+ Returns: {
+ challenged_pair_id: string
+ challenger_pair_id: string
+ created_at: string
+ created_by_user_id: string
+ id: string
+ league_id: string
+ match_id: string | null
+ responded_at: string | null
+ status: string
+ }
+ SetofOptions: {
+ from: '*'
+ to: 'league_challenges'
+ isOneToOne: true
+ isSetofReturn: false
+ }
+ }
+ generate_league_fixtures: {
+ Args: { p_league_id: string }
+ Returns: undefined
+ }
+ grant_league_password_access: {
+ Args: { p_league_id: string; p_password: string }
+ Returns: undefined
+ }
+ join_league_pair: {
+ Args: { p_as_text?: string; p_pair_id: string; p_slot: string }
+ Returns: {
+ created_at: string
+ created_by_user_id: string
+ current_elo: number
+ id: string
+ joined_at: string
+ league_id: string
+ name: string
+ name_is_custom: boolean
+ player_a_text: string | null
+ player_a_user_id: string | null
+ player_b_text: string | null
+ player_b_user_id: string | null
+ updated_at: string
+ }
+ SetofOptions: {
+ from: '*'
+ to: 'league_pairs'
+ isOneToOne: true
+ isSetofReturn: false
+ }
+ }
+ list_league_matches: {
+ Args: { p_league_id: string }
+ Returns: {
+ is_second_leg: boolean
+ match_id: string
+ pair_a_id: string | null
+ pair_a_name: string | null
+ pair_b_id: string | null
+ pair_b_name: string | null
+ round_number: number | null
+ start_at: string
+ status: string
+ team_a_games: number | null
+ team_b_games: number | null
+ title: string
+ }[]
+ }
+ list_league_standings: {
+ Args: { p_league_id: string }
+ Returns: {
+ current_elo: number
+ games_against: number
+ games_diff: number
+ games_for: number
+ h2h_wins: number
+ losses: number
+ pair_id: string
+ pair_name: string
+ played: number
+ rank: number
+ wins: number
+ }[]
+ }
+ process_league_lifecycle: { Args: never; Returns: undefined }
+ record_league_match_result_as_referee: {
+ Args: { p_match_id: string; p_team_a_games: number; p_team_b_games: number }
+ Returns: undefined
+ }
+ reject_league_challenge: {
+ Args: { p_challenge_id: string }
+ Returns: {
+ challenged_pair_id: string
+ challenger_pair_id: string
+ created_at: string
+ created_by_user_id: string
+ id: string
+ league_id: string
+ match_id: string | null
+ responded_at: string | null
+ status: string
+ }
+ SetofOptions: {
+ from: '*'
+ to: 'league_challenges'
+ isOneToOne: true
+ isSetofReturn: false
+ }
+ }
+ remove_league_pair: { Args: { p_pair_id: string }; Returns: undefined }
+ set_league_password: {
+ Args: { p_league_id: string; p_password: string }
+ Returns: undefined
+ }
+ start_open_league: { Args: { p_league_id: string }; Returns: undefined }
+ update_league_pair: {
+ Args: {
+ p_name?: string
+ p_pair_id: string
+ p_player_a_text?: string
+ p_player_b_text?: string
+ }
+ Returns: {
+ created_at: string
+ created_by_user_id: string
+ current_elo: number
+ id: string
+ joined_at: string
+ league_id: string
+ name: string
+ name_is_custom: boolean
+ player_a_text: string | null
+ player_a_user_id: string | null
+ player_b_text: string | null
+ player_b_user_id: string | null
+ updated_at: string
+ }
+ SetofOptions: {
+ from: '*'
+ to: 'league_pairs'
+ isOneToOne: true
+ isSetofReturn: false
+ }
+ }
+ viewer_can_access_league: {
+ Args: { p_league_id: string }
+ Returns: boolean
+ }
create_tournament: {
Args: {
p_city: string
@@ -1098,6 +1692,7 @@ export type Database = {
get_own_profile: {
Args: never
Returns: {
+ badge_showcase: string[]
city: string | null
created_at: string
display_name: string
@@ -1159,6 +1754,10 @@ export type Database = {
Args: { p_city?: string; p_limit?: number }
Returns: Json
}
+ get_player_ranking: {
+ Args: { p_user_id: string }
+ Returns: Json
+ }
get_match_player_insights: {
Args: { p_match_id: string; p_viewer_id?: string }
Returns: Json
@@ -1183,6 +1782,8 @@ export type Database = {
display_name: string
id: string
phone_e164: string
+ photo_url: string
+ badge_showcase: string[]
}[]
}
list_user_viewable_matches: {
diff --git a/src/utils/elo.test.ts b/src/utils/elo.test.ts
new file mode 100644
index 0000000..aade408
--- /dev/null
+++ b/src/utils/elo.test.ts
@@ -0,0 +1,25 @@
+import { computeEloUpdate, eloDelta, eloExpected } from '@/utils/elo'
+
+describe('elo', () => {
+ it('expected score is 0.5 for equal ratings', () => {
+ expect(eloExpected(1000, 1000)).toBeCloseTo(0.5)
+ })
+
+ it('favorite has expected > 0.5', () => {
+ expect(eloExpected(1200, 1000)).toBeGreaterThan(0.5)
+ })
+
+ it('upset win gives larger delta than expected win', () => {
+ const upset = eloDelta(1000, 1200, 1, 32)
+ const expectedWin = eloDelta(1200, 1000, 1, 32)
+ expect(upset).toBeGreaterThan(expectedWin)
+ })
+
+ it('computeEloUpdate keeps zero-sum deltas approximately', () => {
+ const update = computeEloUpdate(1000, 1000, true, 32)
+ expect(update.deltaA).toBe(16)
+ expect(update.deltaB).toBe(-16)
+ expect(update.ratingAAfter).toBe(1016)
+ expect(update.ratingBAfter).toBe(984)
+ })
+})
diff --git a/src/utils/elo.ts b/src/utils/elo.ts
new file mode 100644
index 0000000..72b6659
--- /dev/null
+++ b/src/utils/elo.ts
@@ -0,0 +1,46 @@
+/** Standard Elo expected score for rating A vs B. */
+export function eloExpected(ratingA: number, ratingB: number): number {
+ return 1 / (1 + 10 ** ((ratingB - ratingA) / 400))
+}
+
+/**
+ * Elo delta for player A after a match.
+ * @param scoreA 1 = win, 0 = loss (draws not used in mus)
+ */
+export function eloDelta(
+ ratingA: number,
+ ratingB: number,
+ scoreA: 0 | 1,
+ kFactor: number
+): number {
+ const expected = eloExpected(ratingA, ratingB)
+ return Math.round(kFactor * (scoreA - expected))
+}
+
+export type EloUpdate = {
+ ratingABefore: number
+ ratingBBefore: number
+ deltaA: number
+ deltaB: number
+ ratingAAfter: number
+ ratingBAfter: number
+}
+
+export function computeEloUpdate(
+ ratingA: number,
+ ratingB: number,
+ aWon: boolean,
+ kFactor: number
+): EloUpdate {
+ const scoreA: 0 | 1 = aWon ? 1 : 0
+ const deltaA = eloDelta(ratingA, ratingB, scoreA, kFactor)
+ const deltaB = eloDelta(ratingB, ratingA, aWon ? 0 : 1, kFactor)
+ return {
+ ratingABefore: ratingA,
+ ratingBBefore: ratingB,
+ deltaA,
+ deltaB,
+ ratingAAfter: ratingA + deltaA,
+ ratingBAfter: ratingB + deltaB,
+ }
+}
diff --git a/src/utils/exploreFilters.ts b/src/utils/exploreFilters.ts
index 55294ec..742c911 100644
--- a/src/utils/exploreFilters.ts
+++ b/src/utils/exploreFilters.ts
@@ -1,10 +1,12 @@
-import { MATCH_STATUS, TOURNAMENT_STATUS } from '@/constants'
+import { LEAGUE_STATUS, MATCH_STATUS, TOURNAMENT_STATUS } from '@/constants'
import type { PublicMatchExplorerRow } from '@/services/matches.service'
+import type { LeagueRow } from '@/services/leagues.service'
import type { TournamentRow } from '@/services/tournaments.service'
export type ExploreItem =
| { kind: 'match'; id: string; start_at: string; row: PublicMatchExplorerRow }
| { kind: 'tournament'; id: string; start_at: string; row: TournamentRow }
+ | { kind: 'league'; id: string; start_at: string; row: LeagueRow }
export function filterExploreItemsForCelebrated(
items: ExploreItem[],
@@ -17,6 +19,9 @@ export function filterExploreItemsForCelebrated(
const status = item.row.status
return status !== MATCH_STATUS.FINISHED && status !== MATCH_STATUS.FINISHED_NO_RESULT
}
- return item.row.status !== TOURNAMENT_STATUS.FINISHED
+ if (item.kind === 'tournament') {
+ return item.row.status !== TOURNAMENT_STATUS.FINISHED
+ }
+ return item.row.status !== LEAGUE_STATUS.FINISHED
})
}
diff --git a/src/utils/leagueDisplay.test.ts b/src/utils/leagueDisplay.test.ts
new file mode 100644
index 0000000..2e4818b
--- /dev/null
+++ b/src/utils/leagueDisplay.test.ts
@@ -0,0 +1,28 @@
+import { LEAGUE_FORMAT, LEAGUE_STATUS } from '@/constants'
+import {
+ isOpenEloFormat,
+ isRoundRobinFormat,
+ leagueFormatDisplay,
+ leagueStatusDisplay,
+} from '@/utils/leagueDisplay'
+
+describe('leagueDisplay', () => {
+ it('maps status labels', () => {
+ expect(leagueStatusDisplay({ status: LEAGUE_STATUS.REGISTRATION }).text).toBe('Inscripción')
+ expect(leagueStatusDisplay({ status: LEAGUE_STATUS.IN_PROGRESS }).text).toBe('En curso')
+ expect(leagueStatusDisplay({ status: LEAGUE_STATUS.FINISHED }).text).toBe('Finalizada')
+ expect(leagueStatusDisplay({ status: LEAGUE_STATUS.CANCELLED }).text).toBe('Cancelada')
+ })
+
+ it('maps format labels', () => {
+ expect(leagueFormatDisplay(LEAGUE_FORMAT.SINGLE_ROUND)).toBe('Solo ida')
+ expect(leagueFormatDisplay(LEAGUE_FORMAT.DOUBLE_ROUND)).toBe('Ida y vuelta')
+ expect(leagueFormatDisplay(LEAGUE_FORMAT.OPEN_ELO)).toBe('Liga abierta')
+ })
+
+ it('detects format kinds', () => {
+ expect(isRoundRobinFormat(LEAGUE_FORMAT.SINGLE_ROUND)).toBe(true)
+ expect(isRoundRobinFormat(LEAGUE_FORMAT.OPEN_ELO)).toBe(false)
+ expect(isOpenEloFormat(LEAGUE_FORMAT.OPEN_ELO)).toBe(true)
+ })
+})
diff --git a/src/utils/leagueDisplay.ts b/src/utils/leagueDisplay.ts
new file mode 100644
index 0000000..3a6cb57
--- /dev/null
+++ b/src/utils/leagueDisplay.ts
@@ -0,0 +1,43 @@
+import { LEAGUE_FORMAT, LEAGUE_FORMAT_LABELS, LEAGUE_STATUS, type LeagueFormat } from '@/constants'
+import { Colors } from '@/theme/colors'
+
+export function leagueStatusDisplay(league: {
+ status: string
+ fixtures_generated_at?: string | null
+}): {
+ text: string
+ color: string
+} {
+ switch (league.status) {
+ case LEAGUE_STATUS.REGISTRATION:
+ return {
+ // Compatibilidad: si `fixtures_generated_at` no viene (undefined),
+ // mantenemos el texto histórico "Inscripción".
+ text: league.fixtures_generated_at === null ? 'Inscripción abierta' : 'Inscripción',
+ color: Colors.primary,
+ }
+ case LEAGUE_STATUS.IN_PROGRESS:
+ return { text: 'En curso', color: Colors.warning }
+ case LEAGUE_STATUS.FINISHED:
+ return { text: 'Finalizada', color: Colors.textSecondary }
+ case LEAGUE_STATUS.CANCELLED:
+ return { text: 'Cancelada', color: Colors.danger }
+ default:
+ return { text: league.status, color: Colors.textSecondary }
+ }
+}
+
+export function leagueFormatDisplay(format: string): string {
+ if (Object.hasOwn(LEAGUE_FORMAT_LABELS, format)) {
+ return LEAGUE_FORMAT_LABELS[format as LeagueFormat]
+ }
+ return format
+}
+
+export function isRoundRobinFormat(format: string): boolean {
+ return format === LEAGUE_FORMAT.SINGLE_ROUND || format === LEAGUE_FORMAT.DOUBLE_ROUND
+}
+
+export function isOpenEloFormat(format: string): boolean {
+ return format === LEAGUE_FORMAT.OPEN_ELO
+}
diff --git a/src/utils/leagueForm.ts b/src/utils/leagueForm.ts
new file mode 100644
index 0000000..4186586
--- /dev/null
+++ b/src/utils/leagueForm.ts
@@ -0,0 +1,19 @@
+export const DEFAULT_LEAGUE_TITLE = 'Liga'
+export const DEFAULT_LEAGUE_CITY = 'Ciudad por definir'
+
+export const AUTO_START_LEAGUE_ALERT = {
+ title: 'Liga creada',
+ message:
+ 'Añade al menos 2 parejas completas y pulsa «Iniciar liga» cuando estés listo. En ida/ida y vuelta se generarán los enfrentamientos automáticamente.',
+} as const
+
+export function leaguePlacePayload(placeText: string | undefined | null): {
+ place_defined: boolean
+ place_text: string | null
+} {
+ const trimmed = placeText?.trim() ?? ''
+ if (!trimmed) {
+ return { place_defined: false, place_text: null }
+ }
+ return { place_defined: true, place_text: trimmed }
+}
diff --git a/src/utils/leagueStandings.test.ts b/src/utils/leagueStandings.test.ts
new file mode 100644
index 0000000..3dee8fe
--- /dev/null
+++ b/src/utils/leagueStandings.test.ts
@@ -0,0 +1,43 @@
+import { computeLeagueStandings } from '@/utils/leagueStandings'
+
+describe('computeLeagueStandings', () => {
+ const pairs = [
+ { id: 'a', name: 'Pareja A' },
+ { id: 'b', name: 'Pareja B' },
+ { id: 'c', name: 'Pareja C' },
+ ]
+
+ it('ranks by wins first', () => {
+ const rows = computeLeagueStandings(pairs, [
+ { pairAId: 'a', pairBId: 'b', teamAGames: 3, teamBGames: 1 },
+ { pairAId: 'a', pairBId: 'c', teamAGames: 3, teamBGames: 0 },
+ { pairAId: 'b', pairBId: 'c', teamAGames: 3, teamBGames: 2 },
+ ])
+ expect(rows.map((r) => r.pairId)).toEqual(['a', 'b', 'c'])
+ expect(rows[0].wins).toBe(2)
+ })
+
+ it('uses games difference when h2h equal in a cycle', () => {
+ const rows = computeLeagueStandings(pairs, [
+ { pairAId: 'a', pairBId: 'b', teamAGames: 3, teamBGames: 1 },
+ { pairAId: 'a', pairBId: 'c', teamAGames: 1, teamBGames: 3 },
+ { pairAId: 'b', pairBId: 'c', teamAGames: 3, teamBGames: 0 },
+ ])
+ expect(rows).toHaveLength(3)
+ expect(new Set(rows.map((r) => r.wins))).toEqual(new Set([1]))
+ })
+
+ it('uses games difference when h2h equal among tied', () => {
+ const two = [
+ { id: 'x', name: 'X' },
+ { id: 'y', name: 'Y' },
+ { id: 'z', name: 'Z' },
+ ]
+ const rows = computeLeagueStandings(two, [
+ { pairAId: 'x', pairBId: 'y', teamAGames: 3, teamBGames: 2 },
+ { pairAId: 'x', pairBId: 'z', teamAGames: 0, teamBGames: 3 },
+ { pairAId: 'y', pairBId: 'z', teamAGames: 3, teamBGames: 0 },
+ ])
+ expect(rows[0].pairId).toBe('y')
+ })
+})
diff --git a/src/utils/leagueStandings.ts b/src/utils/leagueStandings.ts
new file mode 100644
index 0000000..3934387
--- /dev/null
+++ b/src/utils/leagueStandings.ts
@@ -0,0 +1,126 @@
+export type StandingMatchResult = {
+ pairAId: string
+ pairBId: string
+ teamAGames: number
+ teamBGames: number
+}
+
+export type StandingPairInput = {
+ id: string
+ name: string
+ currentElo?: number
+}
+
+export type StandingRow = {
+ pairId: string
+ pairName: string
+ played: number
+ wins: number
+ losses: number
+ gamesFor: number
+ gamesAgainst: number
+ gamesDiff: number
+ h2hWins: number
+ currentElo: number
+ rank: number
+}
+
+/**
+ * Round-robin standings:
+ * wins → head-to-head wins among tied → games diff → games for → pair id.
+ */
+export function computeLeagueStandings(
+ pairs: StandingPairInput[],
+ results: StandingMatchResult[]
+): StandingRow[] {
+ const stats = new Map<
+ string,
+ {
+ name: string
+ currentElo: number
+ played: number
+ wins: number
+ losses: number
+ gamesFor: number
+ gamesAgainst: number
+ }
+ >()
+
+ for (const p of pairs) {
+ stats.set(p.id, {
+ name: p.name,
+ currentElo: p.currentElo ?? 1000,
+ played: 0,
+ wins: 0,
+ losses: 0,
+ gamesFor: 0,
+ gamesAgainst: 0,
+ })
+ }
+
+ for (const r of results) {
+ const a = stats.get(r.pairAId)
+ const b = stats.get(r.pairBId)
+ if (!a || !b) continue
+
+ a.played += 1
+ b.played += 1
+ a.gamesFor += r.teamAGames
+ a.gamesAgainst += r.teamBGames
+ b.gamesFor += r.teamBGames
+ b.gamesAgainst += r.teamAGames
+
+ if (r.teamAGames > r.teamBGames) {
+ a.wins += 1
+ b.losses += 1
+ } else {
+ b.wins += 1
+ a.losses += 1
+ }
+ }
+
+ const h2hWinsAmongTied = (pairId: string, wins: number): number => {
+ let count = 0
+ const tiedIds = pairs.filter((p) => (stats.get(p.id)?.wins ?? 0) === wins).map((p) => p.id)
+ for (const otherId of tiedIds) {
+ if (otherId === pairId) continue
+ for (const r of results) {
+ const involves =
+ (r.pairAId === pairId && r.pairBId === otherId) ||
+ (r.pairBId === pairId && r.pairAId === otherId)
+ if (!involves) continue
+ const pairWon =
+ (r.pairAId === pairId && r.teamAGames > r.teamBGames) ||
+ (r.pairBId === pairId && r.teamBGames > r.teamAGames)
+ if (pairWon) count += 1
+ }
+ }
+ return count
+ }
+
+ const rows: Omit[] = pairs.map((p) => {
+ const s = stats.get(p.id)!
+ return {
+ pairId: p.id,
+ pairName: s.name,
+ played: s.played,
+ wins: s.wins,
+ losses: s.losses,
+ gamesFor: s.gamesFor,
+ gamesAgainst: s.gamesAgainst,
+ gamesDiff: s.gamesFor - s.gamesAgainst,
+ h2hWins: h2hWinsAmongTied(p.id, s.wins),
+ currentElo: s.currentElo,
+ }
+ })
+
+ rows.sort((a, b) => {
+ if (b.wins !== a.wins) return b.wins - a.wins
+ if (b.h2hWins !== a.h2hWins) return b.h2hWins - a.h2hWins
+ if (b.gamesDiff !== a.gamesDiff) return b.gamesDiff - a.gamesDiff
+ if (b.gamesFor !== a.gamesFor) return b.gamesFor - a.gamesFor
+ return a.pairId.localeCompare(b.pairId)
+ })
+
+ return rows.map((row, index) => ({ ...row, rank: index + 1 }))
+}
diff --git a/src/utils/matchTeamNames.test.ts b/src/utils/matchTeamNames.test.ts
index d2f6d3d..41a996f 100644
--- a/src/utils/matchTeamNames.test.ts
+++ b/src/utils/matchTeamNames.test.ts
@@ -65,6 +65,29 @@ describe('matchTeamNames', () => {
).toBe('Los Nuestros')
})
+ it('replaces stored placeholder pair name with participant display names', () => {
+ expect(
+ resolveTeamName(
+ { ...baseMatch, team_a_name: 'Jugador - Pepe', team_a_player_2: 'Pepe' },
+ TEAM.A,
+ [participant(TEAM.A, 'María')]
+ )
+ ).toBe('María - Pepe')
+ })
+
+ it('replaces stored Jugador-Jugador pair name when both players are registered', () => {
+ expect(
+ resolveTeamName(
+ { ...baseMatch, team_a_name: 'Jugador - Jugador' },
+ TEAM.A,
+ [
+ participant(TEAM.A, 'Ana', '2026-01-01T10:00:00Z'),
+ participant(TEAM.A, 'Luis', '2026-01-01T11:00:00Z'),
+ ]
+ )
+ ).toBe('Ana - Luis')
+ })
+
it('falls back to Equipo A/B when no players', () => {
expect(resolveTeamName(baseMatch, TEAM.A, [])).toBe(DEFAULT_TEAM_A_NAME)
expect(resolveTeamName(baseMatch, TEAM.B, [])).toBe(DEFAULT_TEAM_B_NAME)
diff --git a/src/utils/matchTeamNames.ts b/src/utils/matchTeamNames.ts
index 965d277..6129d2c 100644
--- a/src/utils/matchTeamNames.ts
+++ b/src/utils/matchTeamNames.ts
@@ -36,6 +36,16 @@ function participantDisplayName(p: TeamRosterParticipant | null | undefined): st
return name || null
}
+const PLACEHOLDER_PLAYER_NAMES = new Set(['Jugador', 'Jugador registrado', 'Usuario'])
+
+/** Detects auto-generated pair names that still use placeholder labels. */
+function teamNameHasPlaceholderPlayers(name: string): boolean {
+ return name
+ .split('-')
+ .map((part) => part.trim())
+ .some((part) => PLACEHOLDER_PLAYER_NAMES.has(part))
+}
+
function registeredOnTeam(
participants: Array,
team: string
@@ -112,7 +122,9 @@ export function collectTeamPlayerNames(
team: string
): string[] {
return collectTeamRosterEntries(match, participants, team).map((entry) =>
- entry.kind === 'registered' ? participantDisplayName(entry.participant)! : entry.name
+ entry.kind === 'registered'
+ ? participantDisplayName(entry.participant) ?? 'Jugador registrado'
+ : entry.name
)
}
@@ -128,11 +140,16 @@ export function resolveTeamName(
participants: TeamRosterParticipant[] = []
): string {
const stored = team === TEAM.B ? match.team_b_name : match.team_a_name
+ const derived = formatTeamNameFromPlayers(collectTeamPlayerNames(match, participants, team))
+
if (!isUnspecifiedTeamName(stored, team)) {
- return stored.trim()
+ const trimmed = stored.trim()
+ if (derived && teamNameHasPlaceholderPlayers(trimmed)) {
+ return derived
+ }
+ return trimmed
}
- const derived = formatTeamNameFromPlayers(collectTeamPlayerNames(match, participants, team))
if (derived) return derived
return team === TEAM.B ? DEFAULT_TEAM_B_NAME : DEFAULT_TEAM_A_NAME
diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts
new file mode 100644
index 0000000..dc3a7df
--- /dev/null
+++ b/src/utils/navigation.ts
@@ -0,0 +1,28 @@
+import type { Href } from 'expo-router'
+
+/** Normalize expo-router search param values. */
+export function firstSearchParam(value: string | string[] | undefined): string | undefined {
+ if (Array.isArray(value)) return value[0]
+ return value
+}
+
+/** Only allow in-app tab routes as return targets. */
+export function isSafeTabsHref(href: string): href is string {
+ return href.startsWith('/(tabs)/') && !href.includes('://') && !href.includes('..')
+}
+
+export function buildMatchDetailHref(
+ matchId: string,
+ opts?: { from?: string; profileUserId?: string }
+): Href {
+ const params = new URLSearchParams()
+ if (opts?.from) params.set('from', opts.from)
+ if (opts?.profileUserId) params.set('profileUserId', opts.profileUserId)
+ const qs = params.toString()
+ return (qs ? `/(tabs)/matches/${matchId}?${qs}` : `/(tabs)/matches/${matchId}`) as Href
+}
+
+export function buildProfileHref(profileUserId?: string | null): Href {
+ if (profileUserId) return `/(tabs)/profile/${profileUserId}` as Href
+ return '/(tabs)/profile' as Href
+}
diff --git a/supabase/migrations/20260810120000_086_leagues.sql b/supabase/migrations/20260810120000_086_leagues.sql
new file mode 100644
index 0000000..fb78ecf
--- /dev/null
+++ b/supabase/migrations/20260810120000_086_leagues.sql
@@ -0,0 +1,1633 @@
+-- Migration 086: Leagues (round-robin + open Elo), pairs, challenges, standings, Elo.
+
+-- ── leagues ───────────────────────────────────────────────────────────────────
+
+CREATE TABLE public.leagues (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ title TEXT NOT NULL,
+ description TEXT,
+ notes TEXT,
+ start_at TIMESTAMPTZ NOT NULL,
+ end_at TIMESTAMPTZ,
+ city TEXT NOT NULL,
+ place_text TEXT,
+ place_defined BOOLEAN NOT NULL DEFAULT TRUE,
+ location_privacy TEXT NOT NULL DEFAULT 'participants_only'
+ CHECK (location_privacy IN ('public_city_only', 'participants_only')),
+ duration_target_games INT NOT NULL CHECK (duration_target_games BETWEEN 1 AND 6),
+ visibility TEXT NOT NULL DEFAULT 'public'
+ CHECK (visibility IN ('public', 'link', 'private')),
+ password_hash TEXT,
+ format TEXT NOT NULL
+ CHECK (format IN ('single_round', 'double_round', 'open_elo')),
+ elo_initial INT NOT NULL DEFAULT 1000 CHECK (elo_initial BETWEEN 100 AND 3000),
+ elo_k_factor INT NOT NULL DEFAULT 32 CHECK (elo_k_factor BETWEEN 1 AND 128),
+ creator_id UUID NOT NULL REFERENCES public.profiles(id),
+ status TEXT NOT NULL DEFAULT 'registration'
+ CHECK (status IN ('registration', 'in_progress', 'finished', 'cancelled')),
+ fixtures_generated_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT leagues_open_elo_requires_end_at CHECK (
+ format <> 'open_elo' OR end_at IS NOT NULL
+ ),
+ CONSTRAINT leagues_end_after_start CHECK (
+ end_at IS NULL OR end_at > start_at
+ )
+);
+
+CREATE OR REPLACE FUNCTION public.set_leagues_updated_at()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = public
+AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER leagues_updated_at
+ BEFORE UPDATE ON public.leagues
+ FOR EACH ROW
+ EXECUTE FUNCTION public.set_leagues_updated_at();
+
+CREATE INDEX idx_leagues_search ON public.leagues (city, start_at, status);
+CREATE INDEX idx_leagues_creator ON public.leagues (creator_id, created_at DESC);
+CREATE INDEX idx_leagues_end_at ON public.leagues (end_at) WHERE format = 'open_elo';
+
+-- ── league_pairs ──────────────────────────────────────────────────────────────
+
+CREATE TABLE public.league_pairs (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ league_id UUID NOT NULL REFERENCES public.leagues(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ name_is_custom BOOLEAN NOT NULL DEFAULT FALSE,
+ player_a_user_id UUID REFERENCES public.profiles(id),
+ player_a_text TEXT,
+ player_b_user_id UUID REFERENCES public.profiles(id),
+ player_b_text TEXT,
+ created_by_user_id UUID NOT NULL REFERENCES public.profiles(id),
+ current_elo INT NOT NULL DEFAULT 1000,
+ joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT league_pairs_slot_a_xor CHECK (
+ (player_a_user_id IS NULL AND player_a_text IS NULL)
+ OR (player_a_user_id IS NOT NULL AND player_a_text IS NULL)
+ OR (player_a_user_id IS NULL AND player_a_text IS NOT NULL)
+ ),
+ CONSTRAINT league_pairs_slot_b_xor CHECK (
+ (player_b_user_id IS NULL AND player_b_text IS NULL)
+ OR (player_b_user_id IS NOT NULL AND player_b_text IS NULL)
+ OR (player_b_user_id IS NULL AND player_b_text IS NOT NULL)
+ ),
+ CONSTRAINT league_pairs_at_least_one_player CHECK (
+ player_a_user_id IS NOT NULL OR player_a_text IS NOT NULL
+ OR player_b_user_id IS NOT NULL OR player_b_text IS NOT NULL
+ )
+);
+
+CREATE INDEX idx_league_pairs_league ON public.league_pairs (league_id);
+
+CREATE OR REPLACE FUNCTION public.set_league_pairs_updated_at()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = public
+AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$;
+
+CREATE TRIGGER league_pairs_updated_at
+ BEFORE UPDATE ON public.league_pairs
+ FOR EACH ROW
+ EXECUTE FUNCTION public.set_league_pairs_updated_at();
+
+-- ── league_challenges (open_elo) ───────────────────────────────────────────────
+
+CREATE TABLE public.league_challenges (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ league_id UUID NOT NULL REFERENCES public.leagues(id) ON DELETE CASCADE,
+ challenger_pair_id UUID NOT NULL REFERENCES public.league_pairs(id) ON DELETE CASCADE,
+ challenged_pair_id UUID NOT NULL REFERENCES public.league_pairs(id) ON DELETE CASCADE,
+ status TEXT NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'accepted', 'rejected', 'expired')),
+ match_id UUID REFERENCES public.matches(id) ON DELETE SET NULL,
+ created_by_user_id UUID NOT NULL REFERENCES public.profiles(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ responded_at TIMESTAMPTZ,
+ CONSTRAINT league_challenges_distinct_pairs CHECK (challenger_pair_id <> challenged_pair_id)
+);
+
+CREATE INDEX idx_league_challenges_league ON public.league_challenges (league_id, status);
+CREATE UNIQUE INDEX idx_league_challenges_pending_unique
+ ON public.league_challenges (league_id, challenger_pair_id, challenged_pair_id)
+ WHERE status = 'pending';
+
+-- ── league_rating_history ─────────────────────────────────────────────────────
+
+CREATE TABLE public.league_rating_history (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ league_id UUID NOT NULL REFERENCES public.leagues(id) ON DELETE CASCADE,
+ pair_id UUID NOT NULL REFERENCES public.league_pairs(id) ON DELETE CASCADE,
+ match_id UUID NOT NULL REFERENCES public.matches(id) ON DELETE CASCADE,
+ elo_before INT NOT NULL,
+ elo_delta INT NOT NULL,
+ elo_after INT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_league_rating_history_pair ON public.league_rating_history (pair_id, created_at DESC);
+CREATE UNIQUE INDEX idx_league_rating_history_match_pair
+ ON public.league_rating_history (match_id, pair_id);
+
+-- ── league_password_grants ────────────────────────────────────────────────────
+
+CREATE TABLE public.league_password_grants (
+ league_id UUID NOT NULL REFERENCES public.leagues(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
+ granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (league_id, user_id)
+);
+
+-- ── matches: league columns ───────────────────────────────────────────────────
+
+ALTER TABLE public.matches
+ ADD COLUMN IF NOT EXISTS league_id UUID REFERENCES public.leagues(id) ON DELETE CASCADE,
+ ADD COLUMN IF NOT EXISTS league_pair_a_id UUID REFERENCES public.league_pairs(id),
+ ADD COLUMN IF NOT EXISTS league_pair_b_id UUID REFERENCES public.league_pairs(id),
+ ADD COLUMN IF NOT EXISTS league_round_number INT,
+ ADD COLUMN IF NOT EXISTS league_is_second_leg BOOLEAN NOT NULL DEFAULT FALSE;
+
+CREATE INDEX idx_matches_league ON public.matches (league_id);
+
+-- ── helpers ───────────────────────────────────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.auth_can_read_league(p_league_id UUID)
+RETURNS BOOLEAN
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT EXISTS (
+ SELECT 1 FROM public.leagues l
+ WHERE l.id = p_league_id
+ AND (
+ l.visibility = 'public'
+ OR l.visibility = 'link'
+ OR l.creator_id = auth.uid()
+ OR EXISTS (
+ SELECT 1 FROM public.league_pairs lp
+ WHERE lp.league_id = l.id
+ AND (
+ lp.player_a_user_id = auth.uid()
+ OR lp.player_b_user_id = auth.uid()
+ OR lp.created_by_user_id = auth.uid()
+ )
+ )
+ OR EXISTS (
+ SELECT 1 FROM public.league_password_grants g
+ WHERE g.league_id = l.id AND g.user_id = auth.uid()
+ )
+ )
+ );
+$$;
+
+CREATE OR REPLACE FUNCTION public.league_pair_is_complete(p_pair public.league_pairs)
+RETURNS BOOLEAN
+LANGUAGE sql
+IMMUTABLE
+SET search_path = public
+AS $$
+ SELECT
+ (p_pair.player_a_user_id IS NOT NULL OR NULLIF(BTRIM(COALESCE(p_pair.player_a_text, '')), '') IS NOT NULL)
+ AND (p_pair.player_b_user_id IS NOT NULL OR NULLIF(BTRIM(COALESCE(p_pair.player_b_text, '')), '') IS NOT NULL);
+$$;
+
+CREATE OR REPLACE FUNCTION public.user_is_in_league_pair(p_league_id UUID, p_user_id UUID)
+RETURNS BOOLEAN
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT EXISTS (
+ SELECT 1 FROM public.league_pairs
+ WHERE league_id = p_league_id
+ AND (player_a_user_id = p_user_id OR player_b_user_id = p_user_id)
+ );
+$$;
+
+CREATE OR REPLACE FUNCTION public.populate_match_roster_from_league_pair(
+ p_match_id UUID,
+ p_pair_id UUID,
+ p_team TEXT
+)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_pair public.league_pairs%ROWTYPE;
+BEGIN
+ SELECT * INTO v_pair FROM public.league_pairs WHERE id = p_pair_id;
+ IF NOT FOUND THEN RETURN; END IF;
+
+ IF p_team = 'A' THEN
+ UPDATE public.matches
+ SET
+ team_a_name = v_pair.name,
+ team_a_player_1 = v_pair.player_a_text,
+ team_a_player_2 = v_pair.player_b_text
+ WHERE id = p_match_id;
+
+ IF v_pair.player_a_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_a_user_id, 'A')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'A', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ IF v_pair.player_b_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_b_user_id, 'A')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'A', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ ELSE
+ UPDATE public.matches
+ SET
+ team_b_name = v_pair.name,
+ team_b_player_1 = v_pair.player_a_text,
+ team_b_player_2 = v_pair.player_b_text
+ WHERE id = p_match_id;
+
+ IF v_pair.player_a_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_a_user_id, 'B')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'B', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ IF v_pair.player_b_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_b_user_id, 'B')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'B', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ END IF;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.create_league_match(
+ p_league public.leagues,
+ p_pair_a_id UUID,
+ p_pair_b_id UUID,
+ p_round_number INT,
+ p_is_second_leg BOOLEAN DEFAULT FALSE
+)
+RETURNS UUID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_match_id UUID;
+ v_pair_a_name TEXT;
+ v_pair_b_name TEXT;
+ v_title TEXT;
+BEGIN
+ SELECT name INTO v_pair_a_name FROM public.league_pairs WHERE id = p_pair_a_id;
+ SELECT name INTO v_pair_b_name FROM public.league_pairs WHERE id = p_pair_b_id;
+
+ v_title := p_league.title || ' — J' || COALESCE(p_round_number::TEXT, '?');
+ IF p_is_second_leg THEN
+ v_title := v_title || ' (vuelta)';
+ END IF;
+
+ INSERT INTO public.matches (
+ title, start_at, city, place_defined, place_text,
+ duration_target_games, visibility, location_privacy,
+ creator_id, status,
+ league_id, league_pair_a_id, league_pair_b_id,
+ league_round_number, league_is_second_leg,
+ team_a_name, team_b_name
+ ) VALUES (
+ v_title,
+ p_league.start_at, p_league.city, p_league.place_defined, p_league.place_text,
+ p_league.duration_target_games, p_league.visibility, p_league.location_privacy,
+ p_league.creator_id, 'planned',
+ p_league.id, p_pair_a_id, p_pair_b_id,
+ p_round_number, COALESCE(p_is_second_leg, FALSE),
+ COALESCE(v_pair_a_name, 'Pareja A'), COALESCE(v_pair_b_name, 'Pareja B')
+ )
+ RETURNING id INTO v_match_id;
+
+ PERFORM public.populate_match_roster_from_league_pair(v_match_id, p_pair_a_id, 'A');
+ PERFORM public.populate_match_roster_from_league_pair(v_match_id, p_pair_b_id, 'B');
+
+ RETURN v_match_id;
+END;
+$$;
+
+-- ── RLS ───────────────────────────────────────────────────────────────────────
+
+ALTER TABLE public.leagues ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.league_pairs ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.league_challenges ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.league_rating_history ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.league_password_grants ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY leagues_select ON public.leagues
+ FOR SELECT TO authenticated
+ USING (
+ -- Solo visibilidad pública para listados públicos.
+ visibility = 'public'
+ OR creator_id = auth.uid()
+ OR EXISTS (
+ SELECT 1
+ FROM public.league_pairs lp
+ WHERE lp.league_id = id
+ AND (lp.player_a_user_id = auth.uid() OR lp.player_b_user_id = auth.uid())
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM public.league_password_grants lpg
+ WHERE lpg.league_id = id
+ AND lpg.user_id = auth.uid()
+ )
+ OR public.auth_is_admin()
+ );
+
+CREATE POLICY leagues_insert ON public.leagues
+ FOR INSERT TO authenticated
+ WITH CHECK (creator_id = auth.uid());
+
+CREATE POLICY leagues_update ON public.leagues
+ FOR UPDATE TO authenticated
+ USING (creator_id = auth.uid())
+ WITH CHECK (creator_id = auth.uid());
+
+CREATE POLICY league_pairs_select ON public.league_pairs
+ FOR SELECT TO authenticated
+ USING (public.auth_can_read_league(league_id));
+
+CREATE POLICY league_pairs_insert ON public.league_pairs
+ FOR INSERT TO authenticated
+ WITH CHECK (
+ public.auth_can_read_league(league_id)
+ AND created_by_user_id = auth.uid()
+ AND EXISTS (
+ SELECT 1 FROM public.leagues l
+ WHERE l.id = league_id AND l.status IN ('registration', 'in_progress')
+ )
+ );
+
+CREATE POLICY league_pairs_update ON public.league_pairs
+ FOR UPDATE TO authenticated
+ USING (
+ created_by_user_id = auth.uid()
+ OR player_a_user_id = auth.uid()
+ OR player_b_user_id = auth.uid()
+ OR EXISTS (
+ SELECT 1 FROM public.leagues l
+ WHERE l.id = league_pairs.league_id AND l.creator_id = auth.uid()
+ )
+ );
+
+CREATE POLICY league_pairs_delete ON public.league_pairs
+ FOR DELETE TO authenticated
+ USING (
+ EXISTS (
+ SELECT 1 FROM public.leagues l
+ WHERE l.id = league_pairs.league_id
+ AND l.creator_id = auth.uid()
+ AND l.status = 'registration'
+ AND l.fixtures_generated_at IS NULL
+ )
+ );
+
+CREATE POLICY league_challenges_select ON public.league_challenges
+ FOR SELECT TO authenticated
+ USING (public.auth_can_read_league(league_id));
+
+CREATE POLICY league_rating_history_select ON public.league_rating_history
+ FOR SELECT TO authenticated
+ USING (public.auth_can_read_league(league_id));
+
+CREATE POLICY league_password_grants_select_self ON public.league_password_grants
+ FOR SELECT TO authenticated
+ USING (user_id = auth.uid());
+
+DROP POLICY IF EXISTS matches_select ON public.matches;
+CREATE POLICY matches_select ON public.matches
+ FOR SELECT TO authenticated USING (
+ visibility = 'public'
+ OR creator_id = auth.uid()
+ OR public.auth_is_confirmed_in_match(id)
+ OR visibility = 'link'
+ OR (
+ tournament_id IS NOT NULL
+ AND public.auth_can_read_tournament(tournament_id)
+ )
+ OR (
+ league_id IS NOT NULL
+ AND public.auth_can_read_league(league_id)
+ )
+ );
+
+-- ── create_league ─────────────────────────────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.create_league(
+ p_title TEXT,
+ p_start_at TIMESTAMPTZ,
+ p_city TEXT,
+ p_duration_target_games INT,
+ p_format TEXT,
+ p_end_at TIMESTAMPTZ DEFAULT NULL,
+ p_description TEXT DEFAULT NULL,
+ p_notes TEXT DEFAULT NULL,
+ p_place_defined BOOLEAN DEFAULT TRUE,
+ p_place_text TEXT DEFAULT NULL,
+ p_visibility TEXT DEFAULT 'public',
+ p_location_privacy TEXT DEFAULT 'participants_only',
+ p_elo_initial INT DEFAULT 1000,
+ p_elo_k_factor INT DEFAULT 32
+)
+RETURNS public.leagues
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_row public.leagues%ROWTYPE;
+ v_format TEXT := COALESCE(NULLIF(BTRIM(p_format), ''), 'single_round');
+BEGIN
+ IF auth.uid() IS NULL THEN
+ RAISE EXCEPTION 'not_authenticated';
+ END IF;
+
+ IF NULLIF(BTRIM(p_title), '') IS NULL THEN
+ RAISE EXCEPTION 'title_required';
+ END IF;
+
+ IF NULLIF(BTRIM(p_city), '') IS NULL THEN
+ RAISE EXCEPTION 'city_required';
+ END IF;
+
+ IF v_format NOT IN ('single_round', 'double_round', 'open_elo') THEN
+ RAISE EXCEPTION 'invalid_format';
+ END IF;
+
+ IF v_format = 'open_elo' AND p_end_at IS NULL THEN
+ RAISE EXCEPTION 'end_at_required_for_open_elo';
+ END IF;
+
+ IF p_end_at IS NOT NULL AND p_end_at <= p_start_at THEN
+ RAISE EXCEPTION 'end_at_must_be_after_start_at';
+ END IF;
+
+ INSERT INTO public.leagues (
+ title, description, notes, start_at, end_at, city,
+ place_defined, place_text, duration_target_games,
+ visibility, location_privacy, format, elo_initial, elo_k_factor,
+ creator_id, status
+ ) VALUES (
+ BTRIM(p_title),
+ NULLIF(BTRIM(p_description), ''),
+ NULLIF(BTRIM(p_notes), ''),
+ p_start_at,
+ CASE WHEN v_format = 'open_elo' THEN p_end_at ELSE p_end_at END,
+ BTRIM(p_city),
+ COALESCE(p_place_defined, TRUE),
+ CASE WHEN COALESCE(p_place_defined, TRUE) THEN NULLIF(BTRIM(p_place_text), '') ELSE NULL END,
+ p_duration_target_games,
+ COALESCE(p_visibility, 'public'),
+ COALESCE(p_location_privacy, 'participants_only'),
+ v_format,
+ COALESCE(p_elo_initial, 1000),
+ COALESCE(p_elo_k_factor, 32),
+ auth.uid(),
+ 'registration'
+ )
+ RETURNING * INTO v_row;
+
+ RETURN v_row;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.create_league(
+ TEXT, TIMESTAMPTZ, TEXT, INT, TEXT, TIMESTAMPTZ, TEXT, TEXT, BOOLEAN, TEXT, TEXT, TEXT, INT, INT
+) TO authenticated;
+
+-- ── password helpers ──────────────────────────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.viewer_can_access_league(p_league_id UUID)
+RETURNS BOOLEAN
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT public.auth_can_read_league(p_league_id);
+$$;
+
+CREATE OR REPLACE FUNCTION public.set_league_password(
+ p_league_id UUID,
+ p_password TEXT
+)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public, extensions
+AS $$
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+ IF NULLIF(BTRIM(p_password), '') IS NULL THEN RAISE EXCEPTION 'password_empty'; END IF;
+
+ UPDATE public.leagues
+ SET
+ visibility = 'private',
+ password_hash = crypt(BTRIM(p_password), gen_salt('bf'))
+ WHERE id = p_league_id
+ AND creator_id = auth.uid();
+
+ IF NOT FOUND THEN
+ IF NOT EXISTS (SELECT 1 FROM public.leagues WHERE id = p_league_id) THEN
+ RAISE EXCEPTION 'league_not_found';
+ END IF;
+ RAISE EXCEPTION 'forbidden';
+ END IF;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.grant_league_password_access(
+ p_league_id UUID,
+ p_password TEXT
+)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public, extensions
+AS $$
+DECLARE
+ v_hash TEXT;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT password_hash INTO v_hash
+ FROM public.leagues
+ WHERE id = p_league_id AND visibility = 'private';
+
+ IF NOT FOUND THEN RAISE EXCEPTION 'league_not_found'; END IF;
+ IF v_hash IS NULL THEN RAISE EXCEPTION 'league_no_password'; END IF;
+ IF v_hash <> crypt(BTRIM(p_password), v_hash) THEN
+ RAISE EXCEPTION 'wrong_password';
+ END IF;
+
+ INSERT INTO public.league_password_grants (league_id, user_id)
+ VALUES (p_league_id, auth.uid())
+ ON CONFLICT DO NOTHING;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.viewer_can_access_league(UUID) TO authenticated;
+GRANT EXECUTE ON FUNCTION public.set_league_password(UUID, TEXT) TO authenticated;
+GRANT EXECUTE ON FUNCTION public.grant_league_password_access(UUID, TEXT) TO authenticated;
+
+-- ── add / join / update / remove pair ─────────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.add_league_pair(
+ p_league_id UUID,
+ p_name TEXT DEFAULT '',
+ p_player_a_user_id UUID DEFAULT NULL,
+ p_player_a_text TEXT DEFAULT NULL,
+ p_player_b_user_id UUID DEFAULT NULL,
+ p_player_b_text TEXT DEFAULT NULL
+)
+RETURNS public.league_pairs
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_league public.leagues%ROWTYPE;
+ v_row public.league_pairs%ROWTYPE;
+ v_name TEXT;
+ v_name_custom BOOLEAN := FALSE;
+ v_a_text TEXT := NULLIF(BTRIM(COALESCE(p_player_a_text, '')), '');
+ v_b_text TEXT := NULLIF(BTRIM(COALESCE(p_player_b_text, '')), '');
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = p_league_id;
+ IF NOT FOUND THEN RAISE EXCEPTION 'league_not_found'; END IF;
+ IF v_league.status NOT IN ('registration', 'in_progress') THEN
+ RAISE EXCEPTION 'league_not_accepting_pairs';
+ END IF;
+ IF v_league.format = 'open_elo' AND v_league.end_at IS NOT NULL AND NOW() > v_league.end_at THEN
+ RAISE EXCEPTION 'league_ended';
+ END IF;
+ IF NOT public.auth_can_read_league(p_league_id) THEN
+ RAISE EXCEPTION 'forbidden';
+ END IF;
+
+ IF p_player_a_user_id IS NOT NULL AND public.user_is_in_league_pair(p_league_id, p_player_a_user_id) THEN
+ RAISE EXCEPTION 'already_in_pair';
+ END IF;
+ IF p_player_b_user_id IS NOT NULL AND public.user_is_in_league_pair(p_league_id, p_player_b_user_id) THEN
+ RAISE EXCEPTION 'already_in_pair';
+ END IF;
+
+ v_name := NULLIF(BTRIM(COALESCE(p_name, '')), '');
+ IF v_name IS NOT NULL THEN
+ v_name_custom := TRUE;
+ ELSE
+ v_name := COALESCE(v_a_text, 'Jugador') || ' - ' || COALESCE(v_b_text, 'Jugador');
+ END IF;
+
+ INSERT INTO public.league_pairs (
+ league_id, name, name_is_custom,
+ player_a_user_id, player_a_text,
+ player_b_user_id, player_b_text,
+ created_by_user_id, current_elo
+ ) VALUES (
+ p_league_id, v_name, v_name_custom,
+ p_player_a_user_id, v_a_text,
+ p_player_b_user_id, v_b_text,
+ auth.uid(),
+ v_league.elo_initial
+ )
+ RETURNING * INTO v_row;
+
+ -- Late join catch-up for round-robin after fixtures started
+ IF v_league.status = 'in_progress'
+ AND v_league.format IN ('single_round', 'double_round')
+ AND v_league.fixtures_generated_at IS NOT NULL
+ AND public.league_pair_is_complete(v_row)
+ THEN
+ PERFORM public.generate_league_catchup_matches(p_league_id, v_row.id);
+ END IF;
+
+ RETURN v_row;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.generate_league_catchup_matches(
+ p_league_id UUID,
+ p_new_pair_id UUID
+)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_league public.leagues%ROWTYPE;
+ v_other RECORD;
+BEGIN
+ SELECT * INTO v_league FROM public.leagues WHERE id = p_league_id;
+ IF NOT FOUND THEN RETURN; END IF;
+ IF v_league.format NOT IN ('single_round', 'double_round') THEN RETURN; END IF;
+
+ FOR v_other IN
+ SELECT id FROM public.league_pairs
+ WHERE league_id = p_league_id
+ AND id <> p_new_pair_id
+ AND public.league_pair_is_complete(league_pairs)
+ LOOP
+ IF NOT EXISTS (
+ SELECT 1 FROM public.matches m
+ WHERE m.league_id = p_league_id
+ AND m.status <> 'cancelled'
+ AND (
+ (m.league_pair_a_id = p_new_pair_id AND m.league_pair_b_id = v_other.id)
+ OR (m.league_pair_a_id = v_other.id AND m.league_pair_b_id = p_new_pair_id)
+ )
+ AND COALESCE(m.league_is_second_leg, FALSE) = FALSE
+ ) THEN
+ PERFORM public.create_league_match(v_league, p_new_pair_id, v_other.id, NULL, FALSE);
+ END IF;
+
+ IF v_league.format = 'double_round' THEN
+ IF NOT EXISTS (
+ SELECT 1 FROM public.matches m
+ WHERE m.league_id = p_league_id
+ AND m.status <> 'cancelled'
+ AND m.league_pair_a_id = v_other.id
+ AND m.league_pair_b_id = p_new_pair_id
+ AND COALESCE(m.league_is_second_leg, FALSE) = TRUE
+ ) THEN
+ PERFORM public.create_league_match(v_league, v_other.id, p_new_pair_id, NULL, TRUE);
+ END IF;
+ END IF;
+ END LOOP;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.join_league_pair(
+ p_pair_id UUID,
+ p_slot TEXT,
+ p_as_text TEXT DEFAULT NULL
+)
+RETURNS public.league_pairs
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_pair public.league_pairs%ROWTYPE;
+ v_league public.leagues%ROWTYPE;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_pair FROM public.league_pairs WHERE id = p_pair_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'pair_not_found'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = v_pair.league_id;
+ IF v_league.status NOT IN ('registration', 'in_progress') THEN
+ RAISE EXCEPTION 'league_not_accepting_pairs';
+ END IF;
+ IF NOT public.auth_can_read_league(v_pair.league_id) THEN
+ RAISE EXCEPTION 'forbidden';
+ END IF;
+ IF public.user_is_in_league_pair(v_pair.league_id, auth.uid()) THEN
+ RAISE EXCEPTION 'already_in_pair';
+ END IF;
+
+ IF p_slot = 'a' THEN
+ IF v_pair.player_a_user_id IS NOT NULL OR v_pair.player_a_text IS NOT NULL THEN
+ RAISE EXCEPTION 'slot_taken';
+ END IF;
+ IF p_as_text IS NOT NULL AND NULLIF(BTRIM(p_as_text), '') IS NOT NULL THEN
+ UPDATE public.league_pairs SET player_a_text = BTRIM(p_as_text) WHERE id = p_pair_id;
+ ELSE
+ UPDATE public.league_pairs SET player_a_user_id = auth.uid() WHERE id = p_pair_id;
+ END IF;
+ ELSIF p_slot = 'b' THEN
+ IF v_pair.player_b_user_id IS NOT NULL OR v_pair.player_b_text IS NOT NULL THEN
+ RAISE EXCEPTION 'slot_taken';
+ END IF;
+ IF p_as_text IS NOT NULL AND NULLIF(BTRIM(p_as_text), '') IS NOT NULL THEN
+ UPDATE public.league_pairs SET player_b_text = BTRIM(p_as_text) WHERE id = p_pair_id;
+ ELSE
+ UPDATE public.league_pairs SET player_b_user_id = auth.uid() WHERE id = p_pair_id;
+ END IF;
+ ELSE
+ RAISE EXCEPTION 'invalid_slot';
+ END IF;
+
+ SELECT * INTO v_pair FROM public.league_pairs WHERE id = p_pair_id;
+
+ IF v_league.status = 'in_progress'
+ AND v_league.format IN ('single_round', 'double_round')
+ AND v_league.fixtures_generated_at IS NOT NULL
+ AND public.league_pair_is_complete(v_pair)
+ THEN
+ PERFORM public.generate_league_catchup_matches(v_league.id, v_pair.id);
+ END IF;
+
+ RETURN v_pair;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.update_league_pair(
+ p_pair_id UUID,
+ p_name TEXT DEFAULT '',
+ p_player_a_text TEXT DEFAULT '',
+ p_player_b_text TEXT DEFAULT ''
+)
+RETURNS public.league_pairs
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_pair public.league_pairs%ROWTYPE;
+ v_league public.leagues%ROWTYPE;
+ v_name TEXT := NULLIF(BTRIM(COALESCE(p_name, '')), '');
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_pair FROM public.league_pairs WHERE id = p_pair_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'pair_not_found'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = v_pair.league_id;
+ IF v_league.status NOT IN ('registration', 'in_progress') THEN
+ RAISE EXCEPTION 'league_not_accepting_pairs';
+ END IF;
+
+ IF v_league.creator_id <> auth.uid()
+ AND v_pair.created_by_user_id <> auth.uid()
+ AND v_pair.player_a_user_id IS DISTINCT FROM auth.uid()
+ AND v_pair.player_b_user_id IS DISTINCT FROM auth.uid()
+ THEN
+ RAISE EXCEPTION 'forbidden';
+ END IF;
+
+ IF v_pair.player_a_text IS NOT NULL AND NULLIF(BTRIM(COALESCE(p_player_a_text, '')), '') IS NULL THEN
+ RAISE EXCEPTION 'cannot_clear_text_player';
+ END IF;
+ IF v_pair.player_b_text IS NOT NULL AND NULLIF(BTRIM(COALESCE(p_player_b_text, '')), '') IS NULL THEN
+ RAISE EXCEPTION 'cannot_clear_text_player';
+ END IF;
+
+ UPDATE public.league_pairs
+ SET
+ name = CASE WHEN v_name IS NOT NULL THEN v_name ELSE name END,
+ name_is_custom = CASE WHEN v_name IS NOT NULL THEN TRUE ELSE name_is_custom END,
+ player_a_text = CASE
+ WHEN player_a_user_id IS NOT NULL THEN NULL
+ ELSE COALESCE(NULLIF(BTRIM(COALESCE(p_player_a_text, '')), ''), player_a_text)
+ END,
+ player_b_text = CASE
+ WHEN player_b_user_id IS NOT NULL THEN NULL
+ ELSE COALESCE(NULLIF(BTRIM(COALESCE(p_player_b_text, '')), ''), player_b_text)
+ END
+ WHERE id = p_pair_id
+ RETURNING * INTO v_pair;
+
+ RETURN v_pair;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.remove_league_pair(p_pair_id UUID)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_pair public.league_pairs%ROWTYPE;
+ v_league public.leagues%ROWTYPE;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_pair FROM public.league_pairs WHERE id = p_pair_id;
+ IF NOT FOUND THEN RAISE EXCEPTION 'pair_not_found'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = v_pair.league_id;
+ IF v_league.creator_id <> auth.uid() THEN RAISE EXCEPTION 'forbidden'; END IF;
+ IF v_league.status <> 'registration' OR v_league.fixtures_generated_at IS NOT NULL THEN
+ RAISE EXCEPTION 'cannot_remove_pair_after_start';
+ END IF;
+
+ DELETE FROM public.league_pairs WHERE id = p_pair_id;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.add_league_pair(UUID, TEXT, UUID, TEXT, UUID, TEXT) TO authenticated;
+GRANT EXECUTE ON FUNCTION public.join_league_pair(UUID, TEXT, TEXT) TO authenticated;
+GRANT EXECUTE ON FUNCTION public.update_league_pair(UUID, TEXT, TEXT, TEXT) TO authenticated;
+GRANT EXECUTE ON FUNCTION public.remove_league_pair(UUID) TO authenticated;
+
+-- Fix: FOR loop variable type for second-leg roster population
+CREATE OR REPLACE FUNCTION public.generate_league_fixtures(p_league_id UUID)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_league public.leagues%ROWTYPE;
+ v_pair_ids UUID[];
+ v_n INT;
+ v_rounds INT;
+ v_round INT;
+ v_i INT;
+ v_home UUID;
+ v_away UUID;
+ v_fixed UUID;
+ v_rot UUID[];
+ v_tmp UUID;
+ v_half INT;
+ v_leg RECORD;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = p_league_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'league_not_found'; END IF;
+ IF v_league.creator_id <> auth.uid() THEN RAISE EXCEPTION 'not_creator'; END IF;
+ IF v_league.status <> 'registration' THEN RAISE EXCEPTION 'invalid_status'; END IF;
+ IF v_league.format NOT IN ('single_round', 'double_round') THEN
+ RAISE EXCEPTION 'fixtures_only_for_round_robin';
+ END IF;
+ IF v_league.fixtures_generated_at IS NOT NULL THEN
+ RAISE EXCEPTION 'fixtures_already_generated';
+ END IF;
+
+ SELECT ARRAY_AGG(id ORDER BY created_at)
+ INTO v_pair_ids
+ FROM public.league_pairs
+ WHERE league_id = p_league_id
+ AND public.league_pair_is_complete(league_pairs);
+
+ v_n := COALESCE(array_length(v_pair_ids, 1), 0);
+ IF v_n < 2 THEN RAISE EXCEPTION 'need_at_least_two_complete_pairs'; END IF;
+
+ IF v_n % 2 = 1 THEN
+ v_pair_ids := v_pair_ids || ARRAY[NULL::UUID];
+ v_n := v_n + 1;
+ END IF;
+
+ v_rounds := v_n - 1;
+ v_half := v_n / 2;
+ v_fixed := v_pair_ids[1];
+ v_rot := v_pair_ids[2:v_n];
+
+ FOR v_round IN 1..v_rounds LOOP
+ v_home := v_fixed;
+ v_away := v_rot[array_length(v_rot, 1)];
+ IF v_home IS NOT NULL AND v_away IS NOT NULL THEN
+ IF v_round % 2 = 0 THEN
+ PERFORM public.create_league_match(v_league, v_away, v_home, v_round, FALSE);
+ ELSE
+ PERFORM public.create_league_match(v_league, v_home, v_away, v_round, FALSE);
+ END IF;
+ END IF;
+
+ FOR v_i IN 1..(v_half - 1) LOOP
+ v_home := v_rot[v_i];
+ v_away := v_rot[array_length(v_rot, 1) - v_i];
+ IF v_home IS NOT NULL AND v_away IS NOT NULL THEN
+ IF (v_round + v_i) % 2 = 0 THEN
+ PERFORM public.create_league_match(v_league, v_away, v_home, v_round, FALSE);
+ ELSE
+ PERFORM public.create_league_match(v_league, v_home, v_away, v_round, FALSE);
+ END IF;
+ END IF;
+ END LOOP;
+
+ v_tmp := v_rot[array_length(v_rot, 1)];
+ v_rot := ARRAY[v_tmp] || v_rot[1:array_length(v_rot, 1) - 1];
+ END LOOP;
+
+ IF v_league.format = 'double_round' THEN
+ INSERT INTO public.matches (
+ title, start_at, city, place_defined, place_text,
+ duration_target_games, visibility, location_privacy,
+ creator_id, status,
+ league_id, league_pair_a_id, league_pair_b_id,
+ league_round_number, league_is_second_leg,
+ team_a_name, team_b_name
+ )
+ SELECT
+ v_league.title || ' — J' || (m.league_round_number + v_rounds)::TEXT || ' (vuelta)',
+ v_league.start_at, v_league.city, v_league.place_defined, v_league.place_text,
+ v_league.duration_target_games, v_league.visibility, v_league.location_privacy,
+ v_league.creator_id, 'planned',
+ p_league_id, m.league_pair_b_id, m.league_pair_a_id,
+ m.league_round_number + v_rounds, TRUE,
+ m.team_b_name, m.team_a_name
+ FROM public.matches m
+ WHERE m.league_id = p_league_id
+ AND COALESCE(m.league_is_second_leg, FALSE) = FALSE;
+
+ FOR v_leg IN
+ SELECT id, league_pair_a_id, league_pair_b_id
+ FROM public.matches
+ WHERE league_id = p_league_id AND league_is_second_leg = TRUE
+ LOOP
+ PERFORM public.populate_match_roster_from_league_pair(v_leg.id, v_leg.league_pair_a_id, 'A');
+ PERFORM public.populate_match_roster_from_league_pair(v_leg.id, v_leg.league_pair_b_id, 'B');
+ END LOOP;
+ END IF;
+
+ UPDATE public.leagues
+ SET status = 'in_progress', fixtures_generated_at = NOW()
+ WHERE id = p_league_id;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.generate_league_fixtures(UUID) TO authenticated;
+
+-- Start open_elo league (no fixtures)
+CREATE OR REPLACE FUNCTION public.start_open_league(p_league_id UUID)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_league public.leagues%ROWTYPE;
+ v_n INT;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = p_league_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'league_not_found'; END IF;
+ IF v_league.creator_id <> auth.uid() THEN RAISE EXCEPTION 'not_creator'; END IF;
+ IF v_league.status <> 'registration' THEN RAISE EXCEPTION 'invalid_status'; END IF;
+ IF v_league.format <> 'open_elo' THEN RAISE EXCEPTION 'not_open_elo'; END IF;
+ IF v_league.end_at IS NULL OR v_league.end_at <= NOW() THEN
+ RAISE EXCEPTION 'end_at_invalid';
+ END IF;
+
+ SELECT COUNT(*) INTO v_n
+ FROM public.league_pairs
+ WHERE league_id = p_league_id
+ AND public.league_pair_is_complete(league_pairs);
+
+ IF v_n < 2 THEN RAISE EXCEPTION 'need_at_least_two_complete_pairs'; END IF;
+
+ UPDATE public.leagues
+ SET status = 'in_progress'
+ WHERE id = p_league_id;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.start_open_league(UUID) TO authenticated;
+
+-- ── challenges ────────────────────────────────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.create_league_challenge(
+ p_league_id UUID,
+ p_challenged_pair_id UUID
+)
+RETURNS public.league_challenges
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_league public.leagues%ROWTYPE;
+ v_challenger_id UUID;
+ v_row public.league_challenges%ROWTYPE;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = p_league_id;
+ IF NOT FOUND THEN RAISE EXCEPTION 'league_not_found'; END IF;
+ IF v_league.format <> 'open_elo' THEN RAISE EXCEPTION 'not_open_elo'; END IF;
+ IF v_league.status <> 'in_progress' THEN RAISE EXCEPTION 'league_not_in_progress'; END IF;
+ IF v_league.end_at IS NOT NULL AND NOW() > v_league.end_at THEN
+ RAISE EXCEPTION 'league_ended';
+ END IF;
+
+ SELECT id INTO v_challenger_id
+ FROM public.league_pairs
+ WHERE league_id = p_league_id
+ AND (player_a_user_id = auth.uid() OR player_b_user_id = auth.uid())
+ LIMIT 1;
+
+ IF v_challenger_id IS NULL THEN RAISE EXCEPTION 'not_in_league_pair'; END IF;
+ IF v_challenger_id = p_challenged_pair_id THEN RAISE EXCEPTION 'cannot_challenge_self'; END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM public.league_pairs
+ WHERE id = p_challenged_pair_id AND league_id = p_league_id
+ AND public.league_pair_is_complete(league_pairs)
+ ) THEN
+ RAISE EXCEPTION 'challenged_pair_invalid';
+ END IF;
+
+ IF EXISTS (
+ SELECT 1 FROM public.league_challenges
+ WHERE league_id = p_league_id
+ AND status = 'pending'
+ AND (
+ (challenger_pair_id = v_challenger_id AND challenged_pair_id = p_challenged_pair_id)
+ OR (challenger_pair_id = p_challenged_pair_id AND challenged_pair_id = v_challenger_id)
+ )
+ ) THEN
+ RAISE EXCEPTION 'challenge_already_pending';
+ END IF;
+
+ INSERT INTO public.league_challenges (
+ league_id, challenger_pair_id, challenged_pair_id,
+ status, created_by_user_id
+ ) VALUES (
+ p_league_id, v_challenger_id, p_challenged_pair_id,
+ 'pending', auth.uid()
+ )
+ RETURNING * INTO v_row;
+
+ RETURN v_row;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.accept_league_challenge(p_challenge_id UUID)
+RETURNS public.league_challenges
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ch public.league_challenges%ROWTYPE;
+ v_league public.leagues%ROWTYPE;
+ v_match_id UUID;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_ch FROM public.league_challenges WHERE id = p_challenge_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'challenge_not_found'; END IF;
+ IF v_ch.status <> 'pending' THEN RAISE EXCEPTION 'challenge_not_pending'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = v_ch.league_id;
+ IF v_league.end_at IS NOT NULL AND NOW() > v_league.end_at THEN
+ UPDATE public.league_challenges SET status = 'expired', responded_at = NOW()
+ WHERE id = p_challenge_id;
+ RAISE EXCEPTION 'league_ended';
+ END IF;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM public.league_pairs
+ WHERE id = v_ch.challenged_pair_id
+ AND (player_a_user_id = auth.uid() OR player_b_user_id = auth.uid())
+ ) AND v_league.creator_id <> auth.uid() THEN
+ RAISE EXCEPTION 'forbidden';
+ END IF;
+
+ v_match_id := public.create_league_match(
+ v_league, v_ch.challenger_pair_id, v_ch.challenged_pair_id, NULL, FALSE
+ );
+
+ UPDATE public.matches SET status = 'in_progress', start_at = NOW() WHERE id = v_match_id;
+
+ UPDATE public.league_challenges
+ SET status = 'accepted', match_id = v_match_id, responded_at = NOW()
+ WHERE id = p_challenge_id
+ RETURNING * INTO v_ch;
+
+ RETURN v_ch;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.reject_league_challenge(p_challenge_id UUID)
+RETURNS public.league_challenges
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_ch public.league_challenges%ROWTYPE;
+ v_league public.leagues%ROWTYPE;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_ch FROM public.league_challenges WHERE id = p_challenge_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'challenge_not_found'; END IF;
+ IF v_ch.status <> 'pending' THEN RAISE EXCEPTION 'challenge_not_pending'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = v_ch.league_id;
+
+ IF NOT EXISTS (
+ SELECT 1 FROM public.league_pairs
+ WHERE id = v_ch.challenged_pair_id
+ AND (player_a_user_id = auth.uid() OR player_b_user_id = auth.uid())
+ ) AND v_league.creator_id <> auth.uid() THEN
+ RAISE EXCEPTION 'forbidden';
+ END IF;
+
+ UPDATE public.league_challenges
+ SET status = 'rejected', responded_at = NOW()
+ WHERE id = p_challenge_id
+ RETURNING * INTO v_ch;
+
+ RETURN v_ch;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.create_league_challenge(UUID, UUID) TO authenticated;
+GRANT EXECUTE ON FUNCTION public.accept_league_challenge(UUID) TO authenticated;
+GRANT EXECUTE ON FUNCTION public.reject_league_challenge(UUID) TO authenticated;
+
+-- ── standings ─────────────────────────────────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.list_league_standings(p_league_id UUID)
+RETURNS TABLE (
+ pair_id UUID,
+ pair_name TEXT,
+ played INT,
+ wins INT,
+ losses INT,
+ games_for INT,
+ games_against INT,
+ games_diff INT,
+ h2h_wins INT,
+ current_elo INT,
+ rank INT
+)
+LANGUAGE plpgsql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ IF NOT public.auth_can_read_league(p_league_id) THEN
+ RAISE EXCEPTION 'forbidden';
+ END IF;
+
+ RETURN QUERY
+ WITH confirmed AS (
+ SELECT
+ m.league_pair_a_id AS pair_a,
+ m.league_pair_b_id AS pair_b,
+ mr.team_a_games,
+ mr.team_b_games
+ FROM public.matches m
+ JOIN public.match_results mr ON mr.match_id = m.id AND mr.status = 'confirmed'
+ WHERE m.league_id = p_league_id
+ AND m.status = 'finished'
+ AND m.league_pair_a_id IS NOT NULL
+ AND m.league_pair_b_id IS NOT NULL
+ ),
+ stats AS (
+ SELECT
+ lp.id AS pair_id,
+ lp.name AS pair_name,
+ lp.current_elo,
+ COALESCE((
+ SELECT COUNT(*)::INT FROM confirmed c
+ WHERE c.pair_a = lp.id OR c.pair_b = lp.id
+ ), 0) AS played,
+ COALESCE((
+ SELECT COUNT(*)::INT FROM confirmed c
+ WHERE (c.pair_a = lp.id AND c.team_a_games > c.team_b_games)
+ OR (c.pair_b = lp.id AND c.team_b_games > c.team_a_games)
+ ), 0) AS wins,
+ COALESCE((
+ SELECT COUNT(*)::INT FROM confirmed c
+ WHERE (c.pair_a = lp.id AND c.team_a_games < c.team_b_games)
+ OR (c.pair_b = lp.id AND c.team_b_games < c.team_a_games)
+ ), 0) AS losses,
+ COALESCE((
+ SELECT SUM(CASE WHEN c.pair_a = lp.id THEN c.team_a_games ELSE c.team_b_games END)::INT
+ FROM confirmed c WHERE c.pair_a = lp.id OR c.pair_b = lp.id
+ ), 0) AS games_for,
+ COALESCE((
+ SELECT SUM(CASE WHEN c.pair_a = lp.id THEN c.team_b_games ELSE c.team_a_games END)::INT
+ FROM confirmed c WHERE c.pair_a = lp.id OR c.pair_b = lp.id
+ ), 0) AS games_against
+ FROM public.league_pairs lp
+ WHERE lp.league_id = p_league_id
+ ),
+ with_h2h AS (
+ SELECT
+ s.*,
+ (s.games_for - s.games_against) AS games_diff,
+ COALESCE((
+ SELECT COUNT(*)::INT
+ FROM confirmed c
+ JOIN stats s2 ON s2.pair_id <> s.pair_id AND s2.wins = s.wins
+ WHERE (
+ (c.pair_a = s.pair_id AND c.pair_b = s2.pair_id AND c.team_a_games > c.team_b_games)
+ OR (c.pair_b = s.pair_id AND c.pair_a = s2.pair_id AND c.team_b_games > c.team_a_games)
+ )
+ ), 0) AS h2h_wins
+ FROM stats s
+ ),
+ ordered AS (
+ SELECT
+ w.*,
+ ROW_NUMBER() OVER (
+ ORDER BY w.wins DESC, w.h2h_wins DESC, w.games_diff DESC, w.games_for DESC, w.pair_id
+ )::INT AS rank
+ FROM with_h2h w
+ )
+ SELECT
+ o.pair_id, o.pair_name, o.played, o.wins, o.losses,
+ o.games_for, o.games_against, o.games_diff, o.h2h_wins, o.current_elo, o.rank
+ FROM ordered o
+ ORDER BY o.rank;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.list_league_standings(UUID) TO authenticated;
+
+-- ── Elo + finish ──────────────────────────────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.recalculate_league_elo(p_match_id UUID)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_match public.matches%ROWTYPE;
+ v_league public.leagues%ROWTYPE;
+ v_result public.match_results%ROWTYPE;
+ v_pair_a public.league_pairs%ROWTYPE;
+ v_pair_b public.league_pairs%ROWTYPE;
+ v_ra NUMERIC;
+ v_rb NUMERIC;
+ v_ea NUMERIC;
+ v_eb NUMERIC;
+ v_sa NUMERIC;
+ v_sb NUMERIC;
+ v_da INT;
+ v_db INT;
+BEGIN
+ SELECT * INTO v_match FROM public.matches WHERE id = p_match_id;
+ IF NOT FOUND OR v_match.league_id IS NULL THEN RETURN; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = v_match.league_id;
+ IF NOT FOUND OR v_league.format <> 'open_elo' THEN RETURN; END IF;
+
+ SELECT * INTO v_result
+ FROM public.match_results
+ WHERE match_id = p_match_id AND status = 'confirmed'
+ ORDER BY created_at DESC
+ LIMIT 1;
+ IF NOT FOUND THEN RETURN; END IF;
+
+ IF EXISTS (
+ SELECT 1 FROM public.league_rating_history WHERE match_id = p_match_id
+ ) THEN
+ RETURN;
+ END IF;
+
+ SELECT * INTO v_pair_a FROM public.league_pairs WHERE id = v_match.league_pair_a_id FOR UPDATE;
+ SELECT * INTO v_pair_b FROM public.league_pairs WHERE id = v_match.league_pair_b_id FOR UPDATE;
+ IF NOT FOUND OR v_pair_a.id IS NULL OR v_pair_b.id IS NULL THEN RETURN; END IF;
+
+ v_ra := v_pair_a.current_elo;
+ v_rb := v_pair_b.current_elo;
+ v_ea := 1.0 / (1.0 + POWER(10.0, (v_rb - v_ra) / 400.0));
+ v_eb := 1.0 - v_ea;
+
+ IF v_result.team_a_games > v_result.team_b_games THEN
+ v_sa := 1; v_sb := 0;
+ ELSE
+ v_sa := 0; v_sb := 1;
+ END IF;
+
+ v_da := ROUND(v_league.elo_k_factor * (v_sa - v_ea))::INT;
+ v_db := ROUND(v_league.elo_k_factor * (v_sb - v_eb))::INT;
+
+ UPDATE public.league_pairs SET current_elo = current_elo + v_da WHERE id = v_pair_a.id;
+ UPDATE public.league_pairs SET current_elo = current_elo + v_db WHERE id = v_pair_b.id;
+
+ INSERT INTO public.league_rating_history (league_id, pair_id, match_id, elo_before, elo_delta, elo_after)
+ VALUES
+ (v_league.id, v_pair_a.id, p_match_id, v_pair_a.current_elo, v_da, v_pair_a.current_elo + v_da),
+ (v_league.id, v_pair_b.id, p_match_id, v_pair_b.current_elo, v_db, v_pair_b.current_elo + v_db);
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.maybe_finish_league(p_league_id UUID)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_league public.leagues%ROWTYPE;
+ v_pending INT;
+BEGIN
+ SELECT * INTO v_league FROM public.leagues WHERE id = p_league_id FOR UPDATE;
+ IF NOT FOUND OR v_league.status <> 'in_progress' THEN RETURN; END IF;
+
+ IF v_league.format = 'open_elo' THEN
+ IF v_league.end_at IS NOT NULL AND NOW() > v_league.end_at THEN
+ UPDATE public.league_challenges
+ SET status = 'expired', responded_at = COALESCE(responded_at, NOW())
+ WHERE league_id = p_league_id AND status = 'pending';
+
+ UPDATE public.leagues SET status = 'finished' WHERE id = p_league_id;
+ END IF;
+ RETURN;
+ END IF;
+
+ SELECT COUNT(*) INTO v_pending
+ FROM public.matches
+ WHERE league_id = p_league_id
+ AND status NOT IN ('finished', 'finished_no_result', 'cancelled');
+
+ IF v_pending = 0 THEN
+ UPDATE public.leagues SET status = 'finished' WHERE id = p_league_id;
+ END IF;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.on_league_match_result_confirmed()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_league_id UUID;
+BEGIN
+ IF NEW.status = 'confirmed' AND (OLD.status IS DISTINCT FROM 'confirmed') THEN
+ SELECT league_id INTO v_league_id FROM public.matches WHERE id = NEW.match_id;
+ IF v_league_id IS NOT NULL THEN
+ PERFORM public.recalculate_league_elo(NEW.match_id);
+ PERFORM public.maybe_finish_league(v_league_id);
+ END IF;
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS league_match_result_confirmed ON public.match_results;
+CREATE TRIGGER league_match_result_confirmed
+ AFTER UPDATE OF status ON public.match_results
+ FOR EACH ROW
+ EXECUTE FUNCTION public.on_league_match_result_confirmed();
+
+-- ── cancel / lifecycle ────────────────────────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.cancel_all_league_matches(p_league_id UUID)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ UPDATE public.matches
+ SET status = 'cancelled'
+ WHERE league_id = p_league_id
+ AND status IN ('planned', 'in_progress');
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.cancel_league(p_league_id UUID)
+RETURNS public.leagues
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_row public.leagues%ROWTYPE;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_row FROM public.leagues WHERE id = p_league_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'league_not_found'; END IF;
+ IF v_row.creator_id <> auth.uid() THEN RAISE EXCEPTION 'forbidden'; END IF;
+ IF v_row.status NOT IN ('registration', 'in_progress') THEN
+ RAISE EXCEPTION 'league_not_cancellable';
+ END IF;
+
+ PERFORM public.cancel_all_league_matches(p_league_id);
+
+ UPDATE public.league_challenges
+ SET status = 'expired', responded_at = COALESCE(responded_at, NOW())
+ WHERE league_id = p_league_id AND status = 'pending';
+
+ UPDATE public.leagues SET status = 'cancelled' WHERE id = p_league_id
+ RETURNING * INTO v_row;
+
+ RETURN v_row;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.process_league_lifecycle()
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_league RECORD;
+BEGIN
+ -- Finish open leagues past end_at (grace: cancel unfinished matches older than end_at + 24h)
+ FOR v_league IN
+ SELECT id, end_at FROM public.leagues
+ WHERE format = 'open_elo'
+ AND status = 'in_progress'
+ AND end_at IS NOT NULL
+ AND NOW() > end_at
+ LOOP
+ UPDATE public.league_challenges
+ SET status = 'expired', responded_at = COALESCE(responded_at, NOW())
+ WHERE league_id = v_league.id AND status = 'pending';
+
+ IF NOW() > v_league.end_at + INTERVAL '24 hours' THEN
+ UPDATE public.matches
+ SET status = 'cancelled'
+ WHERE league_id = v_league.id
+ AND status IN ('planned', 'in_progress');
+ END IF;
+
+ PERFORM public.maybe_finish_league(v_league.id);
+ END LOOP;
+
+ -- Auto-finish round-robin when all matches done
+ FOR v_league IN
+ SELECT id FROM public.leagues
+ WHERE format IN ('single_round', 'double_round')
+ AND status = 'in_progress'
+ LOOP
+ PERFORM public.maybe_finish_league(v_league.id);
+ END LOOP;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.cancel_league(UUID) TO authenticated;
+
+-- ── referee result for league matches ─────────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.record_league_match_result_as_referee(
+ p_match_id UUID,
+ p_team_a_games INT,
+ p_team_b_games INT
+)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_match public.matches%ROWTYPE;
+ v_league public.leagues%ROWTYPE;
+ v_result_id UUID;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_match FROM public.matches WHERE id = p_match_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'match_not_found'; END IF;
+ IF v_match.league_id IS NULL THEN RAISE EXCEPTION 'not_league_match'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = v_match.league_id;
+ IF v_league.creator_id <> auth.uid() THEN RAISE EXCEPTION 'forbidden'; END IF;
+
+ IF p_team_a_games = p_team_b_games THEN RAISE EXCEPTION 'tie_not_allowed'; END IF;
+ IF GREATEST(p_team_a_games, p_team_b_games) <> v_match.duration_target_games THEN
+ RAISE EXCEPTION 'invalid_score';
+ END IF;
+
+ INSERT INTO public.match_results (
+ match_id, team_a_games, team_b_games,
+ submitted_by_user_id, submitted_by_team, status
+ ) VALUES (
+ p_match_id, p_team_a_games, p_team_b_games,
+ auth.uid(), 'A', 'confirmed'
+ )
+ RETURNING id INTO v_result_id;
+
+ UPDATE public.matches SET status = 'finished' WHERE id = p_match_id;
+
+ -- Recompute player stats aggregates (async queue) after the match becomes finished.
+ PERFORM public.recompute_player_stats_for_match(p_match_id);
+
+ PERFORM public.recalculate_league_elo(p_match_id);
+ PERFORM public.maybe_finish_league(v_league.id);
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.record_league_match_result_as_referee(UUID, INT, INT) TO authenticated;
+
+-- list league matches helper
+CREATE OR REPLACE FUNCTION public.list_league_matches(p_league_id UUID)
+RETURNS TABLE (
+ match_id UUID,
+ title TEXT,
+ start_at TIMESTAMPTZ,
+ status TEXT,
+ pair_a_id UUID,
+ pair_a_name TEXT,
+ pair_b_id UUID,
+ pair_b_name TEXT,
+ round_number INT,
+ is_second_leg BOOLEAN,
+ team_a_games INT,
+ team_b_games INT
+)
+LANGUAGE plpgsql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ IF NOT public.auth_can_read_league(p_league_id) THEN
+ RAISE EXCEPTION 'forbidden';
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ m.id,
+ m.title,
+ m.start_at,
+ m.status,
+ m.league_pair_a_id,
+ pa.name,
+ m.league_pair_b_id,
+ pb.name,
+ m.league_round_number,
+ m.league_is_second_leg,
+ mr.team_a_games,
+ mr.team_b_games
+ FROM public.matches m
+ LEFT JOIN public.league_pairs pa ON pa.id = m.league_pair_a_id
+ LEFT JOIN public.league_pairs pb ON pb.id = m.league_pair_b_id
+ LEFT JOIN LATERAL (
+ SELECT r.team_a_games, r.team_b_games
+ FROM public.match_results r
+ WHERE r.match_id = m.id AND r.status = 'confirmed'
+ ORDER BY r.created_at DESC
+ LIMIT 1
+ ) mr ON TRUE
+ WHERE m.league_id = p_league_id
+ ORDER BY
+ COALESCE(m.league_round_number, 9999),
+ m.league_is_second_leg,
+ m.created_at;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.list_league_matches(UUID) TO authenticated;
diff --git a/supabase/migrations/20260810120000_086_player_stats.sql b/supabase/migrations/20260810120000_086_player_stats.sql
index e932ef2..203c2cd 100644
--- a/supabase/migrations/20260810120000_086_player_stats.sql
+++ b/supabase/migrations/20260810120000_086_player_stats.sql
@@ -887,7 +887,7 @@ BEGIN
FROM public.player_stats ps
JOIN public.profiles pr ON pr.id = ps.user_id
WHERE ps.matches_played > 0
- AND (p_city IS NULL OR p_city = '' OR LOWER(pr.city) = LOWER(p_city))
+ AND (p_city IS NULL OR p_city = '' OR pr.city ILIKE p_city)
ORDER BY ps.elo_rating DESC, ps.wins DESC, ps.matches_played DESC
LIMIT v_lim
) r;
@@ -905,6 +905,6 @@ BEGIN
PERFORM public.backfill_player_stats();
EXCEPTION
WHEN OTHERS THEN
- RAISE EXCEPTION 'player_stats backfill failed: %', SQLERRM;
+ RAISE NOTICE 'player_stats backfill skipped: %', SQLERRM;
END;
$$;
diff --git a/supabase/migrations/20260810130000_087_explore_exclude_league_matches.sql b/supabase/migrations/20260810130000_087_explore_exclude_league_matches.sql
new file mode 100644
index 0000000..06bbd40
--- /dev/null
+++ b/supabase/migrations/20260810130000_087_explore_exclude_league_matches.sql
@@ -0,0 +1,224 @@
+-- 087: Exclude league fixtures from explore / casual join flows (mirror tournament).
+
+-- list_public_matches: hide league-linked matches
+CREATE OR REPLACE FUNCTION public.list_public_matches(
+ p_search text DEFAULT NULL,
+ p_city text DEFAULT NULL,
+ p_status text DEFAULT NULL,
+ p_start_after timestamptz DEFAULT NULL,
+ p_start_before timestamptz DEFAULT NULL,
+ p_min_free_slots integer DEFAULT NULL,
+ p_limit integer DEFAULT 20,
+ p_offset integer DEFAULT 0,
+ p_visibility text DEFAULT NULL
+)
+RETURNS TABLE (
+ id uuid,
+ title text,
+ description text,
+ start_at timestamptz,
+ city text,
+ place_defined boolean,
+ place_text text,
+ duration_target_games integer,
+ visibility text,
+ location_privacy text,
+ status text,
+ creator_id uuid,
+ created_at timestamptz,
+ updated_at timestamptz,
+ slots_filled integer,
+ free_slots integer,
+ total_count bigint
+)
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ WITH filtered AS (
+ SELECT
+ m.id,
+ m.title,
+ m.description,
+ m.start_at,
+ m.city,
+ m.place_defined,
+ CASE
+ WHEN m.location_privacy = 'participants_only'
+ AND m.creator_id <> auth.uid()
+ AND NOT public.auth_is_confirmed_in_match(m.id)
+ THEN NULL
+ ELSE m.place_text
+ END AS place_text,
+ m.duration_target_games,
+ m.visibility,
+ m.location_privacy,
+ m.status,
+ m.creator_id,
+ m.created_at,
+ m.updated_at,
+ public.match_effective_roster_filled(m.id) AS slots_filled
+ FROM public.matches m
+ WHERE m.visibility IN ('public', 'private')
+ AND (p_visibility IS NULL OR m.visibility = p_visibility)
+ AND m.status <> 'cancelled'
+ AND m.tournament_id IS NULL
+ AND m.league_id IS NULL
+ AND (
+ p_search IS NULL
+ OR TRIM(p_search) = ''
+ OR m.title ILIKE ('%' || TRIM(p_search) || '%')
+ )
+ AND (
+ p_city IS NULL
+ OR TRIM(p_city) = ''
+ OR m.city = TRIM(p_city)
+ )
+ AND (
+ p_status IS NULL
+ OR TRIM(p_status) = ''
+ OR m.status = TRIM(p_status)
+ )
+ AND (
+ p_start_after IS NULL
+ OR (
+ m.status NOT IN ('finished', 'finished_no_result')
+ AND (
+ m.start_at >= p_start_after
+ OR (
+ m.status IN ('planned', 'in_progress')
+ AND m.start_at < p_start_after
+ )
+ )
+ )
+ )
+ AND (p_start_before IS NULL OR m.start_at <= p_start_before)
+ ),
+ with_free AS (
+ SELECT
+ f.*,
+ (4 - f.slots_filled) AS free_slots
+ FROM filtered f
+ WHERE (
+ p_min_free_slots IS NULL
+ OR p_min_free_slots <= 0
+ OR (4 - f.slots_filled) >= p_min_free_slots
+ )
+ )
+ SELECT
+ w.id,
+ w.title,
+ w.description,
+ w.start_at,
+ w.city,
+ w.place_defined,
+ w.place_text,
+ w.duration_target_games,
+ w.visibility,
+ w.location_privacy,
+ w.status,
+ w.creator_id,
+ w.created_at,
+ w.updated_at,
+ w.slots_filled,
+ w.free_slots,
+ COUNT(*) OVER () AS total_count
+ FROM with_free w
+ ORDER BY w.start_at ASC
+ LIMIT LEAST(100, GREATEST(1, COALESCE(NULLIF(p_limit, 0), 20)))
+ OFFSET GREATEST(0, COALESCE(p_offset, 0));
+$$;
+
+-- join_private_match: reject league fixtures
+CREATE OR REPLACE FUNCTION public.join_private_match(
+ p_match_id UUID,
+ p_team TEXT,
+ p_password TEXT
+)
+RETURNS public.match_participants
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public, extensions
+AS $$
+DECLARE
+ v_match public.matches%ROWTYPE;
+ v_existing public.match_participants%ROWTYPE;
+ v_row public.match_participants%ROWTYPE;
+BEGIN
+ IF auth.uid() IS NULL THEN
+ RAISE EXCEPTION 'not_authenticated';
+ END IF;
+
+ SELECT * INTO v_match FROM public.matches WHERE id = p_match_id FOR SHARE;
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'match_not_found';
+ END IF;
+
+ IF v_match.visibility <> 'private' THEN
+ RAISE EXCEPTION 'not_private_match';
+ END IF;
+
+ IF v_match.status NOT IN ('planned', 'in_progress') THEN
+ RAISE EXCEPTION 'match_not_joinable';
+ END IF;
+
+ IF v_match.tournament_id IS NOT NULL THEN
+ RAISE EXCEPTION 'tournament_match';
+ END IF;
+
+ IF v_match.league_id IS NOT NULL THEN
+ RAISE EXCEPTION 'league_match';
+ END IF;
+
+ IF v_match.password_hash IS NULL THEN
+ RAISE EXCEPTION 'match_no_password';
+ END IF;
+
+ IF crypt(p_password, v_match.password_hash) <> v_match.password_hash THEN
+ RAISE EXCEPTION 'wrong_password';
+ END IF;
+
+ SELECT * INTO v_existing
+ FROM public.match_participants
+ WHERE match_id = p_match_id AND user_id = auth.uid();
+
+ IF FOUND THEN
+ IF v_existing.left_at IS NULL AND v_existing.state = 'confirmed' THEN
+ RAISE EXCEPTION 'already_participant';
+ END IF;
+
+ UPDATE public.match_participants
+ SET
+ team = p_team,
+ state = 'confirmed',
+ left_at = NULL,
+ joined_at = NOW()
+ WHERE id = v_existing.id
+ RETURNING * INTO v_row;
+ ELSE
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, auth.uid(), p_team)
+ RETURNING * INTO v_row;
+ END IF;
+
+ RETURN v_row;
+END;
+$$;
+
+-- Casual join policies: not for league fixtures
+DROP POLICY IF EXISTS participants_insert_self ON public.match_participants;
+CREATE POLICY participants_insert_self ON public.match_participants
+ FOR INSERT TO authenticated
+ WITH CHECK (
+ user_id = auth.uid()
+ AND EXISTS (
+ SELECT 1
+ FROM public.matches m
+ WHERE m.id = match_participants.match_id
+ AND m.status IN ('planned', 'in_progress')
+ AND m.visibility IN ('public', 'link', 'private')
+ AND m.tournament_id IS NULL
+ AND m.league_id IS NULL
+ )
+ );
diff --git a/supabase/migrations/20260810140000_088_league_pair_member_names.sql b/supabase/migrations/20260810140000_088_league_pair_member_names.sql
new file mode 100644
index 0000000..fe75b5d
--- /dev/null
+++ b/supabase/migrations/20260810140000_088_league_pair_member_names.sql
@@ -0,0 +1,266 @@
+-- 088: Fix league/tournament pair member display names.
+-- - profiles RLS: allow reading display_name of pair members in leagues/tournaments you can read.
+-- - profile_is_viewable_by_auth / get_public_profile: include league pairs.
+-- - add_league_pair / join_league_pair: use display_name (not 'Jugador') when generating the auto pair name.
+
+-- ── profile_is_viewable_by_auth: include league pairs ──────────────────────────
+
+CREATE OR REPLACE FUNCTION public.profile_is_viewable_by_auth(p_profile_id UUID)
+RETURNS BOOLEAN
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT EXISTS (
+ SELECT 1
+ FROM public.profiles p
+ WHERE p.id = p_profile_id
+ AND (
+ p.id = auth.uid()
+ OR public.profile_shares_confirmed_match_with_auth(p.id)
+ OR EXISTS (
+ SELECT 1
+ FROM public.tournament_pairs tp
+ WHERE tp.tournament_id IS NOT NULL
+ AND public.auth_can_read_tournament(tp.tournament_id)
+ AND (tp.player_a_user_id = p.id OR tp.player_b_user_id = p.id)
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM public.league_pairs lp
+ WHERE public.auth_can_read_league(lp.league_id)
+ AND (lp.player_a_user_id = p.id OR lp.player_b_user_id = p.id)
+ )
+ OR public.auth_is_admin()
+ )
+ );
+$$;
+
+-- ── get_public_profile: include league pairs ───────────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.get_public_profile(p_profile_id UUID)
+RETURNS TABLE (
+ id UUID,
+ display_name TEXT,
+ photo_url TEXT,
+ city TEXT
+)
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+ SELECT p.id, p.display_name, p.photo_url, p.city
+ FROM public.profiles p
+ WHERE p.id = p_profile_id
+ AND (
+ p.id = auth.uid()
+ OR public.profile_shares_confirmed_match_with_auth(p.id)
+ OR EXISTS (
+ SELECT 1
+ FROM public.tournament_pairs tp
+ WHERE tp.tournament_id IS NOT NULL
+ AND public.auth_can_read_tournament(tp.tournament_id)
+ AND (tp.player_a_user_id = p.id OR tp.player_b_user_id = p.id)
+ )
+ OR EXISTS (
+ SELECT 1
+ FROM public.league_pairs lp
+ WHERE public.auth_can_read_league(lp.league_id)
+ AND (lp.player_a_user_id = p.id OR lp.player_b_user_id = p.id)
+ )
+ OR public.auth_is_admin()
+ );
+$$;
+
+-- ── helper: resolve display label for a pair slot ─────────────────────────────
+
+CREATE OR REPLACE FUNCTION public.league_pair_slot_label(
+ p_user_id UUID,
+ p_text TEXT
+)
+RETURNS TEXT
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT COALESCE(
+ NULLIF(BTRIM(COALESCE(p_text, '')), ''),
+ (SELECT gp.display_name
+ FROM public.get_public_profile(p_user_id) gp
+ LIMIT 1),
+ 'Jugador'
+ );
+$$;
+
+-- ── add_league_pair: use display_name for auto-generated pair name ─────────────
+
+CREATE OR REPLACE FUNCTION public.add_league_pair(
+ p_league_id UUID,
+ p_name TEXT DEFAULT '',
+ p_player_a_user_id UUID DEFAULT NULL,
+ p_player_a_text TEXT DEFAULT NULL,
+ p_player_b_user_id UUID DEFAULT NULL,
+ p_player_b_text TEXT DEFAULT NULL
+)
+RETURNS public.league_pairs
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_league public.leagues%ROWTYPE;
+ v_row public.league_pairs%ROWTYPE;
+ v_name TEXT;
+ v_name_custom BOOLEAN := FALSE;
+ v_a_text TEXT := NULLIF(BTRIM(COALESCE(p_player_a_text, '')), '');
+ v_b_text TEXT := NULLIF(BTRIM(COALESCE(p_player_b_text, '')), '');
+ v_a_label TEXT;
+ v_b_label TEXT;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = p_league_id;
+ IF NOT FOUND THEN RAISE EXCEPTION 'league_not_found'; END IF;
+ IF v_league.status NOT IN ('registration', 'in_progress') THEN
+ RAISE EXCEPTION 'league_not_accepting_pairs';
+ END IF;
+ IF v_league.format = 'open_elo' AND v_league.end_at IS NOT NULL AND NOW() > v_league.end_at THEN
+ RAISE EXCEPTION 'league_ended';
+ END IF;
+ IF NOT public.auth_can_read_league(p_league_id) THEN
+ RAISE EXCEPTION 'forbidden';
+ END IF;
+
+ IF p_player_a_user_id IS NOT NULL AND public.user_is_in_league_pair(p_league_id, p_player_a_user_id) THEN
+ RAISE EXCEPTION 'already_in_pair';
+ END IF;
+ IF p_player_b_user_id IS NOT NULL AND public.user_is_in_league_pair(p_league_id, p_player_b_user_id) THEN
+ RAISE EXCEPTION 'already_in_pair';
+ END IF;
+
+ v_name := NULLIF(BTRIM(COALESCE(p_name, '')), '');
+ IF v_name IS NOT NULL THEN
+ v_name_custom := TRUE;
+ ELSE
+ -- Automatic name: persist the pair first, then compute the final label.
+ v_name_custom := FALSE;
+ v_name := '';
+ END IF;
+
+ INSERT INTO public.league_pairs (
+ league_id, name, name_is_custom,
+ player_a_user_id, player_a_text,
+ player_b_user_id, player_b_text,
+ created_by_user_id, current_elo
+ ) VALUES (
+ p_league_id, v_name, v_name_custom,
+ p_player_a_user_id, v_a_text,
+ p_player_b_user_id, v_b_text,
+ auth.uid(),
+ v_league.elo_initial
+ )
+ RETURNING * INTO v_row;
+
+ IF NOT v_name_custom THEN
+ v_a_label := public.league_pair_slot_label(p_player_a_user_id, v_a_text);
+ v_b_label := public.league_pair_slot_label(p_player_b_user_id, v_b_text);
+ UPDATE public.league_pairs
+ SET name = v_a_label || ' - ' || v_b_label
+ WHERE id = v_row.id;
+ SELECT * INTO v_row FROM public.league_pairs WHERE id = v_row.id;
+ END IF;
+
+ IF v_league.status = 'in_progress'
+ AND v_league.format IN ('single_round', 'double_round')
+ AND v_league.fixtures_generated_at IS NOT NULL
+ AND public.league_pair_is_complete(v_row)
+ THEN
+ PERFORM public.generate_league_catchup_matches(p_league_id, v_row.id);
+ END IF;
+
+ RETURN v_row;
+END;
+$$;
+
+-- ── join_league_pair: regenerate non-custom pair name when a user joins ───────
+
+CREATE OR REPLACE FUNCTION public.join_league_pair(
+ p_pair_id UUID,
+ p_slot TEXT,
+ p_as_text TEXT DEFAULT NULL
+)
+RETURNS public.league_pairs
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_pair public.league_pairs%ROWTYPE;
+ v_league public.leagues%ROWTYPE;
+ v_a_label TEXT;
+ v_b_label TEXT;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_pair FROM public.league_pairs WHERE id = p_pair_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'pair_not_found'; END IF;
+
+ SELECT * INTO v_league FROM public.leagues WHERE id = v_pair.league_id;
+ IF v_league.status NOT IN ('registration', 'in_progress') THEN
+ RAISE EXCEPTION 'league_not_accepting_pairs';
+ END IF;
+ IF NOT public.auth_can_read_league(v_pair.league_id) THEN
+ RAISE EXCEPTION 'forbidden';
+ END IF;
+ IF public.user_is_in_league_pair(v_pair.league_id, auth.uid()) THEN
+ RAISE EXCEPTION 'already_in_pair';
+ END IF;
+
+ IF p_slot = 'a' THEN
+ IF v_pair.player_a_user_id IS NOT NULL OR v_pair.player_a_text IS NOT NULL THEN
+ RAISE EXCEPTION 'slot_taken';
+ END IF;
+ IF p_as_text IS NOT NULL AND NULLIF(BTRIM(p_as_text), '') IS NOT NULL THEN
+ UPDATE public.league_pairs SET player_a_text = BTRIM(p_as_text) WHERE id = p_pair_id;
+ ELSE
+ UPDATE public.league_pairs SET player_a_user_id = auth.uid() WHERE id = p_pair_id;
+ END IF;
+ ELSIF p_slot = 'b' THEN
+ IF v_pair.player_b_user_id IS NOT NULL OR v_pair.player_b_text IS NOT NULL THEN
+ RAISE EXCEPTION 'slot_taken';
+ END IF;
+ IF p_as_text IS NOT NULL AND NULLIF(BTRIM(p_as_text), '') IS NOT NULL THEN
+ UPDATE public.league_pairs SET player_b_text = BTRIM(p_as_text) WHERE id = p_pair_id;
+ ELSE
+ UPDATE public.league_pairs SET player_b_user_id = auth.uid() WHERE id = p_pair_id;
+ END IF;
+ ELSE
+ RAISE EXCEPTION 'invalid_slot';
+ END IF;
+
+ SELECT * INTO v_pair FROM public.league_pairs WHERE id = p_pair_id;
+
+ -- Regenerate auto pair name when not custom so it reflects the new member.
+ IF NOT v_pair.name_is_custom THEN
+ v_a_label := public.league_pair_slot_label(v_pair.player_a_user_id, v_pair.player_a_text);
+ v_b_label := public.league_pair_slot_label(v_pair.player_b_user_id, v_pair.player_b_text);
+ UPDATE public.league_pairs
+ SET name = v_a_label || ' - ' || v_b_label
+ WHERE id = p_pair_id;
+ SELECT * INTO v_pair FROM public.league_pairs WHERE id = p_pair_id;
+ END IF;
+
+ IF v_league.status = 'in_progress'
+ AND v_league.format IN ('single_round', 'double_round')
+ AND v_league.fixtures_generated_at IS NOT NULL
+ AND public.league_pair_is_complete(v_pair)
+ THEN
+ PERFORM public.generate_league_catchup_matches(v_league.id, v_pair.id);
+ END IF;
+
+ RETURN v_pair;
+END;
+$$;
diff --git a/supabase/migrations/20260810150000_089_league_finish_on_all_matches.sql b/supabase/migrations/20260810150000_089_league_finish_on_all_matches.sql
new file mode 100644
index 0000000..f72c50c
--- /dev/null
+++ b/supabase/migrations/20260810150000_089_league_finish_on_all_matches.sql
@@ -0,0 +1,71 @@
+-- 089: Finish round-robin leagues when all matches are done.
+-- Trigger maybe_finish_league after match status becomes terminal (not on result
+-- confirmation alone, which runs before the match is marked finished).
+
+CREATE OR REPLACE FUNCTION public.on_league_match_status_changed()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+BEGIN
+ IF NEW.league_id IS NOT NULL
+ AND NEW.status IN ('finished', 'finished_no_result', 'cancelled')
+ AND OLD.status IS DISTINCT FROM NEW.status
+ THEN
+ PERFORM public.maybe_finish_league(NEW.league_id);
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS league_match_status_changed ON public.matches;
+CREATE TRIGGER league_match_status_changed
+ AFTER UPDATE OF status ON public.matches
+ FOR EACH ROW
+ EXECUTE FUNCTION public.on_league_match_status_changed();
+
+-- Require at least one league match before auto-finishing round-robin.
+CREATE OR REPLACE FUNCTION public.maybe_finish_league(p_league_id UUID)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_league public.leagues%ROWTYPE;
+ v_pending INT;
+ v_has_matches BOOLEAN;
+BEGIN
+ SELECT * INTO v_league FROM public.leagues WHERE id = p_league_id FOR UPDATE;
+ IF NOT FOUND OR v_league.status <> 'in_progress' THEN RETURN; END IF;
+
+ IF v_league.format = 'open_elo' THEN
+ IF v_league.end_at IS NOT NULL AND NOW() > v_league.end_at THEN
+ UPDATE public.league_challenges
+ SET status = 'expired', responded_at = COALESCE(responded_at, NOW())
+ WHERE league_id = p_league_id AND status = 'pending';
+
+ UPDATE public.leagues SET status = 'finished' WHERE id = p_league_id;
+ END IF;
+ RETURN;
+ END IF;
+
+ SELECT EXISTS (
+ SELECT 1 FROM public.matches WHERE league_id = p_league_id
+ ) INTO v_has_matches;
+
+ IF NOT v_has_matches THEN
+ RETURN;
+ END IF;
+
+ SELECT COUNT(*) INTO v_pending
+ FROM public.matches
+ WHERE league_id = p_league_id
+ AND status NOT IN ('finished', 'finished_no_result', 'cancelled');
+
+ IF v_pending = 0 THEN
+ UPDATE public.leagues SET status = 'finished', updated_at = NOW() WHERE id = p_league_id;
+ END IF;
+END;
+$$;
diff --git a/supabase/migrations/20260810150000_091_player_stats_rpc_reload.sql b/supabase/migrations/20260810150000_091_player_stats_rpc_reload.sql
index b84a95e..29c734a 100644
--- a/supabase/migrations/20260810150000_091_player_stats_rpc_reload.sql
+++ b/supabase/migrations/20260810150000_091_player_stats_rpc_reload.sql
@@ -1,12 +1,12 @@
-- Migration 091: Re-expose player stats RPCs to PostgREST
REVOKE ALL ON FUNCTION public.get_player_stats(UUID) FROM PUBLIC;
-GRANT EXECUTE ON FUNCTION public.get_player_stats(UUID) TO authenticated, service_role;
+GRANT EXECUTE ON FUNCTION public.get_player_stats(UUID) TO anon, authenticated, service_role;
REVOKE ALL ON FUNCTION public.get_leaderboard(TEXT, INT) FROM PUBLIC;
-GRANT EXECUTE ON FUNCTION public.get_leaderboard(TEXT, INT) TO authenticated, service_role;
+GRANT EXECUTE ON FUNCTION public.get_leaderboard(TEXT, INT) TO anon, authenticated, service_role;
REVOKE ALL ON FUNCTION public.get_match_player_insights(UUID, UUID) FROM PUBLIC;
-GRANT EXECUTE ON FUNCTION public.get_match_player_insights(UUID, UUID) TO authenticated, service_role;
+GRANT EXECUTE ON FUNCTION public.get_match_player_insights(UUID, UUID) TO anon, authenticated, service_role;
NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260810160000_090_match_team_display_names.sql b/supabase/migrations/20260810160000_090_match_team_display_names.sql
new file mode 100644
index 0000000..b7846a9
--- /dev/null
+++ b/supabase/migrations/20260810160000_090_match_team_display_names.sql
@@ -0,0 +1,197 @@
+-- 090: Fix league/tournament match team names showing "Jugador" for registered players.
+
+CREATE OR REPLACE FUNCTION public.league_pair_display_name(p_pair public.league_pairs)
+RETURNS TEXT
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT CASE
+ WHEN p_pair.name_is_custom THEN p_pair.name
+ ELSE public.league_pair_slot_label(p_pair.player_a_user_id, p_pair.player_a_text)
+ || ' - '
+ || public.league_pair_slot_label(p_pair.player_b_user_id, p_pair.player_b_text)
+ END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.tournament_pair_display_name(p_pair public.tournament_pairs)
+RETURNS TEXT
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = public
+AS $$
+ SELECT CASE
+ WHEN p_pair.name ~ '(^Jugador(\s|$|-)|-\s*Jugador(\s|$))' THEN
+ public.league_pair_slot_label(p_pair.player_a_user_id, p_pair.player_a_text)
+ || ' - '
+ || public.league_pair_slot_label(p_pair.player_b_user_id, p_pair.player_b_text)
+ ELSE p_pair.name
+ END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.populate_match_roster_from_league_pair(
+ p_match_id UUID,
+ p_pair_id UUID,
+ p_team TEXT
+)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_pair public.league_pairs%ROWTYPE;
+ v_display_name TEXT;
+BEGIN
+ SELECT * INTO v_pair FROM public.league_pairs WHERE id = p_pair_id;
+ IF NOT FOUND THEN RETURN; END IF;
+
+ v_display_name := public.league_pair_display_name(v_pair);
+
+ IF p_team = 'A' THEN
+ UPDATE public.matches
+ SET
+ team_a_name = v_display_name,
+ team_a_player_1 = v_pair.player_a_text,
+ team_a_player_2 = v_pair.player_b_text
+ WHERE id = p_match_id;
+
+ IF v_pair.player_a_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_a_user_id, 'A')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'A', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ IF v_pair.player_b_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_b_user_id, 'A')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'A', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ ELSE
+ UPDATE public.matches
+ SET
+ team_b_name = v_display_name,
+ team_b_player_1 = v_pair.player_a_text,
+ team_b_player_2 = v_pair.player_b_text
+ WHERE id = p_match_id;
+
+ IF v_pair.player_a_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_a_user_id, 'B')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'B', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ IF v_pair.player_b_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_b_user_id, 'B')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'B', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ END IF;
+END;
+$$;
+
+CREATE OR REPLACE FUNCTION public.populate_match_roster_from_pair(
+ p_match_id UUID,
+ p_pair_id UUID,
+ p_team TEXT
+)
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_pair public.tournament_pairs%ROWTYPE;
+ v_display_name TEXT;
+BEGIN
+ SELECT * INTO v_pair FROM public.tournament_pairs WHERE id = p_pair_id;
+ IF NOT FOUND THEN RETURN; END IF;
+
+ v_display_name := public.tournament_pair_display_name(v_pair);
+
+ IF p_team = 'A' THEN
+ UPDATE public.matches
+ SET
+ team_a_name = v_display_name,
+ team_a_player_1 = v_pair.player_a_text,
+ team_a_player_2 = v_pair.player_b_text
+ WHERE id = p_match_id;
+
+ IF v_pair.player_a_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_a_user_id, 'A')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'A', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ IF v_pair.player_b_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_b_user_id, 'A')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'A', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ ELSE
+ UPDATE public.matches
+ SET
+ team_b_name = v_display_name,
+ team_b_player_1 = v_pair.player_a_text,
+ team_b_player_2 = v_pair.player_b_text
+ WHERE id = p_match_id;
+
+ IF v_pair.player_a_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_a_user_id, 'B')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'B', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ IF v_pair.player_b_user_id IS NOT NULL THEN
+ INSERT INTO public.match_participants (match_id, user_id, team)
+ VALUES (p_match_id, v_pair.player_b_user_id, 'B')
+ ON CONFLICT (match_id, user_id) DO UPDATE
+ SET team = 'B', state = 'confirmed', left_at = NULL, joined_at = NOW();
+ END IF;
+ END IF;
+END;
+$$;
+
+-- Backfill auto-generated league pair names still using "Jugador".
+UPDATE public.league_pairs lp
+SET name = public.league_pair_display_name(lp)
+WHERE NOT lp.name_is_custom
+ AND lp.name ~ '(^Jugador(\s|$|-)|-\s*Jugador(\s|$))';
+
+-- Backfill match team names from linked pairs.
+UPDATE public.matches m
+SET team_a_name = public.league_pair_display_name(lp)
+FROM public.league_pairs lp
+WHERE m.league_pair_a_id = lp.id
+ AND m.league_id IS NOT NULL
+ AND m.status IN ('planned', 'in_progress')
+ AND m.team_a_name ~ '(^Jugador(\s|$|-)|-\s*Jugador(\s|$))';
+
+UPDATE public.matches m
+SET team_b_name = public.league_pair_display_name(lp)
+FROM public.league_pairs lp
+WHERE m.league_pair_b_id = lp.id
+ AND m.league_id IS NOT NULL
+ AND m.status IN ('planned', 'in_progress')
+ AND m.team_b_name ~ '(^Jugador(\s|$|-)|-\s*Jugador(\s|$))';
+
+UPDATE public.matches m
+SET team_a_name = public.tournament_pair_display_name(tp)
+FROM public.tournament_pairs tp
+WHERE m.tournament_pair_a_id = tp.id
+ AND m.tournament_id IS NOT NULL
+ AND m.status IN ('planned', 'in_progress')
+ AND m.team_a_name ~ '(^Jugador(\s|$|-)|-\s*Jugador(\s|$))';
+
+UPDATE public.matches m
+SET team_b_name = public.tournament_pair_display_name(tp)
+FROM public.tournament_pairs tp
+WHERE m.tournament_pair_b_id = tp.id
+ AND m.tournament_id IS NOT NULL
+ AND m.status IN ('planned', 'in_progress')
+ AND m.team_b_name ~ '(^Jugador(\s|$|-)|-\s*Jugador(\s|$))';
diff --git a/supabase/migrations/20260810180000_094_league_badges.sql b/supabase/migrations/20260810180000_094_league_badges.sql
new file mode 100644
index 0000000..a833952
--- /dev/null
+++ b/supabase/migrations/20260810180000_094_league_badges.sql
@@ -0,0 +1,311 @@
+-- League placement badges in recompute_player_stats_aggregates.
+-- Internal helper avoids auth_can_read_league so triggers/backfill work.
+
+CREATE OR REPLACE FUNCTION public._finished_league_ranks_for_user(p_user_id UUID)
+RETURNS TABLE (league_id UUID, rank INT)
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+ WITH user_pairs AS (
+ SELECT lp.id AS pair_id, lp.league_id
+ FROM public.league_pairs lp
+ JOIN public.leagues l ON l.id = lp.league_id
+ WHERE l.status = 'finished'
+ AND (lp.player_a_user_id = p_user_id OR lp.player_b_user_id = p_user_id)
+ ),
+ confirmed AS (
+ SELECT
+ m.league_id,
+ m.league_pair_a_id AS pair_a,
+ m.league_pair_b_id AS pair_b,
+ mr.team_a_games,
+ mr.team_b_games
+ FROM public.matches m
+ JOIN public.match_results mr ON mr.match_id = m.id AND mr.status = 'confirmed'
+ WHERE m.league_id IN (SELECT DISTINCT up.league_id FROM user_pairs up)
+ AND m.status = 'finished'
+ AND m.league_pair_a_id IS NOT NULL
+ AND m.league_pair_b_id IS NOT NULL
+ ),
+ pair_stats AS (
+ SELECT
+ lp.id AS pair_id,
+ lp.league_id,
+ COALESCE((
+ SELECT COUNT(*)::INT FROM confirmed c
+ WHERE c.league_id = lp.league_id
+ AND (c.pair_a = lp.id OR c.pair_b = lp.id)
+ ), 0) AS played,
+ COALESCE((
+ SELECT COUNT(*)::INT FROM confirmed c
+ WHERE c.league_id = lp.league_id
+ AND (
+ (c.pair_a = lp.id AND c.team_a_games > c.team_b_games)
+ OR (c.pair_b = lp.id AND c.team_b_games > c.team_a_games)
+ )
+ ), 0) AS wins,
+ COALESCE((
+ SELECT SUM(
+ CASE WHEN c.pair_a = lp.id THEN c.team_a_games ELSE c.team_b_games END
+ )::INT
+ FROM confirmed c
+ WHERE c.league_id = lp.league_id AND (c.pair_a = lp.id OR c.pair_b = lp.id)
+ ), 0) AS games_for,
+ COALESCE((
+ SELECT SUM(
+ CASE WHEN c.pair_a = lp.id THEN c.team_b_games ELSE c.team_a_games END
+ )::INT
+ FROM confirmed c
+ WHERE c.league_id = lp.league_id AND (c.pair_a = lp.id OR c.pair_b = lp.id)
+ ), 0) AS games_against
+ FROM public.league_pairs lp
+ WHERE lp.league_id IN (SELECT DISTINCT up.league_id FROM user_pairs up)
+ ),
+ h2h AS (
+ SELECT
+ ps.league_id,
+ ps.pair_id,
+ COALESCE(
+ COUNT(*) FILTER (
+ WHERE
+ (c.pair_a = ps.pair_id AND c.team_a_games > c.team_b_games)
+ OR (c.pair_b = ps.pair_id AND c.team_b_games > c.team_a_games)
+ ),
+ 0
+ )::INT AS h2h_wins
+ FROM pair_stats ps
+ JOIN confirmed c
+ ON c.league_id = ps.league_id
+ AND (c.pair_a = ps.pair_id OR c.pair_b = ps.pair_id)
+ JOIN pair_stats opp
+ ON opp.league_id = ps.league_id
+ AND opp.pair_id = CASE WHEN c.pair_a = ps.pair_id THEN c.pair_b ELSE c.pair_a END
+ AND opp.wins = ps.wins
+ GROUP BY ps.league_id, ps.pair_id
+ ),
+ ranked AS (
+ SELECT
+ ps.league_id,
+ ps.pair_id,
+ ROW_NUMBER() OVER (
+ PARTITION BY ps.league_id
+ ORDER BY
+ ps.wins DESC,
+ COALESCE(h2h.h2h_wins, 0) DESC,
+ (ps.games_for - ps.games_against) DESC,
+ ps.games_for DESC,
+ ps.pair_id
+ )::INT AS rank
+ FROM pair_stats ps
+ LEFT JOIN h2h
+ ON h2h.league_id = ps.league_id
+ AND h2h.pair_id = ps.pair_id
+ )
+ SELECT r.league_id, r.rank
+ FROM ranked r
+ JOIN user_pairs up ON up.pair_id = r.pair_id AND up.league_id = r.league_id;
+$$;
+
+REVOKE ALL ON FUNCTION public._finished_league_ranks_for_user(UUID) FROM PUBLIC;
+
+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;
+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 (COALESCE(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;
+
+ 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_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_nemesis_wins >= 5 THEN v_keys := array_append(v_keys, 'nemesis_confirmed'); 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;
+$$;
+
+REVOKE ALL ON FUNCTION public.recompute_player_stats_aggregates(UUID) FROM PUBLIC;
+
+-- Refresh badges for existing players.
+DO $$
+DECLARE
+ v_uid UUID;
+BEGIN
+ FOR v_uid IN SELECT user_id FROM public.player_stats
+ LOOP
+ PERFORM public.recompute_player_stats_aggregates(v_uid);
+ END LOOP;
+END;
+$$;
diff --git a/supabase/migrations/20260810181000_095_league_badges_apply.sql b/supabase/migrations/20260810181000_095_league_badges_apply.sql
new file mode 100644
index 0000000..cc6f747
--- /dev/null
+++ b/supabase/migrations/20260810181000_095_league_badges_apply.sql
@@ -0,0 +1,5 @@
+-- Remote apply of league badges (idempotent with 094).
+-- Kept for migration history parity with the hosted project.
+
+-- No-op placeholder: full definition lives in 20260810180000_094_league_badges.sql
+SELECT 1;
diff --git a/supabase/migrations/20260810190000_096_hard_badges.sql b/supabase/migrations/20260810190000_096_hard_badges.sql
new file mode 100644
index 0000000..227aca5
--- /dev/null
+++ b/supabase/migrations/20260810190000_096_hard_badges.sql
@@ -0,0 +1,310 @@
+-- Three harder badges: streak_breaker, streak_15, double_champion
+
+CREATE OR REPLACE FUNCTION public._player_broke_nine_win_streak(p_user_id UUID)
+RETURNS BOOLEAN
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+ WITH my_wins AS (
+ SELECT
+ m.id AS match_id,
+ m.start_at,
+ opp.user_id AS opp_id
+ FROM public.match_participants me
+ 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'
+ 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'
+ 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
+ ),
+ defeated_opponents AS (
+ SELECT DISTINCT
+ mw.opp_id AS user_id
+ FROM my_wins mw
+ ),
+ outcomes AS (
+ SELECT
+ mp.user_id,
+ m.id AS match_id,
+ m.start_at,
+ CASE
+ WHEN public._player_won_match(mp.team, mr.team_a_games, mr.team_b_games) IS TRUE THEN 1
+ ELSE 0
+ END AS is_win
+ FROM public.match_participants mp
+ JOIN public.matches m ON m.id = mp.match_id
+ JOIN public.match_results mr ON mr.match_id = m.id AND mr.status = 'confirmed'
+ WHERE mp.state = 'confirmed'
+ AND m.status = 'finished'
+ AND COALESCE(m.tournament_is_bye, FALSE) = FALSE
+ AND public._player_won_match(mp.team, mr.team_a_games, mr.team_b_games) IS NOT NULL
+ AND mp.user_id IN (SELECT user_id FROM defeated_opponents)
+ ),
+ grouped AS (
+ SELECT
+ o.*,
+ SUM(CASE WHEN o.is_win = 0 THEN 1 ELSE 0 END) OVER (
+ PARTITION BY o.user_id
+ ORDER BY o.start_at ASC, o.match_id ASC
+ ROWS UNBOUNDED PRECEDING
+ ) AS loss_grp
+ FROM outcomes o
+ ),
+ streak_at_match AS (
+ SELECT
+ g.user_id,
+ g.match_id,
+ g.start_at,
+ g.is_win,
+ CASE
+ WHEN g.is_win = 1 THEN
+ COUNT(*) FILTER (WHERE g.is_win = 1) OVER (
+ PARTITION BY g.user_id, g.loss_grp
+ ORDER BY g.start_at ASC, g.match_id ASC
+ ROWS UNBOUNDED PRECEDING
+ )
+ ELSE 0
+ END AS win_streak_after
+ FROM grouped g
+ ),
+ streak_before AS (
+ SELECT
+ s.user_id,
+ s.match_id,
+ s.start_at,
+ COALESCE(
+ LAG(s.win_streak_after) OVER (
+ PARTITION BY s.user_id
+ ORDER BY s.start_at ASC, s.match_id ASC
+ ),
+ 0
+ ) AS win_streak_before
+ FROM streak_at_match s
+ )
+ SELECT EXISTS (
+ SELECT 1
+ FROM my_wins mw
+ JOIN streak_before sb
+ ON sb.user_id = mw.opp_id
+ AND sb.match_id = mw.match_id
+ WHERE sb.win_streak_before = 9
+ );
+$$;
+
+REVOKE ALL ON FUNCTION public._player_broke_nine_win_streak(UUID) FROM PUBLIC;
+
+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 (COALESCE(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_best >= 15 THEN v_keys := array_append(v_keys, 'streak_15'); 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_nemesis_wins >= 5 THEN v_keys := array_append(v_keys, 'nemesis_confirmed'); 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;
+$$;
+
+REVOKE ALL ON FUNCTION public.recompute_player_stats_aggregates(UUID) FROM PUBLIC;
+
+DO $$
+DECLARE
+ v_uid UUID;
+BEGIN
+ FOR v_uid IN SELECT user_id FROM public.player_stats
+ LOOP
+ PERFORM public.recompute_player_stats_aggregates(v_uid);
+ END LOOP;
+END;
+$$;
diff --git a/supabase/migrations/20260810200000_097_badge_updates.sql b/supabase/migrations/20260810200000_097_badge_updates.sql
new file mode 100644
index 0000000..04533a1
--- /dev/null
+++ b/supabase/migrations/20260810200000_097_badge_updates.sql
@@ -0,0 +1,222 @@
+-- 097: Badge catalog updates
+-- - Removes "Racha de 15" (streak_15)
+-- - Adds "La vuelta al mundo" (explorer_20) for playing in 20 distinct venues
+-- - Adds more hard badges (crown_5, crown_10, league_crown_3, rivalry_10)
+
+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;
+ -- Badges intentionally removed from the catalog but that may still
+ -- exist in historical player_stats rows.
+ v_obsolete_keys TEXT[] := ARRAY['streak_15'];
+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 (COALESCE(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;
+
+ -- Preserve all previously stored badges except known obsolete keys.
+ v_badges := COALESCE((
+ SELECT jsonb_agg(e)
+ FROM jsonb_array_elements(COALESCE(v_existing, '[]'::jsonb)) e
+ WHERE NOT (e->>'key' = ANY(v_obsolete_keys))
+ ), '[]'::jsonb);
+
+ -- Ensure every currently-earned badge exists in the array.
+ FOREACH v_key IN ARRAY v_keys
+ LOOP
+ IF NOT EXISTS (
+ SELECT 1
+ FROM jsonb_array_elements(v_badges) e
+ WHERE e->>'key' = v_key
+ ) THEN
+ 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;
+$$;
+
+REVOKE ALL ON FUNCTION public.recompute_player_stats_aggregates(UUID) FROM PUBLIC;
+
+DO $$
+DECLARE
+ v_uid UUID;
+BEGIN
+ FOR v_uid IN SELECT user_id FROM public.player_stats
+ LOOP
+ PERFORM public.recompute_player_stats_aggregates(v_uid);
+ END LOOP;
+END;
+$$;
diff --git a/supabase/migrations/20260810210000_098_badge_showcase.sql b/supabase/migrations/20260810210000_098_badge_showcase.sql
new file mode 100644
index 0000000..5aee275
--- /dev/null
+++ b/supabase/migrations/20260810210000_098_badge_showcase.sql
@@ -0,0 +1,142 @@
+-- 098: Badge showcase on profiles
+-- Users can pin up to 3 earned badges on their profile.
+
+ALTER TABLE public.profiles
+ ADD COLUMN IF NOT EXISTS badge_showcase TEXT[] NOT NULL DEFAULT '{}';
+
+-- Normalize existing rows: keep at most 3, drop duplicates/empties
+UPDATE public.profiles
+SET badge_showcase = sub.keys
+FROM (
+ SELECT
+ id,
+ COALESCE(
+ (
+ SELECT array_agg(k ORDER BY ord)
+ FROM (
+ SELECT k, ord
+ FROM (
+ SELECT
+ pg_catalog.btrim(u.value::text) AS k,
+ u.ordinality AS ord,
+ ROW_NUMBER() OVER (
+ PARTITION BY pg_catalog.btrim(u.value::text)
+ ORDER BY u.ordinality
+ ) AS rn
+ FROM pg_catalog.unnest(badge_showcase) WITH ORDINALITY AS u(value, ordinality)
+ WHERE u.value IS NOT NULL AND pg_catalog.length(pg_catalog.btrim(u.value::text)) > 0
+ ) t
+ WHERE t.rn = 1
+ ) s
+ ORDER BY ord
+ LIMIT 3
+ ),
+ '{}'::text[]
+ ) AS keys
+ FROM public.profiles
+) sub
+WHERE sub.id = public.profiles.id;
+
+-- Validate badge_showcase on every update/insert.
+-- Reject:
+-- - empty/blank keys inside the array
+-- - duplicates
+-- - more than 3 keys
+-- - keys not present in player_stats.badges for this user
+CREATE OR REPLACE FUNCTION public.validate_badge_showcase()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+BEGIN
+ IF NEW.badge_showcase IS NULL THEN
+ RAISE EXCEPTION 'badge_showcase_null';
+ END IF;
+
+ -- Max 3 slots.
+ IF pg_catalog.cardinality(NEW.badge_showcase) > 3 THEN
+ RAISE EXCEPTION 'badge_showcase_too_many';
+ END IF;
+
+ -- No empty/blank values.
+ IF EXISTS (
+ SELECT 1
+ FROM pg_catalog.unnest(NEW.badge_showcase) k
+ WHERE k IS NULL OR pg_catalog.length(pg_catalog.btrim(k)) = 0
+ ) THEN
+ RAISE EXCEPTION 'badge_showcase_empty_value';
+ END IF;
+
+ -- No duplicates.
+ IF (
+ SELECT pg_catalog.count(DISTINCT k) FROM pg_catalog.unnest(NEW.badge_showcase) k
+ ) <> pg_catalog.cardinality(NEW.badge_showcase) THEN
+ RAISE EXCEPTION 'badge_showcase_duplicates';
+ END IF;
+
+ -- Keys must exist in earned badges.
+ IF pg_catalog.cardinality(NEW.badge_showcase) > 0 AND EXISTS (
+ SELECT 1
+ FROM pg_catalog.unnest(NEW.badge_showcase) k
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM public.player_stats ps,
+ pg_catalog.jsonb_array_elements(ps.badges) b
+ WHERE ps.user_id = NEW.id
+ AND b->>'key' = k
+ )
+ ) THEN
+ RAISE EXCEPTION 'badge_showcase_invalid_key';
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS validate_badge_showcase_trg ON public.profiles;
+CREATE TRIGGER validate_badge_showcase_trg
+BEFORE INSERT OR UPDATE OF badge_showcase
+ON public.profiles
+FOR EACH ROW
+EXECUTE FUNCTION public.validate_badge_showcase();
+
+-- Expose showcase on viewable profiles (recreate return type)
+DROP FUNCTION IF EXISTS public.get_viewable_user_profile(UUID);
+
+CREATE OR REPLACE FUNCTION public.get_viewable_user_profile(p_user_id UUID)
+RETURNS TABLE (
+ id UUID,
+ display_name TEXT,
+ city TEXT,
+ phone_e164 TEXT,
+ photo_url TEXT,
+ badge_showcase TEXT[]
+)
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+ SELECT
+ p.id,
+ p.display_name,
+ p.city,
+ CASE
+ WHEN p.id = auth.uid()
+ OR public.profile_shares_confirmed_match_with_auth(p.id)
+ OR public.auth_is_admin()
+ THEN p.phone_e164
+ ELSE NULL
+ END AS phone_e164,
+ p.photo_url,
+ p.badge_showcase
+ FROM public.profiles p
+ WHERE p.id = p_user_id
+ AND public.profile_is_viewable_by_auth(p.id);
+$$;
+
+REVOKE ALL ON FUNCTION public.get_viewable_user_profile(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.get_viewable_user_profile(UUID) TO authenticated;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260810220000_099_badge_showcase_grants.sql b/supabase/migrations/20260810220000_099_badge_showcase_grants.sql
new file mode 100644
index 0000000..05efaea
--- /dev/null
+++ b/supabase/migrations/20260810220000_099_badge_showcase_grants.sql
@@ -0,0 +1,17 @@
+-- 099: Allow authenticated users to update/select badge_showcase on own profile
+
+REVOKE SELECT ON public.profiles FROM authenticated;
+GRANT SELECT (
+ id, display_name, city, photo_url, badge_showcase, notify_push, notify_on_join,
+ notify_on_match_change, notify_on_match_start, notify_on_match_edit, notify_on_match_cancel,
+ notify_on_result, notify_on_reminder, notify_on_reminder_24h, notify_on_reminder_2h,
+ notify_on_reminder_in_progress, role, status, created_at, updated_at
+) ON public.profiles TO authenticated;
+
+REVOKE UPDATE ON public.profiles FROM authenticated;
+GRANT UPDATE (
+ display_name, phone_e164, city, photo_url, badge_showcase, notify_push, notify_on_join,
+ notify_on_match_change, notify_on_match_start, notify_on_match_edit, notify_on_match_cancel,
+ notify_on_result, notify_on_reminder, notify_on_reminder_24h, notify_on_reminder_2h,
+ notify_on_reminder_in_progress, push_token
+) ON public.profiles TO authenticated;
diff --git a/supabase/migrations/20260810230000_100_player_ranking.sql b/supabase/migrations/20260810230000_100_player_ranking.sql
new file mode 100644
index 0000000..54c4ea7
--- /dev/null
+++ b/supabase/migrations/20260810230000_100_player_ranking.sql
@@ -0,0 +1,135 @@
+-- Player ranking positions (global + city) for profile stats card.
+
+CREATE OR REPLACE FUNCTION public.get_player_ranking(p_user_id UUID)
+RETURNS JSONB
+LANGUAGE plpgsql
+STABLE
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_profile_city TEXT;
+ v_city TEXT;
+ v_elo INT;
+ v_wins INT;
+ v_played INT;
+ v_global_rank INT;
+ v_city_rank INT;
+ v_global_total INT;
+ v_city_total INT;
+BEGIN
+ IF p_user_id IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ SELECT
+ NULLIF(BTRIM(pr.city), ''),
+ COALESCE(ps.elo_rating, 1200),
+ COALESCE(ps.wins, 0),
+ COALESCE(ps.matches_played, 0)
+ INTO v_profile_city, v_elo, v_wins, v_played
+ FROM public.profiles pr
+ LEFT JOIN public.player_stats ps ON ps.user_id = pr.id
+ WHERE pr.id = p_user_id;
+
+ IF NOT FOUND THEN
+ RETURN NULL;
+ END IF;
+
+ IF v_profile_city IS NOT NULL
+ AND lower(v_profile_city) <> lower('Ciudad por definir') THEN
+ v_city := v_profile_city;
+ ELSE
+ SELECT c.city
+ INTO v_city
+ FROM (
+ SELECT
+ BTRIM(m.city) AS city,
+ COUNT(*)::INT AS cnt
+ FROM public._player_confirmed_match_rows(p_user_id) m
+ WHERE NULLIF(BTRIM(m.city), '') IS NOT NULL
+ AND lower(BTRIM(m.city)) <> lower('Ciudad por definir')
+ GROUP BY BTRIM(m.city)
+ ORDER BY COUNT(*) DESC, BTRIM(m.city) ASC
+ LIMIT 1
+ ) c;
+ END IF;
+
+ SELECT COUNT(*)::INT
+ INTO v_global_total
+ FROM public.player_stats ps
+ WHERE ps.matches_played > 0;
+
+ IF v_city IS NOT NULL THEN
+ SELECT COUNT(*)::INT
+ INTO v_city_total
+ FROM public.player_stats ps
+ JOIN public.profiles pr ON pr.id = ps.user_id
+ WHERE ps.matches_played > 0
+ AND (pr.city ILIKE v_city OR ps.user_id = p_user_id);
+ END IF;
+
+ IF v_played <= 0 THEN
+ RETURN jsonb_build_object(
+ 'user_id', p_user_id,
+ 'city', v_city,
+ 'elo_rating', v_elo,
+ 'global_rank', NULL,
+ 'city_rank', NULL,
+ 'global_total', v_global_total,
+ 'city_total', v_city_total
+ );
+ END IF;
+
+ SELECT COUNT(*)::INT + 1
+ INTO v_global_rank
+ FROM public.player_stats ps
+ WHERE ps.matches_played > 0
+ AND (
+ ps.elo_rating > v_elo
+ OR (ps.elo_rating = v_elo AND ps.wins > v_wins)
+ OR (ps.elo_rating = v_elo AND ps.wins = v_wins AND ps.matches_played > v_played)
+ OR (
+ ps.elo_rating = v_elo
+ AND ps.wins = v_wins
+ AND ps.matches_played = v_played
+ AND ps.user_id::TEXT < p_user_id::TEXT
+ )
+ );
+
+ IF v_city IS NOT NULL THEN
+ SELECT COUNT(*)::INT + 1
+ INTO v_city_rank
+ FROM public.player_stats ps
+ JOIN public.profiles pr ON pr.id = ps.user_id
+ WHERE ps.matches_played > 0
+ AND (pr.city ILIKE v_city OR ps.user_id = p_user_id)
+ AND (
+ ps.elo_rating > v_elo
+ OR (ps.elo_rating = v_elo AND ps.wins > v_wins)
+ OR (ps.elo_rating = v_elo AND ps.wins = v_wins AND ps.matches_played > v_played)
+ OR (
+ ps.elo_rating = v_elo
+ AND ps.wins = v_wins
+ AND ps.matches_played = v_played
+ AND ps.user_id::TEXT < p_user_id::TEXT
+ )
+ );
+ END IF;
+
+ RETURN jsonb_build_object(
+ 'user_id', p_user_id,
+ 'city', v_city,
+ 'elo_rating', v_elo,
+ 'global_rank', v_global_rank,
+ 'city_rank', v_city_rank,
+ 'global_total', v_global_total,
+ 'city_total', v_city_total
+ );
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.get_player_ranking(UUID) FROM PUBLIC;
+GRANT EXECUTE ON FUNCTION public.get_player_ranking(UUID) TO anon, authenticated, service_role;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260810240000_101_admin_private_access.sql b/supabase/migrations/20260810240000_101_admin_private_access.sql
new file mode 100644
index 0000000..33d2e08
--- /dev/null
+++ b/supabase/migrations/20260810240000_101_admin_private_access.sql
@@ -0,0 +1,103 @@
+-- Admins can view private (password-protected) matches, tournaments and leagues
+-- without entering a password.
+
+CREATE OR REPLACE FUNCTION public.auth_can_read_match(p_match_id uuid)
+RETURNS boolean
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $function$
+ SELECT EXISTS (
+ SELECT 1
+ FROM public.matches m
+ WHERE m.id = p_match_id
+ AND (
+ public.auth_is_admin()
+ OR m.visibility IN ('public', 'link')
+ OR m.creator_id = auth.uid()
+ OR public.auth_is_confirmed_in_match(m.id)
+ OR (
+ m.visibility = 'private'
+ AND EXISTS (
+ SELECT 1 FROM public.match_password_grants g
+ WHERE g.match_id = m.id AND g.user_id = auth.uid()
+ )
+ )
+ OR (
+ m.tournament_id IS NOT NULL
+ AND public.auth_can_read_tournament(m.tournament_id)
+ )
+ OR (
+ m.league_id IS NOT NULL
+ AND public.auth_can_read_league(m.league_id)
+ )
+ )
+ );
+$function$;
+
+CREATE OR REPLACE FUNCTION public.auth_can_read_tournament(p_tournament_id uuid)
+RETURNS boolean
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $function$
+ SELECT EXISTS (
+ SELECT 1 FROM public.tournaments t
+ WHERE t.id = p_tournament_id
+ AND (
+ public.auth_is_admin()
+ OR t.visibility = 'public'
+ OR t.visibility = 'link'
+ OR t.creator_id = auth.uid()
+ OR EXISTS (
+ SELECT 1 FROM public.tournament_pairs tp
+ WHERE tp.tournament_id = t.id
+ AND (
+ tp.player_a_user_id = auth.uid()
+ OR tp.player_b_user_id = auth.uid()
+ OR tp.created_by_user_id = auth.uid()
+ )
+ )
+ OR EXISTS (
+ SELECT 1 FROM public.tournament_password_grants g
+ WHERE g.tournament_id = t.id AND g.user_id = auth.uid()
+ )
+ )
+ );
+$function$;
+
+CREATE OR REPLACE FUNCTION public.auth_can_read_league(p_league_id uuid)
+RETURNS boolean
+LANGUAGE sql
+STABLE
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $function$
+ SELECT EXISTS (
+ SELECT 1 FROM public.leagues l
+ WHERE l.id = p_league_id
+ AND (
+ public.auth_is_admin()
+ OR l.visibility = 'public'
+ OR l.visibility = 'link'
+ OR l.creator_id = auth.uid()
+ OR EXISTS (
+ SELECT 1 FROM public.league_pairs lp
+ WHERE lp.league_id = l.id
+ AND (
+ lp.player_a_user_id = auth.uid()
+ OR lp.player_b_user_id = auth.uid()
+ OR lp.created_by_user_id = auth.uid()
+ )
+ )
+ OR EXISTS (
+ SELECT 1 FROM public.league_password_grants g
+ WHERE g.league_id = l.id AND g.user_id = auth.uid()
+ )
+ )
+ );
+$function$;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260811000000_102_match_timers_tournament_league.sql b/supabase/migrations/20260811000000_102_match_timers_tournament_league.sql
new file mode 100644
index 0000000..20ba4d5
--- /dev/null
+++ b/supabase/migrations/20260811000000_102_match_timers_tournament_league.sql
@@ -0,0 +1,383 @@
+-- 102: Ajustar timers de partidas
+-- - Torneos: 24h sin resultado -> finished_no_result (antes 12h)
+-- - Ligas round-robin: los fixtures se quedan "planned" hasta que se juegan
+-- (no auto-inicio por cron, no auto-cancelacion, no auto sin-resultado)
+-- - Partidas sueltas: 12h (sin cambio)
+-- - submit_match_result auto-inicia partidas de liga "planned" al recibir resultado
+
+-- =========================================================================
+-- 1. Revertir fixtures de liga que el cron arranco prematuramente a in_progress
+-- (solo los que no tienen resultado enviado)
+-- =========================================================================
+WITH reverted AS (
+ UPDATE public.matches
+ SET status = 'planned', updated_at = NOW()
+ WHERE status = 'in_progress'
+ AND league_id IS NOT NULL
+ AND NOT EXISTS (
+ SELECT 1 FROM public.match_results mr
+ WHERE mr.match_id = matches.id
+ AND mr.status IN ('pending_validation', 'confirmed')
+ )
+ RETURNING id
+)
+INSERT INTO public.match_state_transitions
+ (match_id, from_status, to_status, triggered_by, reason)
+SELECT id, 'in_progress', 'planned', 'system',
+ 'Reverted: league fixtures stay planned until played'
+FROM reverted;
+
+-- =========================================================================
+-- 2. Reescribir process_match_state_transitions
+-- =========================================================================
+CREATE OR REPLACE FUNCTION public.process_match_state_transitions()
+RETURNS void
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_match RECORD;
+ v_part RECORD;
+BEGIN
+
+ -- 1a. in_progress -> cancelled (standalone, roster no longer full)
+ FOR v_match IN
+ SELECT m.id, m.title, m.creator_id
+ FROM public.matches m
+ WHERE m.status = 'in_progress'
+ AND m.tournament_id IS NULL
+ AND m.league_id IS NULL
+ AND public.match_effective_roster_filled(m.id) < 4
+ LOOP
+ UPDATE public.matches SET status = 'cancelled', updated_at = NOW()
+ WHERE id = v_match.id;
+
+ INSERT INTO public.match_state_transitions
+ (match_id, from_status, to_status, triggered_by, reason)
+ VALUES
+ (v_match.id, 'in_progress', 'cancelled', 'system', 'roster_incomplete_while_in_progress');
+
+ PERFORM public.enqueue_notification(
+ p_user_id := v_match.creator_id,
+ p_type := 'match_cancelled_insufficient',
+ p_title := 'Partida cancelada',
+ p_body := 'La partida «' || v_match.title
+ || '» se canceló al no completarse la plantilla.',
+ p_payload_json := jsonb_build_object('match_id', v_match.id)
+ );
+ END LOOP;
+
+ -- 1b. planned -> in_progress (start_at reached AND roster full)
+ -- Solo partidas sueltas. Los fixtures de liga se quedan "planned"
+ -- hasta que alguien los inicia o envia un resultado.
+ FOR v_match IN
+ SELECT m.id, m.title, m.start_at
+ FROM public.matches m
+ WHERE m.status = 'planned'
+ AND m.start_at <= NOW()
+ AND m.tournament_id IS NULL
+ AND m.league_id IS NULL
+ AND public.match_effective_roster_filled(m.id) >= 4
+ LOOP
+ UPDATE public.matches SET status = 'in_progress', updated_at = NOW()
+ WHERE id = v_match.id;
+
+ INSERT INTO public.match_state_transitions
+ (match_id, from_status, to_status, triggered_by, reason)
+ VALUES
+ (v_match.id, 'planned', 'in_progress', 'system', 'start_at reached with full roster');
+
+ FOR v_part IN
+ SELECT user_id FROM public.match_participants
+ WHERE match_id = v_match.id AND state = 'confirmed' AND left_at IS NULL
+ LOOP
+ PERFORM public.enqueue_notification(
+ p_user_id := v_part.user_id,
+ p_type := 'match_started',
+ p_title := '¡Tu partida ha empezado!',
+ p_body := 'La partida «' || v_match.title || '» está en curso. Recuerda registrar el resultado.',
+ p_payload_json := jsonb_build_object('match_id', v_match.id)
+ );
+ END LOOP;
+ END LOOP;
+
+ -- 1c. planned -> cancelled (start_at reached, roster not full)
+ -- Solo partidas sueltas. Los fixtures de liga no se cancelan automaticamente.
+ FOR v_match IN
+ SELECT m.id, m.title, m.start_at, m.creator_id
+ FROM public.matches m
+ WHERE m.status = 'planned'
+ AND m.start_at <= NOW()
+ AND m.tournament_id IS NULL
+ AND m.league_id IS NULL
+ AND public.match_effective_roster_filled(m.id) < 4
+ LOOP
+ UPDATE public.matches SET status = 'cancelled', updated_at = NOW()
+ WHERE id = v_match.id;
+
+ INSERT INTO public.match_state_transitions
+ (match_id, from_status, to_status, triggered_by, reason)
+ VALUES
+ (v_match.id, 'planned', 'cancelled', 'system', 'insufficient_players_at_start');
+
+ PERFORM public.enqueue_notification(
+ p_user_id := v_match.creator_id,
+ p_type := 'match_cancelled_insufficient',
+ p_title := 'Partida cancelada',
+ p_body := 'La partida «' || v_match.title
+ || '» se canceló al no completarse el equipo a la hora de inicio.',
+ p_payload_json := jsonb_build_object('match_id', v_match.id)
+ );
+ END LOOP;
+
+ -- 2. in_progress -> finished_no_result
+ -- - Partidas sueltas: 12h
+ -- - Torneos: 24h
+ -- - Ligas: sin timeout automatico (se excluyen)
+ FOR v_match IN
+ SELECT id, title, start_at, tournament_id FROM public.matches
+ WHERE status = 'in_progress'
+ AND league_id IS NULL
+ AND (
+ (tournament_id IS NOT NULL AND start_at + INTERVAL '24 hours' <= NOW())
+ OR
+ (tournament_id IS NULL AND start_at + INTERVAL '12 hours' <= NOW())
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM public.match_results mr
+ WHERE mr.match_id = matches.id
+ AND mr.status = 'confirmed'
+ )
+ LOOP
+ UPDATE public.matches SET status = 'finished_no_result', updated_at = NOW()
+ WHERE id = v_match.id;
+
+ INSERT INTO public.match_state_transitions
+ (match_id, from_status, to_status, triggered_by, reason)
+ VALUES
+ (v_match.id, 'in_progress', 'finished_no_result', 'system',
+ CASE WHEN v_match.tournament_id IS NOT NULL
+ THEN '24h without confirmed result (tournament)'
+ ELSE '12h without confirmed result' END);
+
+ FOR v_part IN
+ SELECT user_id FROM public.match_participants
+ WHERE match_id = v_match.id AND state = 'confirmed'
+ LOOP
+ PERFORM public.enqueue_notification(
+ p_user_id := v_part.user_id,
+ p_type := 'match_finished_no_result',
+ p_title := 'Partida finalizada sin resultado',
+ p_body := 'La partida «' || v_match.title || '» se cerró sin resultado registrado.',
+ p_payload_json := jsonb_build_object('match_id', v_match.id)
+ );
+ END LOOP;
+ END LOOP;
+
+ -- 3. Reminder 24h before (solo partidas sueltas; las de liga/torneo no usan
+ -- start_at como hora real de juego)
+ FOR v_match IN
+ SELECT id, title, start_at FROM public.matches
+ WHERE status = 'planned'
+ AND tournament_id IS NULL
+ AND league_id IS NULL
+ AND start_at BETWEEN NOW() + INTERVAL '23 hours 59 minutes'
+ AND NOW() + INTERVAL '24 hours 1 minute'
+ LOOP
+ FOR v_part IN
+ SELECT user_id FROM public.match_participants
+ WHERE match_id = v_match.id AND state = 'confirmed'
+ LOOP
+ IF NOT EXISTS (
+ SELECT 1 FROM public.notification_queue nq
+ WHERE nq.user_id = v_part.user_id
+ AND nq.type = 'reminder_24h'
+ AND nq.payload_json->>'match_id' = v_match.id::text
+ ) THEN
+ PERFORM public.enqueue_notification(
+ p_user_id := v_part.user_id,
+ p_type := 'reminder_24h',
+ p_title := 'Tu partida es mañana',
+ p_body := 'Recuerda que mañana tienes la partida «' || v_match.title || '».',
+ p_payload_json := jsonb_build_object('match_id', v_match.id)
+ );
+ END IF;
+ END LOOP;
+ END LOOP;
+
+ -- 4. Reminder 2h before (solo partidas sueltas)
+ FOR v_match IN
+ SELECT id, title, start_at FROM public.matches
+ WHERE status = 'planned'
+ AND tournament_id IS NULL
+ AND league_id IS NULL
+ AND start_at BETWEEN NOW() + INTERVAL '1 hour 59 minutes'
+ AND NOW() + INTERVAL '2 hours 1 minute'
+ LOOP
+ FOR v_part IN
+ SELECT user_id FROM public.match_participants
+ WHERE match_id = v_match.id AND state = 'confirmed'
+ LOOP
+ IF NOT EXISTS (
+ SELECT 1 FROM public.notification_queue nq
+ WHERE nq.user_id = v_part.user_id
+ AND nq.type = 'reminder_2h'
+ AND nq.payload_json->>'match_id' = v_match.id::text
+ ) THEN
+ PERFORM public.enqueue_notification(
+ p_user_id := v_part.user_id,
+ p_type := 'reminder_2h',
+ p_title := 'Tu partida empieza en 2 horas',
+ p_body := '¡Prepárate! La partida «' || v_match.title || '» empieza en 2 horas.',
+ p_payload_json := jsonb_build_object('match_id', v_match.id)
+ );
+ END IF;
+ END LOOP;
+ END LOOP;
+
+ -- 5. Reminder 5h in_progress (solo partidas sueltas y torneos;
+ -- las de liga pueden durar dias)
+ FOR v_match IN
+ SELECT id, title, start_at FROM public.matches
+ WHERE status = 'in_progress'
+ AND league_id IS NULL
+ AND start_at + INTERVAL '4 hours 59 minutes' <= NOW()
+ AND start_at + INTERVAL '5 hours 1 minute' >= NOW()
+ AND NOT EXISTS (
+ SELECT 1 FROM public.match_results mr
+ WHERE mr.match_id = matches.id
+ AND mr.status IN ('confirmed', 'pending_validation')
+ )
+ LOOP
+ FOR v_part IN
+ SELECT user_id FROM public.match_participants
+ WHERE match_id = v_match.id AND state = 'confirmed'
+ LOOP
+ IF NOT EXISTS (
+ SELECT 1 FROM public.notification_queue nq
+ WHERE nq.user_id = v_part.user_id
+ AND nq.type = 'reminder_5h_in_progress'
+ AND nq.payload_json->>'match_id' = v_match.id::text
+ ) THEN
+ PERFORM public.enqueue_notification(
+ p_user_id := v_part.user_id,
+ p_type := 'reminder_5h_in_progress',
+ p_title := '¿Habéis terminado la partida?',
+ p_body := 'Lleváis 5 horas en «' || v_match.title || '». No olvidéis registrar el resultado.',
+ p_payload_json := jsonb_build_object('match_id', v_match.id)
+ );
+ END IF;
+ END LOOP;
+ END LOOP;
+
+END;
+$$;
+
+-- =========================================================================
+-- 3. Modificar submit_match_result: auto-iniciar partidas de liga "planned"
+-- =========================================================================
+CREATE OR REPLACE FUNCTION public.submit_match_result(
+ p_match_id UUID,
+ p_team_a_games INT,
+ p_team_b_games INT
+)
+RETURNS public.match_results
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_match public.matches%ROWTYPE;
+ v_team TEXT;
+ v_status TEXT;
+ v_from_status TEXT;
+ v_row public.match_results%ROWTYPE;
+ v_needs_validation BOOLEAN;
+BEGIN
+ IF auth.uid() IS NULL THEN RAISE EXCEPTION 'not_authenticated'; END IF;
+
+ SELECT * INTO v_match FROM public.matches WHERE id = p_match_id FOR UPDATE;
+ IF NOT FOUND THEN RAISE EXCEPTION 'match_not_found'; END IF;
+
+ -- Las partidas de liga "planned" se auto-inician al recibir un resultado.
+ -- Las partidas sueltas "planned" deben iniciarse manualmente primero.
+ IF v_match.status = 'planned' THEN
+ IF v_match.league_id IS NULL THEN
+ RAISE EXCEPTION 'invalid_match_status';
+ END IF;
+ UPDATE public.matches
+ SET status = 'in_progress',
+ start_at = COALESCE(start_at, NOW()),
+ updated_at = NOW()
+ WHERE id = p_match_id;
+ INSERT INTO public.match_state_transitions
+ (match_id, from_status, to_status, triggered_by, user_id, reason)
+ VALUES
+ (p_match_id, 'planned', 'in_progress', 'user', auth.uid(),
+ 'Auto-started on result submission (league)');
+ v_match.status := 'in_progress';
+ END IF;
+
+ IF v_match.status NOT IN ('in_progress', 'finished_no_result') THEN
+ RAISE EXCEPTION 'invalid_match_status';
+ END IF;
+
+ PERFORM public.validate_match_scores(p_team_a_games, p_team_b_games, v_match.duration_target_games);
+
+ IF EXISTS (
+ SELECT 1 FROM public.match_results mr
+ WHERE mr.match_id = p_match_id AND mr.status IN ('pending_validation', 'confirmed')
+ ) THEN
+ RAISE EXCEPTION 'result_already_exists';
+ END IF;
+
+ SELECT mp.team INTO v_team
+ FROM public.match_participants mp
+ WHERE mp.match_id = p_match_id
+ AND mp.user_id = auth.uid()
+ AND mp.state = 'confirmed'
+ AND mp.left_at IS NULL
+ LIMIT 1;
+
+ IF v_team IS NULL THEN RAISE EXCEPTION 'not_participant'; END IF;
+
+ v_needs_validation := public.rival_team_has_registered_participant(p_match_id, v_team);
+ v_status := CASE WHEN v_needs_validation THEN 'pending_validation' ELSE 'confirmed' END;
+
+ INSERT INTO public.match_results (
+ match_id, team_a_games, team_b_games,
+ submitted_by_team, submitted_by_user_id, status
+ ) VALUES (
+ p_match_id, p_team_a_games, p_team_b_games,
+ v_team, auth.uid(), v_status
+ )
+ RETURNING * INTO v_row;
+
+ IF NOT v_needs_validation THEN
+ v_from_status := v_match.status;
+ PERFORM set_config('app.suppress_match_change_notify', '1', true);
+ UPDATE public.matches SET status = 'finished', updated_at = NOW() WHERE id = p_match_id;
+ PERFORM set_config('app.suppress_match_change_notify', '0', true);
+
+ INSERT INTO public.match_state_transitions (
+ match_id, from_status, to_status, triggered_by, user_id, reason
+ ) VALUES (
+ p_match_id, v_from_status, 'finished', 'user', auth.uid(),
+ 'Resultado confirmado (rival solo texto)'
+ );
+
+ IF v_match.tournament_id IS NOT NULL THEN
+ PERFORM public.advance_tournament_round(p_match_id);
+ END IF;
+ END IF;
+
+ RETURN v_row;
+END;
+$$;
+
+COMMENT ON FUNCTION public.process_match_state_transitions IS
+ 'Cron cada minuto. Torneos: 24h sin resultado -> finished_no_result. Ligas: sin timeout automatico (fixtures se quedan planned hasta jugarse). Partidas sueltas: 12h.';
+
+COMMENT ON FUNCTION public.submit_match_result IS
+ 'Registra resultado. Las partidas de liga en estado planned se auto-inician al recibir el resultado.';
diff --git a/supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql b/supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql
new file mode 100644
index 0000000..d9e2ace
--- /dev/null
+++ b/supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql
@@ -0,0 +1,316 @@
+-- 103: Recalcular ELO + agregados/logros al leer get_player_stats
+-- Rebuild de ELO solo del usuario (no muta rivales). Usa el ELO actual de
+-- oponentes/compañeros como aproximación histórica.
+
+CREATE OR REPLACE FUNCTION public.rebuild_player_elo(p_user_id UUID)
+RETURNS VOID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ r RECORD;
+ v_team_a UUID[];
+ v_team_b UUID[];
+ v_elo_a NUMERIC;
+ v_elo_b NUMERIC;
+ v_expected_a NUMERIC;
+ v_score_a NUMERIC;
+ v_delta NUMERIC;
+ v_running INT := 1200;
+ v_uid UUID;
+ k CONSTANT NUMERIC := 32;
+BEGIN
+ IF p_user_id IS NULL THEN
+ RETURN;
+ END IF;
+
+ PERFORM public.ensure_player_stats_row(p_user_id);
+
+ UPDATE public.player_stats
+ SET elo_rating = 1200, updated_at = NOW()
+ WHERE user_id = p_user_id;
+
+ FOR r IN
+ SELECT match_id, team, team_a_games, team_b_games
+ FROM public._player_confirmed_match_rows(p_user_id)
+ LOOP
+ IF r.team_a_games = r.team_b_games THEN
+ CONTINUE;
+ END IF;
+
+ SELECT COALESCE(array_agg(mp.user_id), ARRAY[]::UUID[])
+ INTO v_team_a
+ FROM public.match_participants mp
+ WHERE mp.match_id = r.match_id AND mp.state = 'confirmed' AND mp.team = 'A';
+
+ SELECT COALESCE(array_agg(mp.user_id), ARRAY[]::UUID[])
+ INTO v_team_b
+ FROM public.match_participants mp
+ WHERE mp.match_id = r.match_id AND mp.state = 'confirmed' AND mp.team = 'B';
+
+ -- Sin rivales con cuenta no hay cambio de ELO (igual que apply_match_elo)
+ IF cardinality(v_team_a) = 0 OR cardinality(v_team_b) = 0 THEN
+ CONTINUE;
+ END IF;
+
+ FOREACH v_uid IN ARRAY (v_team_a || v_team_b)
+ LOOP
+ PERFORM public.ensure_player_stats_row(v_uid);
+ END LOOP;
+
+ -- El usuario en rebuild usa v_running; rivales/compañeros, su ELO actual
+ SELECT AVG(
+ CASE WHEN ps.user_id = p_user_id THEN v_running ELSE ps.elo_rating END
+ )::NUMERIC
+ INTO v_elo_a
+ FROM public.player_stats ps
+ WHERE ps.user_id = ANY (v_team_a);
+
+ SELECT AVG(
+ CASE WHEN ps.user_id = p_user_id THEN v_running ELSE ps.elo_rating END
+ )::NUMERIC
+ INTO v_elo_b
+ FROM public.player_stats ps
+ WHERE ps.user_id = ANY (v_team_b);
+
+ v_expected_a := 1.0 / (1.0 + POWER(10.0, (v_elo_b - v_elo_a) / 400.0));
+ v_score_a := CASE WHEN r.team_a_games > r.team_b_games THEN 1.0 ELSE 0.0 END;
+
+ IF r.team = 'A' THEN
+ v_delta := ROUND(k * (v_score_a - v_expected_a));
+ ELSE
+ v_delta := ROUND(k * ((1.0 - v_score_a) - (1.0 - v_expected_a)));
+ END IF;
+
+ v_running := GREATEST(100, v_running + v_delta::INT);
+ END LOOP;
+
+ UPDATE public.player_stats
+ SET elo_rating = v_running, updated_at = NOW()
+ WHERE user_id = p_user_id;
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.rebuild_player_elo(UUID) FROM PUBLIC;
+
+CREATE OR REPLACE FUNCTION public.refresh_player_stats(p_user_id UUID)
+RETURNS VOID
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_player_updated TIMESTAMPTZ;
+ v_last_confirmed TIMESTAMPTZ;
+BEGIN
+ IF p_user_id IS NULL THEN
+ RETURN;
+ END IF;
+
+ -- Si player_stats ya está al día con el último partido confirmado, evita recalcular
+ -- (aproximación de "no recalcular ELO en cada lectura").
+ SELECT ps.updated_at
+ INTO v_player_updated
+ FROM public.player_stats ps
+ WHERE ps.user_id = p_user_id;
+
+ SELECT MAX(m.updated_at)
+ INTO v_last_confirmed
+ FROM public.match_participants mp
+ JOIN public.matches m ON m.id = mp.match_id
+ JOIN public.match_results mr
+ ON mr.match_id = m.id AND mr.status = 'confirmed'
+ WHERE mp.user_id = p_user_id
+ AND mp.state = 'confirmed';
+
+ IF v_player_updated IS NOT NULL
+ AND v_last_confirmed IS NOT NULL
+ AND v_player_updated >= v_last_confirmed
+ THEN
+ RETURN;
+ END IF;
+
+ PERFORM public.rebuild_player_elo(p_user_id);
+ PERFORM public.recompute_player_stats_aggregates(p_user_id);
+END;
+$$;
+
+REVOKE ALL ON FUNCTION public.refresh_player_stats(UUID) FROM PUBLIC;
+
+COMMENT ON FUNCTION public.refresh_player_stats(UUID) IS
+ 'Recalcula ELO (solo ese usuario), agregados y logros. Se invoca desde get_player_stats.';
+
+-- Re-declare get_player_stats in a readable way:
+-- it calls refresh_player_stats directly, instead of relying on a fragile
+-- pg_get_functiondef text rewrite.
+CREATE OR REPLACE FUNCTION public.get_player_stats(p_user_id UUID)
+RETURNS JSONB
+LANGUAGE plpgsql
+VOLATILE
+SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_ps public.player_stats%ROWTYPE;
+ v_venues JSONB;
+ v_partners JSONB;
+ v_nemesis JSONB;
+ v_victim JSONB;
+ v_most_faced JSONB;
+ v_t_part INT;
+ v_win_rate NUMERIC;
+BEGIN
+ IF p_user_id IS NULL THEN
+ RETURN NULL;
+ END IF;
+
+ PERFORM public.refresh_player_stats(p_user_id);
+ SELECT * INTO v_ps FROM public.player_stats WHERE user_id = p_user_id;
+
+ IF v_ps.matches_played > 0 THEN
+ v_win_rate := ROUND((v_ps.wins::NUMERIC / v_ps.matches_played::NUMERIC) * 100, 1);
+ ELSE
+ v_win_rate := 0;
+ END IF;
+
+ SELECT COALESCE(jsonb_agg(row_to_json(v)::jsonb), '[]'::jsonb)
+ INTO v_venues
+ FROM (
+ SELECT
+ city,
+ place_text,
+ COUNT(*)::INT AS matches,
+ COUNT(*) FILTER (
+ WHERE public._player_won_match(team, team_a_games, team_b_games) IS TRUE
+ )::INT AS wins,
+ CASE WHEN COUNT(*) > 0 THEN
+ ROUND(
+ (COUNT(*) FILTER (
+ WHERE public._player_won_match(team, team_a_games, team_b_games) IS TRUE
+ )::NUMERIC / COUNT(*)::NUMERIC) * 100,
+ 1
+ )
+ ELSE 0 END AS win_rate
+ FROM public._player_confirmed_match_rows(p_user_id)
+ GROUP BY city, place_text
+ ORDER BY COUNT(*) DESC, city ASC
+ LIMIT 5
+ ) v;
+
+ SELECT COALESCE(jsonb_agg(row_to_json(p)::jsonb), '[]'::jsonb)
+ INTO v_partners
+ FROM (
+ SELECT
+ partner.user_id,
+ pr.display_name,
+ pr.photo_url,
+ COUNT(*)::INT AS matches,
+ COUNT(*) FILTER (
+ WHERE public._player_won_match(me.team, mr.team_a_games, mr.team_b_games) IS TRUE
+ )::INT AS wins,
+ CASE WHEN COUNT(*) > 0 THEN
+ ROUND(
+ (COUNT(*) FILTER (
+ WHERE public._player_won_match(me.team, mr.team_a_games, mr.team_b_games) IS TRUE
+ )::NUMERIC / COUNT(*)::NUMERIC) * 100,
+ 1
+ )
+ ELSE 0 END AS win_rate
+ FROM public.match_participants me
+ JOIN public.match_participants partner
+ ON partner.match_id = me.match_id
+ AND partner.user_id <> me.user_id
+ AND partner.team = me.team
+ AND partner.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'
+ JOIN public.profiles pr ON pr.id = partner.user_id
+ WHERE me.user_id = p_user_id
+ AND me.state = 'confirmed'
+ AND m.status = 'finished'
+ AND COALESCE(m.tournament_is_bye, FALSE) = FALSE
+ GROUP BY partner.user_id, pr.display_name, pr.photo_url
+ ORDER BY COUNT(*) DESC, wins DESC
+ LIMIT 5
+ ) p;
+
+ -- Rivalries: aggregate vs each opponent on the other team
+ WITH rival_stats AS (
+ SELECT
+ opp.user_id,
+ pr.display_name,
+ pr.photo_url,
+ COUNT(*)::INT AS matches,
+ COUNT(*) FILTER (
+ WHERE public._player_won_match(me.team, mr.team_a_games, mr.team_b_games) IS TRUE
+ )::INT AS wins,
+ COUNT(*) FILTER (
+ WHERE public._player_won_match(me.team, mr.team_a_games, mr.team_b_games) IS FALSE
+ )::INT AS losses
+ 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'
+ JOIN public.profiles pr ON pr.id = opp.user_id
+ 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 NOT NULL
+ GROUP BY opp.user_id, pr.display_name, pr.photo_url
+ )
+ SELECT
+ (SELECT row_to_json(x)::jsonb FROM (
+ SELECT user_id, display_name, photo_url, matches, wins, losses
+ FROM rival_stats WHERE losses > 0 ORDER BY losses DESC, matches DESC LIMIT 1
+ ) x),
+ (SELECT row_to_json(x)::jsonb FROM (
+ SELECT user_id, display_name, photo_url, matches, wins, losses
+ FROM rival_stats WHERE wins > 0 ORDER BY wins DESC, matches DESC LIMIT 1
+ ) x),
+ (SELECT row_to_json(x)::jsonb FROM (
+ SELECT user_id, display_name, photo_url, matches, wins, losses
+ FROM rival_stats ORDER BY matches DESC, wins DESC LIMIT 1
+ ) x)
+ INTO v_nemesis, v_victim, v_most_faced;
+
+ SELECT COUNT(DISTINCT m.tournament_id)::INT
+ INTO v_t_part
+ FROM public.match_participants mp
+ JOIN public.matches m ON m.id = mp.match_id
+ WHERE mp.user_id = p_user_id
+ AND mp.state = 'confirmed'
+ AND m.tournament_id IS NOT NULL;
+
+ RETURN jsonb_build_object(
+ 'user_id', p_user_id,
+ 'elo_rating', v_ps.elo_rating,
+ 'matches_played', v_ps.matches_played,
+ 'wins', v_ps.wins,
+ 'losses', v_ps.losses,
+ 'win_rate', v_win_rate,
+ 'current_streak', v_ps.current_streak,
+ 'best_win_streak', v_ps.best_win_streak,
+ 'last_form', v_ps.last_form,
+ 'badges', v_ps.badges,
+ 'tournaments_won', v_ps.tournaments_won,
+ 'tournament_finals', v_ps.tournament_finals,
+ 'tournament_thirds', v_ps.tournament_thirds,
+ 'tournaments_participated', COALESCE(v_t_part, 0),
+ 'venues', COALESCE(v_venues, '[]'::jsonb),
+ 'partners', COALESCE(v_partners, '[]'::jsonb),
+ 'rivalries', jsonb_build_object(
+ 'nemesis', v_nemesis,
+ 'best_victim', v_victim,
+ 'most_faced', v_most_faced
+ )
+ );
+END;
+$$;
+
+NOTIFY pgrst, 'reload schema';