From ca9a3ca2c7b463b0f278d693c17c7817f5ddef48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:50:45 +0200 Subject: [PATCH 01/24] chore(deps): bump the npm-runtime-patch-minor group with 2 updates (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm-runtime-patch-minor group with 2 updates: [@supabase/supabase-js](https://github.com/supabase/supabase-js/tree/HEAD/packages/core/supabase-js) and [posthog-react-native](https://github.com/PostHog/posthog-js/tree/HEAD/packages/react-native). Updates `@supabase/supabase-js` from 2.110.8 to 2.111.0 - [Release notes](https://github.com/supabase/supabase-js/releases) - [Changelog](https://github.com/supabase/supabase-js/blob/master/packages/core/supabase-js/CHANGELOG.md) - [Commits](https://github.com/supabase/supabase-js/commits/v2.111.0/packages/core/supabase-js) Updates `posthog-react-native` from 4.61.0 to 4.61.1 - [Release notes](https://github.com/PostHog/posthog-js/releases) - [Changelog](https://github.com/PostHog/posthog-js/blob/main/packages/react-native/CHANGELOG.md) - [Commits](https://github.com/PostHog/posthog-js/commits/posthog-react-native@4.61.1/packages/react-native) --- updated-dependencies: - dependency-name: "@supabase/supabase-js" dependency-version: 2.111.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: npm-runtime-patch-minor - dependency-name: posthog-react-native dependency-version: 4.61.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-runtime-patch-minor ... Signed-off-by: dependabot[bot] Co-authored-by: Javier Peña Gómez <60773775+Japegomez@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 59 +++++++++++++++++++++++++---------------------- package.json | 4 ++-- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0fadef8..499b288 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@react-native-community/datetimepicker": "8.4.4", "@react-native-community/slider": "5.0.1", "@sentry/react-native": "~7.2.0", - "@supabase/supabase-js": "^2.110.8", + "@supabase/supabase-js": "^2.111.0", "@tanstack/react-query": "^5.101.4", "expo": "~54.0.36", "expo-apple-authentication": "~8.0.8", @@ -38,7 +38,7 @@ "expo-status-bar": "~3.0.9", "expo-store-review": "~9.0.9", "expo-web-browser": "~15.0.11", - "posthog-react-native": "^4.61.0", + "posthog-react-native": "^4.61.1", "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.83.0", @@ -5104,9 +5104,9 @@ "license": "MIT" }, "node_modules/@supabase/auth-js": { - "version": "2.110.8", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.8.tgz", - "integrity": "sha512-TQ5neTUDX2C2WmyYa03yGhLMkhdE/SkHXtK8/qxO/APUy3rsymsJCBP48p4jcN6iO2G0ow6RRexQd2mX+dSyJg==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.111.0.tgz", + "integrity": "sha512-hbRLgyQZEX0SDyF4LYXpv94qOIQyFATfpT5SIs2V0SisHnisVRkYgiPQWzv80l4S2mO3qof78rmoH9j5zoFsYQ==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -5116,9 +5116,9 @@ } }, "node_modules/@supabase/functions-js": { - "version": "2.110.8", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.8.tgz", - "integrity": "sha512-5yB9TLYzvv2oSQxwb0gamEvIAsuH66pVt7AM/pz03S7wN6ehD34GNgbShrccetqPedXQSz7e/1hAJ9NeEhoZVg==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.111.0.tgz", + "integrity": "sha512-RW/OCsd6MO592zU8ifzP8/f8XzxxIdpb+Up5XaOtE26Fw+3zTp475WX7+GuuktiD1WF8pFUDe6khUPbMp77RCw==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -5134,9 +5134,9 @@ "license": "MIT" }, "node_modules/@supabase/postgrest-js": { - "version": "2.110.8", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.8.tgz", - "integrity": "sha512-QeRROxl1PpOZw5Jzi7BwdN9icsycMrLlCCvsjS0hYLW+nZoaT46zdagz/glJirj8jHF4jSd5Jyipuae2cBClCw==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.111.0.tgz", + "integrity": "sha512-pcqeDsnWP0lx9GawduYxNZJHeuTm53O7L0SC8RF8tniV3GWIPY6me6OTdnwzdwNUmNy1dzUVtSyIfE6+OflzPQ==", "license": "MIT", "dependencies": { "tslib": "2.8.1" @@ -5146,9 +5146,9 @@ } }, "node_modules/@supabase/realtime-js": { - "version": "2.110.8", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.8.tgz", - "integrity": "sha512-mwX7ituX6O31fLf+0g65rpLlNxqgnMaPltPsQwzox6jfmbfVl3tCxXrfr3HEsQcCRjpjuJG1+A0vFzP1yVjKHA==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.111.0.tgz", + "integrity": "sha512-6oRf/vZyRwg8f8GbFSJkrD2w4HAu/yTvyMViHXHS+H5hNJzdXCrUR7cP5oW7daT3YlRnzRPY9LcGSJKZAmfMSg==", "license": "MIT", "dependencies": { "@supabase/phoenix": "0.4.5", @@ -5159,9 +5159,9 @@ } }, "node_modules/@supabase/storage-js": { - "version": "2.110.8", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.8.tgz", - "integrity": "sha512-CcfhkZFBLxsthgUabZKxwfsoXdrikIGsL3LsGoV3FZTqCMx/s1y49taT4jT/oya5+1IuB0sFFHw6pF0o0iJniQ==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.111.0.tgz", + "integrity": "sha512-UEViNmTzVOxE8dqUA81wls+n9xgmlvSFfhfwo6QxrO4kQOytCYyw3ciYFoi4XoD4Jl95NJ3jnndHN5iIudWzqw==", "license": "MIT", "dependencies": { "iceberg-js": "^0.8.1", @@ -5172,16 +5172,16 @@ } }, "node_modules/@supabase/supabase-js": { - "version": "2.110.8", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.8.tgz", - "integrity": "sha512-E5qzoe74zhJRv4wRcbO9eMYzeQDb/+h6c603pL8shcxLGBjTKsIF7XXj05IcNj23TLDgJN1WkMw7mwAPyu5dZg==", + "version": "2.111.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.111.0.tgz", + "integrity": "sha512-9q0/AULthQnWeiDh1vGyjoJZbSY04bu6qHcWit70pqEYn5Kv/dkCPY62Ja1123jEnJbB9Vd2pjY7Kvk/lK3peA==", "license": "MIT", "dependencies": { - "@supabase/auth-js": "2.110.8", - "@supabase/functions-js": "2.110.8", - "@supabase/postgrest-js": "2.110.8", - "@supabase/realtime-js": "2.110.8", - "@supabase/storage-js": "2.110.8" + "@supabase/auth-js": "2.111.0", + "@supabase/functions-js": "2.111.0", + "@supabase/postgrest-js": "2.111.0", + "@supabase/realtime-js": "2.111.0", + "@supabase/storage-js": "2.111.0" }, "engines": { "node": ">=22.0.0" @@ -15652,14 +15652,17 @@ "license": "MIT" }, "node_modules/posthog-react-native": { - "version": "4.61.0", - "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.61.0.tgz", - "integrity": "sha512-0kX+bGa0J4aGfbfYxcngI1jnW6CZhUyhhltL887zbf5q0Yp3lC3ec1J9nKGoREch7nyBfZUGL5vYvO89WkCpyw==", + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/posthog-react-native/-/posthog-react-native-4.61.1.tgz", + "integrity": "sha512-wnDKO2q6rKL5KoWCFJosRHnCuHrSm+iBjI87N00O9y+BZhqqvJfVpS7wI87VF+AA09ib9gKNnfK9a8aeTN0W/A==", "license": "MIT", "dependencies": { "@posthog/core": "^1.45.1", "@posthog/types": "^1.398.0" }, + "bin": { + "posthog-react-native-xcode": "tooling/posthog-xcode.sh" + }, "peerDependencies": { "@posthog/react-native-plugin": ">= 2.2.0", "@react-native-async-storage/async-storage": ">=1.0.0", diff --git a/package.json b/package.json index e291fa4..b7762bd 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@react-native-community/datetimepicker": "8.4.4", "@react-native-community/slider": "5.0.1", "@sentry/react-native": "~7.2.0", - "@supabase/supabase-js": "^2.110.8", + "@supabase/supabase-js": "^2.111.0", "@tanstack/react-query": "^5.101.4", "expo": "~54.0.36", "expo-apple-authentication": "~8.0.8", @@ -46,7 +46,7 @@ "expo-status-bar": "~3.0.9", "expo-store-review": "~9.0.9", "expo-web-browser": "~15.0.11", - "posthog-react-native": "^4.61.0", + "posthog-react-native": "^4.61.1", "react": "19.1.0", "react-dom": "19.1.0", "react-hook-form": "^7.83.0", From 251114c5aadb293e146cea912d7ce579be74c731 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:50:49 +0200 Subject: [PATCH 02/24] chore(deps): bump fast-uri from 3.1.4 to 3.1.5 (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: Javier Peña Gómez <60773775+Japegomez@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 499b288..234c6cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9660,9 +9660,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", From 299b95d2389220395f268f6a9cdff720752d3139 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:50:52 +0200 Subject: [PATCH 03/24] chore(deps): bump undici from 6.27.0 to 6.28.0 (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [undici](https://github.com/nodejs/undici) from 6.27.0 to 6.28.0. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v6.27.0...v6.28.0) --- updated-dependencies: - dependency-name: undici dependency-version: 6.28.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: Javier Peña Gómez <60773775+Japegomez@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 234c6cf..c4a2af2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18061,9 +18061,9 @@ } }, "node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" From 239575d825c7e85120e29bf1c2ee270b51393732 Mon Sep 17 00:00:00 2001 From: Japegomez Date: Mon, 10 Aug 2026 07:52:24 +0200 Subject: [PATCH 04/24] chore(security): tighten Dependabot and add SECURITY.md Route version updates to develop with prod/dev/security groups, cooldown, and Expo-safe ignores; document reporting policy and maintenance tasks. Co-authored-by: Cursor --- .github/dependabot.yml | 57 +++++++++++++++++++++++++++------- REQUIREMENTS.md | 4 +-- SECURITY.md | 69 ++++++++++++++++++++++++++++++++++++++++++ TASKS.md | 10 ++++-- 4 files changed, 124 insertions(+), 16 deletions(-) create mode 100644 SECURITY.md diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a5a1cd4..fe80549 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -2,35 +2,50 @@ version: 2 updates: - package-ecosystem: npm directory: / + target-branch: develop schedule: interval: weekly day: monday time: '09:00' timezone: Europe/Madrid open-pull-requests-limit: 3 + assignees: + - Japegomez + labels: + - dependencies + commit-message: + prefix: chore + prefix-development: chore + include: scope + cooldown: + default-days: 3 + semver-minor-days: 5 + semver-patch-days: 2 + exclude-paths: + - vendor/** groups: + security-patches: + applies-to: security-updates + patterns: + - '*' + update-types: + - minor + - patch npm-dev-patch-minor: dependency-type: development update-types: - minor - patch - npm-runtime-patch-minor: - patterns: - - '@supabase/*' - - '@tanstack/*' - - '@hookform/resolvers' - - 'react-hook-form' - - 'posthog-react-native' - - 'zod' - - 'zustand' + npm-prod-patch-minor: + dependency-type: production update-types: - minor - patch ignore: - # Block all semver-major bumps (Expo SDK, RN, TypeScript, etc.) — upgrade manually + # Block all semver-major bumps (Expo SDK, RN, TypeScript, etc.) — use `npx expo upgrade` / manual - dependency-name: '*' update-types: ['version-update:semver-major'] - # Expo SDK pins these — use npx expo install on SDK upgrades + # Expo SDK pins these — use `npx expo install` / `npx expo upgrade` on SDK upgrades - dependency-name: 'react' update-types: - 'version-update:semver-minor' @@ -70,13 +85,33 @@ updates: - package-ecosystem: github-actions directory: / + target-branch: develop schedule: interval: weekly day: monday time: '09:00' timezone: Europe/Madrid open-pull-requests-limit: 1 + assignees: + - Japegomez + labels: + - dependencies + - github_actions + commit-message: + prefix: chore + include: scope + cooldown: + default-days: 3 + semver-minor-days: 5 + semver-patch-days: 2 groups: + security-patches: + applies-to: security-updates + patterns: + - '*' + update-types: + - minor + - patch github-actions-patch-minor: patterns: - '*' diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 58bd467..7fb6718 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -11,8 +11,8 @@ App móvil para jugadores de mus en España que permite encontrar contrincantes ### Decisiones clave - **Marca y legal (may. 2026):** nombre comercial **jugaMUS** (`APP_DISPLAY_NAME`; nombre en launcher vía `app.json`). Deep link scheme `jugamus`. Términos y privacidad con texto estático y disclaimer «Texto legal definitivo pendiente de revisión jurídica.» hasta revisión legal. -- **CI/CD (jun. 2026):** workflow reutilizable `quality.yml` (job `Quality`: Gitleaks, `expo-doctor`, lint, tests, cobertura 1%). `eas.yml` en push a `main`: quality → tag `v{version}-{YYYYMMDD.HHmm}` → build Android/iOS → submit Play + TestFlight. Dependabot conservador (sin bumps Expo/RN). Secrets GitHub: `EXPO_TOKEN`, `GOOGLE_PLAY_SERVICE_KEY_JSON`. EAS `production`: `GOOGLE_SERVICES_JSON`, `SENTRY_AUTH_TOKEN`, **`EXPO_PUBLIC_SUPABASE_URL`** / **`EXPO_PUBLIC_SUPABASE_ANON_KEY`**, **`EXPO_PUBLIC_INVITE_HOST`**. iOS: `ascAppId` `6775626292`. -- **Legal / Play (may. 2026):** URLs públicas de privacidad y eliminación de cuenta vía **GitHub Pages** (`docs/` en `main`, carpeta `/docs`). Contacto de soporte: `japenago@gmail.com`. Package Android: `com.javiwacho.musapp`; slug EAS `musapp`. +- **CI/CD (jun.–ago. 2026):** workflow reutilizable `quality.yml` (job `Quality`: Gitleaks, `expo-doctor`, lint, tests, cobertura 1%). `eas.yml` en push a `main`: quality → tag `v{version}-{YYYYMMDD.HHmm}` → build Android/iOS → submit Play + TestFlight. Dependabot (npm + github-actions): version updates a **`develop`**, sin majors ni patch/minor del stack Expo/RN (upgrade de SDK con `npx expo upgrade`), grupos prod/dev + security, cooldown. Política de vulnerabilidades en `SECURITY.md`. Secrets GitHub: `EXPO_TOKEN`, `GOOGLE_PLAY_SERVICE_KEY_JSON`. EAS `production`: `GOOGLE_SERVICES_JSON`, `SENTRY_AUTH_TOKEN`, **`EXPO_PUBLIC_SUPABASE_URL`** / **`EXPO_PUBLIC_SUPABASE_ANON_KEY`**, **`EXPO_PUBLIC_INVITE_HOST`**. iOS: `ascAppId` `6775626292`. +- **Legal / Play (may. 2026):** URLs públicas de privacidad y eliminación de cuenta vía **GitHub Pages** (`docs/` en `main`, carpeta `/docs`). Contacto de soporte / seguridad: `japenago@gmail.com`. Package Android: `com.javiwacho.musapp`; slug EAS `musapp`. - **Partidas (may. 2026):** el creador puede cancelar partidas en `planned` e `in_progress` desde la ficha (no hace falta ser participante). En web, las confirmaciones destructivas (cancelar, abandonar, aprobar resultado) usan **modales** en lugar de `Alert.alert`, que no es fiable en Expo Web. **Empezar partida (jul. 2026):** el creador puede pasar una partida `planned` a `in_progress` manualmente (sin modal de confirmación; sí aviso si la plantilla está incompleta); se fija `start_at` al instante actual. - **Plantilla mixta (may. 2026):** en crear/editar se pueden añadir compañeros/rivales **por nombre** además de cuentas registradas; las plazas (UI, explore y cron) cuentan texto + confirmados (máx. 2 por equipo). El creador puede registrar marcador **sin validación rival** solo si no hay otros participantes con cuenta y la partida está **`in_progress`** (`record_match_result_direct`). Tras aprobar un resultado rival, un trigger en BD confirma el resultado y finaliza la partida (`018`). - **Eliminación de cuenta (may. 2026):** derecho de supresión RGPD vía Edge Function `delete-account`. Se borran auth, perfil, avatar y datos personales (reportes, cola de notificaciones). El **historial de partidas se anonimiza**, no se elimina: referencias pasan al perfil interno **Usuario eliminado** (sentinel); las participaciones en plantilla se reasignan al sentinel para que sigan visibles en la UI. CORS de la función: orígenes de producción + loopback local (`localhost` / `127.0.0.1`) para desarrollo web. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..30f88c8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,69 @@ +# Security Policy + +## Supported versions + +| Version | Supported | +| ------------------------------------------------- | --------------------------------- | +| Latest release on `main` (App Store / Play Store) | Yes | +| Development builds from `develop` | Best effort | +| Older store builds | No — update to the latest release | + +## Reporting a vulnerability + +**Do not** open a public GitHub issue for security problems. + +Please report vulnerabilities privately using one of these channels: + +1. **GitHub Private Vulnerability Reporting** (preferred): + [Report a vulnerability](https://github.com/Japegomez/jugaMUS/security/advisories/new) +2. **Email:** [japenago@gmail.com](mailto:japenago@gmail.com) with subject + `[jugaMUS] Security vulnerability` + +Include as much detail as you can: + +- Description of the issue and potential impact +- Steps to reproduce (PoC, screenshots, or request/response samples) +- Affected version, platform (iOS / Android / web), and environment +- Whether you have a suggested fix + +### Scope (examples) + +In scope: + +- Authentication / session issues (including OAuth) +- Unauthorized data access (RLS / API / Edge Functions) +- Injection, XSS, or similar client/server flaws +- Secrets exposure in the repo or client builds +- Privilege escalation (e.g. gaining `admin` without authorization) + +Out of scope: + +- Social engineering / physical attacks +- Denial of service against third-party providers +- Issues only present on rooted/jailbroken devices or modified clients +- Vulnerabilities in dependencies already fixed in a newer release we have not yet shipped (Dependabot / security updates handle tracking) + +## What to expect + +| Step | Target | +| ------------------------- | ---------------------------------------------------- | +| Acknowledgement | Within **72 hours** | +| Initial triage / severity | Within **7 days** | +| Fix or mitigation plan | Depends on severity; critical issues are prioritized | + +If the report is accepted, we will credit you in the advisory (unless you prefer to remain anonymous). If it is declined, we will explain why. + +Please give us reasonable time to investigate and ship a fix before any public disclosure. + +## Dependency security + +This repository uses: + +- **Dependabot** alerts and version updates (see `.github/dependabot.yml`) +- **`npm audit`** in CI (`quality` workflow, high severity and above) + +Security-related dependency fixes may land on the default branch (`main`); routine version updates are reviewed via Dependabot PRs. + +## Safe harbor + +We welcome good-faith security research. If you follow this policy, avoid privacy violations and service disruption, and report findings promptly, we will not pursue legal action related to that research. diff --git a/TASKS.md b/TASKS.md index 6e085d7..c2dd296 100644 --- a/TASKS.md +++ b/TASKS.md @@ -1,6 +1,6 @@ # Tareas - jugaMUS -> Actualizado: 02/08/2026 (minor v1.5.0 — partida pasada con resultado, orden parejas, locales Android) +> Actualizado: 10/08/2026 (Dependabot → `develop` + `SECURITY.md`; majors Expo vía `npx expo upgrade`) > Metodología: Kanban personal. Actualizar al inicio y al final de cada sesión de trabajo. --- @@ -265,10 +265,13 @@ Las notificaciones push **no** funcionan en Expo Go; hace falta un build con cre - [x] **Auditoría de dependencias** — `npm audit --audit-level=high` en `quality.yml`; `.github/dependabot.yml` (npm + github-actions, semanal). - [x] **Quality gate unificado** — workflow `quality.yml` (job `Quality`): Gitleaks + `expo-doctor` + lint + type-check + tests + cobertura. Eliminado `secret-scan.yml`. - [x] **GitHub Actions Node 24** — `checkout`/`setup-node` v6, `upload-artifact` v7. -- [x] **Dependabot Expo-safe** — grupo runtime limitado a libs independientes; ignora patch/minor en stack Expo/RN. PR #84 cerrada (conflictos + bumps incompatibles SDK 54). +- [x] **Dependabot Expo-safe** — ignora majors + patch/minor del stack Expo/RN; grupos prod/dev + security; PRs a `develop`; cooldown; assignee `Japegomez`; `exclude-paths: vendor/**`. - [x] **Fix deps Expo SDK 54 en `main`** — `async-storage` 2.2.0, `expo-splash-screen` ~31.0.13 (PR #85/#86). - [x] **Tags de release en `main`** — job `release-tag` en `eas.yml`: `v{app.json version}-{YYYYMMDD.HHmm}` UTC antes de EAS build. Visibles en GitHub → Tags. -- [x] **Pendiente manual (GitHub):** Settings → Code security → Dependabot **security updates** (opción 3) + Push protection; ruleset `status check` en `develop`/`main` (check `quality / Quality`). +- [x] **GitHub Advanced Security** — Dependabot security updates + secret scanning push protection enabled. +- [x] **`SECURITY.md`** — política de reporte privado (GitHub Advisories + `japenago@gmail.com`). +- [x] **Ruleset `status check`** — activo solo en **`main`** (check `quality / Quality`, sin force-push, vía PR). **No** en `develop`: permite push directo desde Cursor / local. +- [ ] **Upgrade Expo SDK** — cuando toque subir de SDK: `npx expo upgrade`, changelog del SDK, `npx expo-doctor`, QA builds nativos / EAS. Dependabot no propone estos bumps. --- @@ -622,3 +625,4 @@ Las notificaciones push **no** funcionan en Expo Go; hace falta un build con cre - [x] Documentación de variables de entorno (`.env.example`) — incluye `EDGE_CRON_SECRET` para CI - [x] **Security hardening (may. 2026, rama `chore/security`, migraciones 038–048):** anti-escalada admin, PII lockdown, cron secret, Edge Functions, Sentry, OAuth release - `CRON_SECRET` / `EDGE_CRON_SECRET` configurados (Supabase Edge Functions + GitHub) según `.env.example` / docs. +- [x] **`SECURITY.md` (ago. 2026)** — reporte privado de vulnerabilidades; ver también Dependabot en CI/CD hardening. From b6c8df0a74378e8629c375f945e7915aa8279ca8 Mon Sep 17 00:00:00 2001 From: Japegomez Date: Mon, 10 Aug 2026 08:07:00 +0200 Subject: [PATCH 05/24] fix(security): patch transitive advisories and audit allowlist Bump overrides (undici, fast-uri, nanoid, js-yaml, postcss) and vendor brace-expansion 5.0.9; allowlist unpatched image-size GHSAs in CI until Expo upgrades Metro. Co-authored-by: Cursor --- .github/workflows/quality.yml | 2 +- SECURITY.md | 2 +- package-lock.json | 54 +++++++++++----------- package.json | 10 ++++- scripts/npm-audit-ci.mjs | 70 +++++++++++++++++++++++++++++ vendor/brace-expansion/README.md | 5 ++- vendor/brace-expansion/index.js | 25 ++++++++--- vendor/brace-expansion/package.json | 4 +- 8 files changed, 133 insertions(+), 39 deletions(-) create mode 100644 scripts/npm-audit-ci.mjs diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 46b3123..284a196 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -27,7 +27,7 @@ jobs: run: npm ci - name: Dependency audit - run: npm audit --audit-level=high + run: node scripts/npm-audit-ci.mjs - name: Expo SDK compatibility run: npx expo-doctor diff --git a/SECURITY.md b/SECURITY.md index 30f88c8..43f25b3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -60,7 +60,7 @@ Please give us reasonable time to investigate and ship a fix before any public d This repository uses: - **Dependabot** alerts and version updates (see `.github/dependabot.yml`) -- **`npm audit`** in CI (`quality` workflow, high severity and above) +- **`npm audit`** in CI (`quality` workflow, high severity and above), with an allowlist only for unpatched `image-size` advisories transitive via Metro/Expo SDK 54 (`scripts/npm-audit-ci.mjs`) Security-related dependency fixes may land on the default branch (`main`); routine version updates are reviewed via Dependabot PRs. diff --git a/package-lock.json b/package-lock.json index c4a2af2..1f8fbee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1710,9 +1710,9 @@ "license": "Python-2.0" }, "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -3054,9 +3054,9 @@ } }, "node_modules/@expo/xcpretty/node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -3315,6 +3315,19 @@ "node": ">=6" } }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@istanbuljs/schema": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", @@ -13343,19 +13356,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/jsc-safe-url": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", @@ -14895,9 +14895,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -15618,9 +15618,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -15637,7 +15637,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -19007,7 +19007,7 @@ } }, "vendor/brace-expansion": { - "version": "5.0.8", + "version": "5.0.9", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" diff --git a/package.json b/package.json index b7762bd..fd1171f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "type-check": "tsc --noEmit", "test": "jest", "test:ci": "jest --ci --coverage --coverageReporters=text-summary --coverageReporters=lcov", + "audit:ci": "node scripts/npm-audit-ci.mjs", "format": "prettier --write \"src/**/*.{ts,tsx,json}\"", "prepare": "husky", "sync:municipalities": "node scripts/sync-municipalities.mjs" @@ -80,9 +81,14 @@ "private": true, "overrides": { "brace-expansion": "$brace-expansion", - "postcss": "8.5.22", + "postcss": "8.5.26", "tar": "7.5.22", - "uuid": "11.1.1" + "uuid": "11.1.1", + "undici": "6.28.0", + "fast-uri": "3.1.5", + "nanoid": "3.3.18", + "js-yaml@3": "3.15.1", + "js-yaml@4": "4.3.1" }, "lint-staged": { "src/**/*.{ts,tsx}": [ diff --git a/scripts/npm-audit-ci.mjs b/scripts/npm-audit-ci.mjs new file mode 100644 index 0000000..f020f49 --- /dev/null +++ b/scripts/npm-audit-ci.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * CI wrapper around `npm audit --audit-level=high`. + * + * `image-size` (via Metro / Expo SDK 54) has high DoS advisories with no + * patched release (package archived; all versions <=2.0.2). Those are + * build-time only and cannot be fixed without an Expo SDK bump. + * + * Any other high/critical advisory still fails the job. + */ +import { spawnSync } from 'node:child_process' + +const ALLOWED_GHSA = new Set([ + 'GHSA-w3rx-r6r6-pgpr', // image-size ICNS infinite loop + 'GHSA-5p2g-fcmc-qvqq', // image-size JXL/HEIF infinite loop +]) + +const result = spawnSync('npm', ['audit', '--json', '--audit-level=high'], { + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + shell: process.platform === 'win32', +}) + +const stdout = result.stdout || '' +let report +try { + report = JSON.parse(stdout) +} catch { + console.error('npm audit did not return JSON') + console.error(stdout || result.stderr) + process.exit(1) +} + +if (result.status === 0) { + console.log('npm audit: no high/critical vulnerabilities') + process.exit(0) +} + +const advisoryHits = [] +for (const [name, vuln] of Object.entries(report.vulnerabilities || {})) { + if (!['high', 'critical'].includes(vuln.severity)) continue + for (const via of vuln.via || []) { + if (typeof via !== 'object' || !via) continue + const match = String(via.url || '').match(/GHSA-[\w-]+/) + const ghsa = match ? match[0] : null + advisoryHits.push({ + package: name, + severity: vuln.severity, + ghsa, + title: via.title || via.name || 'unknown', + url: via.url || null, + }) + } +} + +const blocking = advisoryHits.filter((hit) => !hit.ghsa || !ALLOWED_GHSA.has(hit.ghsa)) + +if (blocking.length === 0) { + const allowed = [...new Set(advisoryHits.map((h) => h.ghsa).filter(Boolean))] + console.log( + `npm audit: only allowlisted build-toolchain advisories remain (${allowed.join(', ')} via image-size/Metro; no upstream patch on Expo SDK 54).`, + ) + process.exit(0) +} + +console.error('npm audit: blocking high/critical advisories:') +for (const hit of blocking) { + console.error(`- ${hit.severity} ${hit.package}: ${hit.title}${hit.ghsa ? ` (${hit.ghsa})` : ''}`) +} +process.exit(1) diff --git a/vendor/brace-expansion/README.md b/vendor/brace-expansion/README.md index 75f6a90..2f17210 100644 --- a/vendor/brace-expansion/README.md +++ b/vendor/brace-expansion/README.md @@ -1,5 +1,8 @@ # brace-expansion compat shim -Vendors the **5.0.8** security fix for [GHSA-mh99-v99m-4gvg](https://github.com/advisories/GHSA-mh99-v99m-4gvg) / CVE-2026-14257 (`maxLength` bound). +Vendors the **5.0.9** security fix for: + +- [GHSA-mh99-v99m-4gvg](https://github.com/advisories/GHSA-mh99-v99m-4gvg) / CVE-2026-14257 (`maxLength` bound) +- [GHSA-rgw5-rvv9-x895](https://github.com/advisories/GHSA-rgw5-rvv9-x895) (bounds intermediate arrays / sequences) Upstream `brace-expansion@5` only exports `{ expand }`. Older `minimatch` (3.x via eslint/jest/RN) still does `require('brace-expansion')(pattern)`. This package keeps the patched algorithm and restores a default-function CJS export so a single override clears the advisory without bumping Expo/React Native. diff --git a/vendor/brace-expansion/index.js b/vendor/brace-expansion/index.js index 16b85e8..1aec476 100644 --- a/vendor/brace-expansion/index.js +++ b/vendor/brace-expansion/index.js @@ -1,6 +1,6 @@ 'use strict' /** - * Vendored brace-expansion@5.0.8 (CVE-2026-14257 / GHSA-mh99-v99m-4gvg) + * Vendored brace-expansion@5.0.9 (GHSA-rgw5-rvv9-x895 / GHSA-mh99-v99m-4gvg) * with a CJS default-function export so minimatch@3/9/10 keep working. * Upstream 5.x only exposes `{ expand }` which breaks `require('brace-expansion')(pattern)`. */ @@ -96,7 +96,7 @@ function combine(acc, pre, values, max, maxLength, dropEmpties) { return out } -function expandSequence(body, isAlphaSequence, max) { +function expandSequence(body, isAlphaSequence, max, maxLength) { const n = body.split(/\.\./) const N = [] if (n[0] === undefined || n[1] === undefined) return N @@ -111,6 +111,7 @@ function expandSequence(body, isAlphaSequence, max) { test = gte } const pad = n.some(isPadded) + let length = 0 for (let i = x; test(i, y) && N.length < max; i += incr) { let c if (isAlphaSequence) { @@ -126,7 +127,9 @@ function expandSequence(body, isAlphaSequence, max) { } } } + if (length + c.length > maxLength) break N.push(c) + length += c.length } return N } @@ -166,7 +169,7 @@ function expand_(str, max, maxLength, isTop) { } let values if (isSequence) { - values = expandSequence(m.body, isAlphaSequence, max) + values = expandSequence(m.body, isAlphaSequence, max, maxLength) } else { let n = parseCommaParts(m.body) if (n.length === 1 && n[0] !== undefined) { @@ -178,9 +181,21 @@ function expand_(str, max, maxLength, isTop) { continue } } + let dropsEmpties = dropEmpties && !m.post.length && !pre + for (let d = 0; dropsEmpties && d < acc.length; d++) { + if (acc[d]) dropsEmpties = false + } values = [] - for (let j = 0; j < n.length; j++) { - values.push.apply(values, expand_(n[j], max, maxLength, false)) + let valuesLength = 0 + outer: for (let j = 0; j < n.length; j++) { + const expanded = expand_(n[j], max, maxLength, false) + for (let k = 0; k < expanded.length; k++) { + const v = expanded[k] + if (dropsEmpties && !v) continue + if (values.length >= max || valuesLength + v.length > maxLength) break outer + values.push(v) + valuesLength += v.length + } } } acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length) diff --git a/vendor/brace-expansion/package.json b/vendor/brace-expansion/package.json index 0eddcc1..d2076f2 100644 --- a/vendor/brace-expansion/package.json +++ b/vendor/brace-expansion/package.json @@ -1,7 +1,7 @@ { "name": "brace-expansion", - "version": "5.0.8", - "description": "Compat shim: brace-expansion 5.0.8 security fix with default-function CJS export for minimatch@3/9/10", + "version": "5.0.9", + "description": "Compat shim: brace-expansion 5.0.9 security fix with default-function CJS export for minimatch@3/9/10", "license": "MIT", "main": "index.js", "type": "commonjs", From 4f1b12fd6151dd47ff761331680db4806cea1c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Pe=C3=B1a=20G=C3=B3mez?= <60773775+Japegomez@users.noreply.github.com> Date: Mon, 10 Aug 2026 08:20:16 +0200 Subject: [PATCH 06/24] feat(profile): add native contact creation from player phone (#139) Co-authored-by: Cursor --- app.json | 8 ++- package-lock.json | 16 ++++- package.json | 3 +- src/app/(tabs)/matches/[id].tsx | 36 +++++++++++- src/app/(tabs)/profile/[userId].tsx | 71 +++++++++++++++++++++-- src/utils/contacts.test.ts | 90 +++++++++++++++++++++++++++++ src/utils/contacts.ts | 66 +++++++++++++++++++++ 7 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 src/utils/contacts.test.ts create mode 100644 src/utils/contacts.ts diff --git a/app.json b/app.json index 6c681c0..783bea1 100644 --- a/app.json +++ b/app.json @@ -3,7 +3,7 @@ "name": "jugaMUS", "slug": "musapp", "scheme": "jugamus", - "version": "1.5.0", + "version": "1.6.0", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -54,6 +54,12 @@ "expo-web-browser", "expo-apple-authentication", "@react-native-community/datetimepicker", + [ + "expo-contacts", + { + "contactsPermission": "jugaMUS puede sugerir guardar el teléfono de un jugador en tus contactos cuando lo pulses." + } + ], [ "expo-notifications", { diff --git a/package-lock.json b/package-lock.json index 1f8fbee..0bd9135 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jugamus", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jugamus", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/vector-icons": "^15.1.1", @@ -23,6 +23,7 @@ "expo-auth-session": "~7.0.11", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", + "expo-contacts": "~15.0.11", "expo-crypto": "~15.0.9", "expo-dev-client": "~6.0.21", "expo-device": "~8.0.10", @@ -8946,6 +8947,17 @@ "react-native": "*" } }, + "node_modules/expo-contacts": { + "version": "15.0.11", + "resolved": "https://registry.npmjs.org/expo-contacts/-/expo-contacts-15.0.11.tgz", + "integrity": "sha512-buKkoQuWJgVSEvR1Yloa5SvGC/ud7T2YeYDe0R7Aa0oz8pwQpjk3W7OoL3QrXIfYcuKTrIz6ffqC8lc3peaAyg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-crypto": { "version": "15.0.9", "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz", diff --git a/package.json b/package.json index fd1171f..29c0072 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jugamus", - "version": "1.5.0", + "version": "1.6.0", "main": "expo-router/entry", "scripts": { "start": "expo start", @@ -32,6 +32,7 @@ "expo-auth-session": "~7.0.11", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.13", + "expo-contacts": "~15.0.11", "expo-crypto": "~15.0.9", "expo-dev-client": "~6.0.21", "expo-device": "~8.0.10", diff --git a/src/app/(tabs)/matches/[id].tsx b/src/app/(tabs)/matches/[id].tsx index 9d875ef..5e167fb 100644 --- a/src/app/(tabs)/matches/[id].tsx +++ b/src/app/(tabs)/matches/[id].tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { useFocusEffect } from '@react-navigation/native' +import { Ionicons } from '@expo/vector-icons' import { useLocalSearchParams, useRouter, type Href } from 'expo-router' import { ActivityIndicator, @@ -73,6 +74,8 @@ import { prefetchOrientationLock } from '@/lib/orientationLock' import { Colors } from '@/theme/colors' import { Fonts } from '@/theme/typography' import { screenTopPadding } from '@/theme/layout' +import { openCreateContactForm } from '@/utils/contacts' +import { formatPhone } from '@/utils/formatters' import { matchStatusDisplay } from '@/utils/matchDisplay' import * as ScreenOrientation from 'expo-screen-orientation' @@ -137,6 +140,20 @@ function ParticipantCard({ const p = participant?.profile if (!p) return null + const handleCreateContact = async () => { + const phone = fullProfile?.phone_e164?.trim() + if (!phone) return + const result = await openCreateContactForm({ + displayName: p.display_name, + phoneE164: phone, + }) + if (result === 'unsupported') { + Alert.alert('Número copiado', 'El teléfono se ha copiado al portapapeles.') + } else if (result === 'error') { + Alert.alert('Error', 'No se pudo abrir la ficha de contacto.') + } + } + return ( {fullProfile?.phone_e164 ? ( - {fullProfile.phone_e164} + void handleCreateContact()} + accessibilityRole="button" + accessibilityLabel={`Crear contacto con ${p.display_name}`} + style={({ pressed }) => [card.phoneBtn, pressed && card.phoneBtnPressed]} + hitSlop={8}> + {formatPhone(fullProfile.phone_e164)} + + ) : ( {loading ? 'Cargando...' : 'Ver teléfono'} @@ -223,7 +248,14 @@ const card = StyleSheet.create({ name: { fontSize: 15, fontFamily: Fonts.semiBold, color: Colors.textPrimary }, nameLink: { color: Colors.primary }, city: { fontSize: 12, color: Colors.textSecondary, marginTop: 1 }, - phone: { fontSize: 13, color: Colors.primary, marginTop: 3, fontFamily: Fonts.medium }, + phone: { fontSize: 13, color: Colors.primary, fontFamily: Fonts.medium }, + phoneBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 3, + }, + phoneBtnPressed: { opacity: 0.7 }, revealPhone: { fontSize: 13, color: Colors.primary, diff --git a/src/app/(tabs)/profile/[userId].tsx b/src/app/(tabs)/profile/[userId].tsx index 50c656b..2f004be 100644 --- a/src/app/(tabs)/profile/[userId].tsx +++ b/src/app/(tabs)/profile/[userId].tsx @@ -1,5 +1,14 @@ -import { useEffect } from 'react' -import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native' +import { useEffect, useState } from 'react' +import { + ActivityIndicator, + Alert, + Pressable, + ScrollView, + StyleSheet, + Text, + View, +} from 'react-native' +import { Ionicons } from '@expo/vector-icons' import { useLocalSearchParams, useRouter, type Href } from 'expo-router' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -12,6 +21,8 @@ import { useViewableUserProfile } from '@/hooks/useProfile' import { Colors } from '@/theme/colors' import { Fonts } from '@/theme/typography' import { screenTopPadding } from '@/theme/layout' +import { openCreateContactForm } from '@/utils/contacts' +import { formatPhone } from '@/utils/formatters' function InfoRow({ label, value }: { label: string; value: string }) { return ( @@ -22,6 +33,40 @@ function InfoRow({ label, value }: { label: string; value: string }) { ) } +function PhoneContactRow({ displayName, phoneE164 }: { displayName: string; phoneE164: string }) { + const [busy, setBusy] = useState(false) + + const handlePress = async () => { + if (busy) return + setBusy(true) + try { + const result = await openCreateContactForm({ displayName, phoneE164 }) + if (result === 'unsupported') { + Alert.alert('Número copiado', 'El teléfono se ha copiado al portapapeles.') + } else if (result === 'error') { + Alert.alert('Error', 'No se pudo abrir la ficha de contacto.') + } + } finally { + setBusy(false) + } + } + + return ( + void handlePress()} + disabled={busy} + accessibilityRole="button" + accessibilityLabel={`Crear contacto con ${displayName}`} + style={({ pressed }) => [styles.infoRow, pressed && styles.phoneRowPressed]}> + Teléfono + + {formatPhone(phoneE164)} + + + + ) +} + export default function UserProfileScreen() { const { userId } = useLocalSearchParams<{ userId: string }>() const router = useRouter() @@ -78,7 +123,7 @@ export default function UserProfileScreen() { ) } - const phoneLabel = profile.phone_e164?.trim() || 'No disponible' + const phone = profile.phone_e164?.trim() ?? '' return ( @@ -102,7 +147,11 @@ export default function UserProfileScreen() { - + {phone ? ( + + ) : ( + + )} @@ -179,6 +228,7 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingVertical: 12, }, + phoneRowPressed: { opacity: 0.7 }, infoLabel: { fontSize: 15, color: Colors.textPrimary, @@ -192,4 +242,17 @@ const styles = StyleSheet.create({ 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/utils/contacts.test.ts b/src/utils/contacts.test.ts new file mode 100644 index 0000000..9cfcdae --- /dev/null +++ b/src/utils/contacts.test.ts @@ -0,0 +1,90 @@ +import { openCreateContactForm, splitDisplayName } from './contacts' + +jest.mock('expo-clipboard', () => ({ + setStringAsync: jest.fn(async () => true), +})) + +jest.mock('expo-contacts', () => ({ + ContactTypes: { Person: 'person', Company: 'company' }, + presentFormAsync: jest.fn(async () => undefined), +})) + +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, +})) + +describe('splitDisplayName', () => { + it('returns empty parts for blank input', () => { + expect(splitDisplayName('')).toEqual({ givenName: '', familyName: '' }) + expect(splitDisplayName(' ')).toEqual({ givenName: '', familyName: '' }) + }) + + it('puts a single token in givenName', () => { + expect(splitDisplayName('Juan')).toEqual({ givenName: 'Juan', familyName: '' }) + }) + + it('splits first token and the rest', () => { + expect(splitDisplayName('Juan García López')).toEqual({ + givenName: 'Juan', + familyName: 'García López', + }) + }) + + it('trims surrounding whitespace', () => { + expect(splitDisplayName(' Ana Pérez ')).toEqual({ + givenName: 'Ana', + familyName: 'Pérez', + }) + }) +}) + +describe('openCreateContactForm', () => { + const Contacts = jest.requireMock('expo-contacts') as { + presentFormAsync: jest.Mock + } + const Clipboard = jest.requireMock('expo-clipboard') as { + setStringAsync: jest.Mock + } + const RN = jest.requireMock('react-native') as { Platform: { OS: string } } + + beforeEach(() => { + jest.clearAllMocks() + RN.Platform.OS = 'ios' + }) + + it('opens native create-contact form with name and phone', async () => { + const result = await openCreateContactForm({ + displayName: 'Juan García', + phoneE164: '+34612345678', + }) + + expect(Contacts.presentFormAsync).toHaveBeenCalledWith( + null, + expect.objectContaining({ + firstName: 'Juan', + lastName: 'García', + phoneNumbers: [expect.objectContaining({ number: '+34612345678', label: 'mobile' })], + }), + { isNew: true } + ) + expect(result).toBe('cancelled') + }) + + it('copies phone on web and returns unsupported', async () => { + RN.Platform.OS = 'web' + const result = await openCreateContactForm({ + displayName: 'Juan', + phoneE164: '+34612345678', + }) + + expect(Clipboard.setStringAsync).toHaveBeenCalledWith('+34612345678') + expect(Contacts.presentFormAsync).not.toHaveBeenCalled() + expect(result).toBe('unsupported') + }) + + it('returns error when phone is empty', async () => { + await expect(openCreateContactForm({ displayName: 'Juan', phoneE164: ' ' })).resolves.toBe( + 'error' + ) + }) +}) diff --git a/src/utils/contacts.ts b/src/utils/contacts.ts new file mode 100644 index 0000000..9380dd9 --- /dev/null +++ b/src/utils/contacts.ts @@ -0,0 +1,66 @@ +import * as Clipboard from 'expo-clipboard' +import * as Contacts from 'expo-contacts' +import { Platform } from 'react-native' + +export type OpenCreateContactResult = 'created' | 'cancelled' | 'unsupported' | 'error' + +export interface CreateContactInput { + displayName: string + phoneE164: string +} + +/** Split "Juan García López" → givenName + familyName for the native contact form. */ +export function splitDisplayName(name: string): { givenName: string; familyName: string } { + const trimmed = name.trim() + if (!trimmed) return { givenName: '', familyName: '' } + const parts = trimmed.split(/\s+/) + if (parts.length === 1) return { givenName: parts[0], familyName: '' } + return { givenName: parts[0], familyName: parts.slice(1).join(' ') } +} + +/** + * Opens the system "Create contact" form prefilled with the player's name and phone. + * On web (no contacts API), copies the phone number to the clipboard instead. + */ +export async function openCreateContactForm( + input: CreateContactInput +): Promise { + const phone = input.phoneE164.trim() + const displayName = input.displayName.trim() + if (!phone) return 'error' + + if (Platform.OS === 'web') { + try { + await Clipboard.setStringAsync(phone) + return 'unsupported' + } catch { + return 'error' + } + } + + try { + const { givenName, familyName } = splitDisplayName(displayName || phone) + // SDK 54 API: presentFormAsync(null, contact, { isNew: true }) + await Contacts.presentFormAsync( + null, + { + contactType: Contacts.ContactTypes.Person, + name: displayName || givenName, + firstName: givenName, + lastName: familyName || undefined, + phoneNumbers: [ + { + label: 'mobile', + number: phone, + isPrimary: true, + }, + ], + }, + { isNew: true } + ) + // presentFormAsync resolves when the form closes; OS does not report save vs cancel. + return 'cancelled' + } catch { + return 'error' + } +} From bd14623ae28a497a7eeb3d8345f30a97124e2c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Pe=C3=B1a=20G=C3=B3mez?= <60773775+Japegomez@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:02:11 +0200 Subject: [PATCH 07/24] feat(stats): player statistics, ELO ranking, H2H insights and unified podium (#142) * feat(stats): add player statistics, ELO ranking, and H2H match insights Introduce player_stats with ELO, win rate, rivalries, and badges backed by Supabase RPCs, plus profile stats screens, match H2H context, and an ELO leaderboard. Co-authored-by: Cursor * feat(stats): add tournament podium medals with named lists Show gold, silver and bronze medal counts on profile stats and list each tournament title in the stats detail screen. Co-authored-by: Cursor * feat(stats): unify league/tournament podiums and improve profile stats UX Merge podium medals across tournaments and leagues with source labels in detail view, surface win rate and medals at the top of profiles, and fix get_player_stats RPC volatility for PostgREST. Co-authored-by: Cursor * fix(matches): include phone_e164 in nested profile fallback select Co-authored-by: Cursor --------- Co-authored-by: Cursor --- src/app/(admin)/analytics.tsx | 27 +- src/app/(tabs)/_layout.tsx | 2 + src/app/(tabs)/leaderboard/index.tsx | 182 ++++ src/app/(tabs)/matches/[id].tsx | 3 + src/app/(tabs)/profile/[userId].tsx | 8 + src/app/(tabs)/profile/index.tsx | 8 + src/app/(tabs)/profile/stats/[userId].tsx | 205 ++++ src/components/stats/BadgeList.tsx | 47 + src/components/stats/ELOBadge.tsx | 32 + src/components/stats/FormBadges.tsx | 54 ++ src/components/stats/HeadToHeadCard.tsx | 79 ++ src/components/stats/MatchInsightsSection.tsx | 152 +++ src/components/stats/PartnerList.tsx | 81 ++ src/components/stats/ProfileStatsCard.tsx | 180 ++++ src/components/stats/RivalryList.tsx | 108 +++ src/components/stats/StatCard.tsx | 37 + src/components/stats/StatsGrid.tsx | 23 + .../stats/TournamentPodiumSection.tsx | 267 +++++ src/components/stats/VenueList.tsx | 75 ++ src/components/stats/WinRateBar.tsx | 51 + src/hooks/useMatches.ts | 2 + src/hooks/useResults.ts | 3 + src/hooks/useStats.ts | 51 + src/lib/invalidateExploreCaches.ts | 4 + src/services/matches.service.ts | 2 +- src/services/stats.service.ts | 392 ++++++++ src/types/database.types.ts | 78 ++ .../20260810120000_086_player_stats.sql | 910 ++++++++++++++++++ ...0810130000_089_tournament_podium_lists.sql | 276 ++++++ ...60810140000_090_unified_podium_leagues.sql | 308 ++++++ ...0810150000_091_player_stats_rpc_reload.sql | 12 + ...10160000_092_get_player_stats_volatile.sql | 5 + 32 files changed, 3637 insertions(+), 27 deletions(-) create mode 100644 src/app/(tabs)/leaderboard/index.tsx create mode 100644 src/app/(tabs)/profile/stats/[userId].tsx create mode 100644 src/components/stats/BadgeList.tsx create mode 100644 src/components/stats/ELOBadge.tsx create mode 100644 src/components/stats/FormBadges.tsx create mode 100644 src/components/stats/HeadToHeadCard.tsx create mode 100644 src/components/stats/MatchInsightsSection.tsx create mode 100644 src/components/stats/PartnerList.tsx create mode 100644 src/components/stats/ProfileStatsCard.tsx create mode 100644 src/components/stats/RivalryList.tsx create mode 100644 src/components/stats/StatCard.tsx create mode 100644 src/components/stats/StatsGrid.tsx create mode 100644 src/components/stats/TournamentPodiumSection.tsx create mode 100644 src/components/stats/VenueList.tsx create mode 100644 src/components/stats/WinRateBar.tsx create mode 100644 src/hooks/useStats.ts create mode 100644 src/services/stats.service.ts create mode 100644 supabase/migrations/20260810120000_086_player_stats.sql create mode 100644 supabase/migrations/20260810130000_089_tournament_podium_lists.sql create mode 100644 supabase/migrations/20260810140000_090_unified_podium_leagues.sql create mode 100644 supabase/migrations/20260810150000_091_player_stats_rpc_reload.sql create mode 100644 supabase/migrations/20260810160000_092_get_player_stats_volatile.sql diff --git a/src/app/(admin)/analytics.tsx b/src/app/(admin)/analytics.tsx index b00e7ab..1f90ced 100644 --- a/src/app/(admin)/analytics.tsx +++ b/src/app/(admin)/analytics.tsx @@ -3,6 +3,7 @@ import { ActivityIndicator, Dimensions, ScrollView, StyleSheet, Text, View } fro import { BarChart, LineChart } from 'react-native-chart-kit' import { AdminCloseBar } from '@/components/admin/AdminCloseBar' +import { StatCard } from '@/components/stats/StatCard' import { useAnalyticsSummary, useMatchesByCity, @@ -24,15 +25,6 @@ const chartConfig = { propsForDots: { r: '4', strokeWidth: '2', stroke: Colors.primary }, } -function StatCard({ label, value }: { label: string; value: string }) { - return ( - - {value} - {label} - - ) -} - export default function AdminAnalyticsScreen() { const { data: summary, isLoading: summaryLoading } = useAnalyticsSummary() const { data: byWeek, isLoading: weekLoading } = useMatchesByWeek(12) @@ -150,23 +142,6 @@ const styles = StyleSheet.create({ flexWrap: 'wrap', gap: 12, }, - statCard: { - backgroundColor: Colors.surface, - borderRadius: 12, - padding: 14, - minWidth: '47%', - flexGrow: 1, - }, - statValue: { - fontSize: 22, - fontFamily: Fonts.bold, - color: Colors.primary, - }, - statLabel: { - fontSize: 12, - color: Colors.textSecondary, - marginTop: 2, - }, sectionTitle: { fontSize: 16, fontFamily: Fonts.semiBold, diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index cf79195..c25070f 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -77,6 +77,8 @@ export default function TabsLayout() { + + ) } diff --git a/src/app/(tabs)/leaderboard/index.tsx b/src/app/(tabs)/leaderboard/index.tsx new file mode 100644 index 0000000..20fbb14 --- /dev/null +++ b/src/app/(tabs)/leaderboard/index.tsx @@ -0,0 +1,182 @@ +import { 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' + +import { MunicipalityPicker } from '@/components/ui/MunicipalityPicker' +import { useLeaderboard } from '@/hooks/useStats' +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' +import { screenTopPadding } from '@/theme/layout' + +export default function LeaderboardScreen() { + const router = useRouter() + const insets = useSafeAreaInsets() + const [city, setCity] = useState('') + const { data, isPending, isError, refetch } = useLeaderboard(city || null) + + return ( + + + router.back()} + accessibilityRole="button" + hitSlop={8} + style={styles.closeWrap}> + + + + + + Ranking ELO + Clasificación por puntuación ELO + + + + {city ? ( + setCity('')} style={styles.clearBtn}> + Quitar filtro + + ) : null} + + + {isPending ? : null} + + {isError ? ( + void refetch()} style={styles.errorBox}> + No se pudo cargar. Toca para reintentar. + + ) : null} + + {!isPending && !isError ? ( + + {(data ?? []).length === 0 ? ( + Aún no hay jugadores en el ranking + ) : ( + (data ?? []).map((entry, index) => ( + router.push(`/(tabs)/profile/${entry.user_id}` as Href)} + style={[styles.row, index === (data?.length ?? 0) - 1 && styles.rowLast]}> + {index + 1} + + + {entry.display_name} + + + {`${entry.city ?? '—'} · ${entry.wins}G/${entry.matches_played} · ${entry.win_rate}%`} + + + {entry.elo_rating} + + )) + )} + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: Colors.background }, + topBar: { flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 16 }, + closeWrap: { alignSelf: 'flex-end', padding: 8 }, + close: { fontSize: 22, color: Colors.textSecondary, padding: 8 }, + scroll: { + flexGrow: 1, + paddingHorizontal: 20, + gap: 12, + }, + title: { + fontFamily: Fonts.bold, + fontSize: 24, + color: Colors.textPrimary, + }, + subtitle: { + fontFamily: Fonts.regular, + fontSize: 14, + color: Colors.textSecondary, + marginBottom: 4, + }, + filter: { + gap: 8, + }, + clearBtn: { + alignSelf: 'flex-start', + }, + clearText: { + fontFamily: Fonts.medium, + fontSize: 13, + color: Colors.primary, + }, + card: { + backgroundColor: Colors.surface, + borderRadius: 12, + paddingHorizontal: 8, + paddingVertical: 4, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Colors.border, + gap: 10, + }, + rowLast: { + borderBottomWidth: 0, + }, + rank: { + width: 28, + fontFamily: Fonts.bold, + fontSize: 15, + color: Colors.primary, + }, + info: { + flex: 1, + }, + name: { + fontFamily: Fonts.semiBold, + fontSize: 15, + color: Colors.textPrimary, + }, + meta: { + marginTop: 2, + fontFamily: Fonts.regular, + fontSize: 12, + color: Colors.textSecondary, + }, + elo: { + fontFamily: Fonts.bold, + fontSize: 16, + color: Colors.primary, + }, + empty: { + fontFamily: Fonts.regular, + fontSize: 14, + color: Colors.textSecondary, + textAlign: 'center', + paddingVertical: 20, + }, + errorBox: { + padding: 16, + borderRadius: 12, + backgroundColor: Colors.surface, + }, + errorText: { + fontFamily: Fonts.medium, + fontSize: 14, + color: Colors.danger, + textAlign: 'center', + }, +}) diff --git a/src/app/(tabs)/matches/[id].tsx b/src/app/(tabs)/matches/[id].tsx index 5e167fb..e0a1c94 100644 --- a/src/app/(tabs)/matches/[id].tsx +++ b/src/app/(tabs)/matches/[id].tsx @@ -34,6 +34,7 @@ import { Button } from '@/components/ui/Button' import { ReportModal } from '@/components/ui/ReportModal' import { ScrollableModalBody } from '@/components/ui/ScrollableModalBody' import { ShareInviteButton } from '@/components/ShareInviteButton' +import { MatchInsightsSection } from '@/components/stats/MatchInsightsSection' import { useAuthStore } from '@/hooks/useAuth' import { useCancelMatch, @@ -1117,6 +1118,8 @@ export default function MatchDetailScreen() { )} + {!needsPrivateAccess && id ? : null} + {/* Resultado (F7) */} Resultado diff --git a/src/app/(tabs)/profile/[userId].tsx b/src/app/(tabs)/profile/[userId].tsx index 2f004be..336e355 100644 --- a/src/app/(tabs)/profile/[userId].tsx +++ b/src/app/(tabs)/profile/[userId].tsx @@ -14,6 +14,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import { MatchHistoryList } from '@/components/profile/MatchHistoryList' import { AvatarCircle } from '@/components/profile/AvatarCircle' +import { ProfileStatsCard } from '@/components/stats/ProfileStatsCard' import { Button } from '@/components/ui/Button' import { useAuthStore } from '@/hooks/useAuth' import { useViewableUserMatches } from '@/hooks/useMatches' @@ -146,6 +147,13 @@ export default function UserProfileScreen() { {profile.city ? {profile.city} : null} + {userId ? ( + router.push(`/(tabs)/profile/stats/${userId}` as Href)} + /> + ) : null} + {phone ? ( diff --git a/src/app/(tabs)/profile/index.tsx b/src/app/(tabs)/profile/index.tsx index e043b63..e3489ca 100644 --- a/src/app/(tabs)/profile/index.tsx +++ b/src/app/(tabs)/profile/index.tsx @@ -16,6 +16,7 @@ import { DeleteAccountModal } from '@/components/DeleteAccountModal' import { FeedbackModal } from '@/components/FeedbackModal' import { AvatarCircle } from '@/components/profile/AvatarCircle' import { MatchHistoryList } from '@/components/profile/MatchHistoryList' +import { ProfileStatsCard } from '@/components/stats/ProfileStatsCard' import { SignOutModal } from '@/components/SignOutModal' import { Button } from '@/components/ui/Button' import { isRatingPromptSupported } from '@/lib/appRating' @@ -165,6 +166,13 @@ export default function ProfileScreen() { {profile.city ? {profile.city} : null} + {sessionUserId ? ( + router.push(`/(tabs)/profile/stats/${sessionUserId}` as Href)} + /> + ) : null} + diff --git a/src/app/(tabs)/profile/stats/[userId].tsx b/src/app/(tabs)/profile/stats/[userId].tsx new file mode 100644 index 0000000..18b5994 --- /dev/null +++ b/src/app/(tabs)/profile/stats/[userId].tsx @@ -0,0 +1,205 @@ +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' + +import { BadgeList } from '@/components/stats/BadgeList' +import { ELOBadge } from '@/components/stats/ELOBadge' +import { FormBadges } from '@/components/stats/FormBadges' +import { PartnerList } from '@/components/stats/PartnerList' +import { RivalryList } from '@/components/stats/RivalryList' +import { StatsGrid } from '@/components/stats/StatsGrid' +import { PodiumSection } from '@/components/stats/TournamentPodiumSection' +import type { PodiumEntry } from '@/services/stats.service' +import { VenueList } from '@/components/stats/VenueList' +import { WinRateBar } from '@/components/stats/WinRateBar' +import { usePlayerStats } from '@/hooks/useStats' +import { formatStreak } from '@/services/stats.service' +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' +import { screenTopPadding } from '@/theme/layout' + +export default function PlayerStatsScreen() { + const { userId } = useLocalSearchParams<{ userId: string }>() + const router = useRouter() + const insets = useSafeAreaInsets() + const { data, isPending, isError, refetch } = usePlayerStats(userId) + + return ( + + + router.back()} + accessibilityRole="button" + hitSlop={8} + style={styles.closeWrap}> + + + + + + + Estadísticas + {data ? : null} + + + {isPending ? : null} + + {isError ? ( + void refetch()} style={styles.errorBox}> + No se pudieron cargar. Toca para reintentar. + + ) : null} + + {data ? ( + <> + + Resumen + + + Forma reciente + + + + + + + Podio — Torneos y ligas + { + if (entry.source === 'league') { + router.push(`/(tabs)/leagues/${entry.id}` as Href) + } else { + router.push(`/(tabs)/tournaments/${entry.id}` as Href) + } + }} + /> + + {`${data.tournaments_participated} torneos · ${data.leagues_participated} ligas`} + + + + + Sitios + + + + + Compañeros + router.push(`/(tabs)/profile/${id}` as Href)} + /> + + + + Rivalidades + router.push(`/(tabs)/profile/${id}` as Href)} + /> + + + + Logros + + + + router.push('/(tabs)/leaderboard' as Href)} + style={styles.leaderboardBtn} + accessibilityRole="button"> + Ver ranking ELO + + + ) : null} + + + ) +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: Colors.background }, + topBar: { flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 16 }, + closeWrap: { alignSelf: 'flex-end', padding: 8 }, + close: { fontSize: 22, color: Colors.textSecondary, padding: 8 }, + scroll: { + flexGrow: 1, + paddingHorizontal: 20, + gap: 12, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 4, + }, + title: { + fontFamily: Fonts.bold, + fontSize: 24, + color: Colors.textPrimary, + }, + card: { + backgroundColor: Colors.surface, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + gap: 12, + }, + cardTitle: { + fontSize: 13, + fontFamily: Fonts.semiBold, + color: Colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + block: { gap: 6 }, + blockLabel: { + fontFamily: Fonts.medium, + fontSize: 12, + color: Colors.textSecondary, + }, + participations: { + fontFamily: Fonts.regular, + fontSize: 12, + color: Colors.textSecondary, + marginTop: 4, + }, + errorBox: { + padding: 16, + borderRadius: 12, + backgroundColor: Colors.surface, + }, + errorText: { + fontFamily: Fonts.medium, + fontSize: 14, + 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/components/stats/BadgeList.tsx b/src/components/stats/BadgeList.tsx new file mode 100644 index 0000000..3c62059 --- /dev/null +++ b/src/components/stats/BadgeList.tsx @@ -0,0 +1,47 @@ +import { StyleSheet, Text, View } from 'react-native' + +import { BADGE_LABELS, type PlayerBadge } from '@/services/stats.service' +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' + +export function BadgeList({ badges }: { badges: PlayerBadge[] }) { + if (!badges.length) { + return Aún no hay logros + } + + return ( + + {badges.map((badge) => ( + + {BADGE_LABELS[badge.key] ?? badge.key} + + ))} + + ) +} + +const styles = StyleSheet.create({ + wrap: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + chip: { + backgroundColor: Colors.surface, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 6, + borderWidth: 1, + borderColor: Colors.border, + }, + chipText: { + fontFamily: Fonts.medium, + fontSize: 12, + color: Colors.textPrimary, + }, + empty: { + fontFamily: Fonts.regular, + fontSize: 13, + color: Colors.textSecondary, + }, +}) diff --git a/src/components/stats/ELOBadge.tsx b/src/components/stats/ELOBadge.tsx new file mode 100644 index 0000000..15c1d35 --- /dev/null +++ b/src/components/stats/ELOBadge.tsx @@ -0,0 +1,32 @@ +import { StyleSheet, Text, View } from 'react-native' + +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' + +export function ELOBadge({ rating }: { rating: number }) { + return ( + + {rating} + ELO + + ) +} + +const styles = StyleSheet.create({ + wrap: { + flexDirection: 'row', + alignItems: 'baseline', + gap: 4, + }, + value: { + fontFamily: Fonts.bold, + fontSize: 18, + color: Colors.primary, + }, + label: { + fontFamily: Fonts.semiBold, + fontSize: 11, + color: Colors.textSecondary, + letterSpacing: 0.4, + }, +}) diff --git a/src/components/stats/FormBadges.tsx b/src/components/stats/FormBadges.tsx new file mode 100644 index 0000000..5d4203a --- /dev/null +++ b/src/components/stats/FormBadges.tsx @@ -0,0 +1,54 @@ +import { StyleSheet, Text, View } from 'react-native' + +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 + } + + return ( + + {form.map((outcome, index) => ( + + {outcome === 'won' ? 'G' : 'P'} + + ))} + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + gap: 6, + alignItems: 'center', + }, + dot: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + }, + won: { + backgroundColor: Colors.historyWonBackground, + }, + lost: { + backgroundColor: Colors.historyLostBackground, + }, + dotText: { + fontFamily: Fonts.semiBold, + fontSize: 11, + color: Colors.textPrimary, + }, + empty: { + fontFamily: Fonts.regular, + fontSize: 12, + color: Colors.textSecondary, + }, +}) diff --git a/src/components/stats/HeadToHeadCard.tsx b/src/components/stats/HeadToHeadCard.tsx new file mode 100644 index 0000000..3445f95 --- /dev/null +++ b/src/components/stats/HeadToHeadCard.tsx @@ -0,0 +1,79 @@ +import { StyleSheet, Text, View } from 'react-native' + +import type { IndividualH2H, MatchInsightPlayer, PairH2H } from '@/services/stats.service' +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' + +function nameById(players: MatchInsightPlayer[], id: string): string { + return players.find((p) => p.user_id === id)?.display_name ?? 'Jugador' +} + +export function HeadToHeadCard({ + players, + individual, + pair, +}: { + players: MatchInsightPlayer[] + individual: IndividualH2H[] + pair: PairH2H | null +}) { + const meaningful = individual.filter((h) => h.wins_a + h.wins_b > 0) + + if (!meaningful.length && !pair) { + return Sin historial previo entre estos jugadores + } + + return ( + + {meaningful.map((h) => ( + + + {`${nameById(players, h.user_a)} vs ${nameById(players, h.user_b)}`} + + {`${h.wins_a}–${h.wins_b}`} + + ))} + {pair ? ( + + Pareja vs pareja + {`${pair.wins_a}–${pair.wins_b}`} + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + wrap: { + gap: 0, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 8, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Colors.border, + }, + pairRow: { + borderBottomWidth: 0, + marginTop: 4, + }, + names: { + flex: 1, + marginRight: 12, + fontFamily: Fonts.medium, + fontSize: 13, + color: Colors.textPrimary, + }, + score: { + fontFamily: Fonts.bold, + fontSize: 15, + color: Colors.primary, + }, + empty: { + fontFamily: Fonts.regular, + fontSize: 13, + color: Colors.textSecondary, + }, +}) diff --git a/src/components/stats/MatchInsightsSection.tsx b/src/components/stats/MatchInsightsSection.tsx new file mode 100644 index 0000000..a220e78 --- /dev/null +++ b/src/components/stats/MatchInsightsSection.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react' +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' + +import { ELOBadge } from '@/components/stats/ELOBadge' +import { FormBadges } from '@/components/stats/FormBadges' +import { HeadToHeadCard } from '@/components/stats/HeadToHeadCard' +import { useMatchInsights } from '@/hooks/useStats' +import { formatStreak } from '@/services/stats.service' +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' + +export function MatchInsightsSection({ matchId }: { matchId: string }) { + const { data, isPending, isError } = useMatchInsights(matchId) + const [expanded, setExpanded] = useState(true) + + if (isPending) { + return ( + + Estadísticas + + + ) + } + + if (isError || !data || data.players.length === 0) { + return null + } + + const teamA = data.players.filter((p) => p.team === 'A') + const teamB = data.players.filter((p) => p.team === 'B') + + return ( + + setExpanded((v) => !v)} + accessibilityRole="button" + style={styles.headerRow}> + Estadísticas del enfrentamiento + {expanded ? 'Ocultar' : 'Ver'} + + + {expanded ? ( + + + + {teamA.map((p) => ( + + + {p.display_name} + + + + {`${p.win_rate}%`} + {formatStreak(p.current_streak)} + + + + ))} + + + {teamB.map((p) => ( + + + {p.display_name} + + + + {`${p.win_rate}%`} + {formatStreak(p.current_streak)} + + + + ))} + + + + Head to head + + + ) : null} + + ) +} + +const styles = StyleSheet.create({ + section: { + marginBottom: 16, + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + }, + sectionTitle: { + fontSize: 13, + fontFamily: Fonts.semiBold, + color: Colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + toggle: { + fontFamily: Fonts.medium, + fontSize: 13, + color: Colors.primary, + }, + card: { + backgroundColor: Colors.surface, + borderRadius: 12, + padding: 14, + }, + teamsRow: { + flexDirection: 'row', + gap: 12, + }, + teamCol: { + flex: 1, + gap: 12, + }, + playerBlock: { + gap: 6, + }, + playerName: { + fontFamily: Fonts.semiBold, + fontSize: 14, + color: Colors.textPrimary, + }, + playerMeta: { + flexDirection: 'row', + flexWrap: 'wrap', + alignItems: 'center', + gap: 8, + }, + metaText: { + fontFamily: Fonts.medium, + fontSize: 12, + color: Colors.textSecondary, + }, + subTitle: { + marginTop: 16, + marginBottom: 6, + fontFamily: Fonts.semiBold, + fontSize: 12, + color: Colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.4, + }, +}) diff --git a/src/components/stats/PartnerList.tsx b/src/components/stats/PartnerList.tsx new file mode 100644 index 0000000..448f10e --- /dev/null +++ b/src/components/stats/PartnerList.tsx @@ -0,0 +1,81 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' + +import type { PartnerStat } from '@/services/stats.service' +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' + +export function PartnerList({ + partners, + onPressPartner, +}: { + partners: PartnerStat[] + onPressPartner?: (userId: string) => void +}) { + if (!partners.length) { + return Sin compañeros registrados + } + + return ( + + {partners.map((partner, index) => { + const content = ( + <> + + + {partner.display_name} + + + {`${partner.matches} juntas · ${partner.wins}G · ${partner.win_rate}%`} + + + + ) + + return onPressPartner ? ( + onPressPartner(partner.user_id)} + style={[styles.row, index === partners.length - 1 && styles.rowLast]}> + {content} + + ) : ( + + {content} + + ) + })} + + ) +} + +const styles = StyleSheet.create({ + row: { + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Colors.border, + }, + rowLast: { + borderBottomWidth: 0, + }, + info: { + flex: 1, + }, + title: { + fontFamily: Fonts.medium, + fontSize: 14, + color: Colors.textPrimary, + }, + meta: { + marginTop: 2, + fontFamily: Fonts.regular, + fontSize: 12, + color: Colors.textSecondary, + }, + empty: { + fontFamily: Fonts.regular, + fontSize: 13, + color: Colors.textSecondary, + }, +}) diff --git a/src/components/stats/ProfileStatsCard.tsx b/src/components/stats/ProfileStatsCard.tsx new file mode 100644 index 0000000..75ae79c --- /dev/null +++ b/src/components/stats/ProfileStatsCard.tsx @@ -0,0 +1,180 @@ +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' + +import { ELOBadge } from '@/components/stats/ELOBadge' +import { PodiumMedalsRow } from '@/components/stats/TournamentPodiumSection' +import { usePlayerStats } from '@/hooks/useStats' +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' + +export function ProfileStatsCard({ + userId, + onPressDetails, +}: { + userId: string + onPressDetails: () => void +}) { + const { data, isPending, isError, refetch } = usePlayerStats(userId) + + if (isPending) { + return ( + + Estadísticas + + + ) + } + + if (isError || !data) { + return ( + + Estadísticas + No se pudieron cargar las estadísticas. + void refetch()} + accessibilityRole="button" + style={styles.retryBtn}> + Reintentar + + + ) + } + + const medalTotal = data.podium.gold.length + data.podium.silver.length + data.podium.bronze.length + + return ( + + + Estadísticas + + + + + {data.win_rate}% + Win rate + + {`${data.wins}V · ${data.losses}D · ${data.matches_played} partidas`} + + + + + Podio + {medalTotal > 0 ? ( + + ) : ( + Sin medallas aún + )} + + + [styles.detailsBtn, pressed && styles.detailsBtnPressed]}> + Ver estadísticas detalladas + + + + ) +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: Colors.surface, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + gap: 14, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + cardTitle: { + fontSize: 13, + fontFamily: Fonts.semiBold, + color: Colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + winRateBlock: { + alignItems: 'center', + gap: 2, + paddingVertical: 4, + }, + winRateValue: { + fontFamily: Fonts.bold, + fontSize: 40, + color: Colors.primary, + lineHeight: 44, + }, + winRateLabel: { + fontFamily: Fonts.medium, + fontSize: 13, + color: Colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.4, + }, + matchesMeta: { + marginTop: 4, + fontFamily: Fonts.regular, + fontSize: 13, + color: Colors.textSecondary, + }, + medalsBlock: { + gap: 8, + paddingTop: 2, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: Colors.border, + }, + medalsLabel: { + fontFamily: Fonts.medium, + fontSize: 12, + color: Colors.textSecondary, + textTransform: 'uppercase', + letterSpacing: 0.4, + }, + medalsEmpty: { + fontFamily: Fonts.regular, + fontSize: 13, + color: Colors.textSecondary, + textAlign: 'center', + paddingVertical: 4, + }, + detailsBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + minHeight: 44, + borderRadius: 10, + backgroundColor: Colors.primary, + }, + detailsBtnPressed: { + opacity: 0.85, + }, + detailsBtnText: { + fontFamily: Fonts.semiBold, + fontSize: 15, + color: Colors.white, + }, + detailsBtnChevron: { + fontFamily: Fonts.semiBold, + fontSize: 20, + color: Colors.white, + lineHeight: 20, + }, + errorText: { + fontFamily: Fonts.regular, + fontSize: 14, + color: Colors.textSecondary, + }, + retryBtn: { + alignSelf: 'flex-start', + paddingVertical: 4, + }, + retryText: { + fontFamily: Fonts.semiBold, + fontSize: 14, + color: Colors.primary, + }, +}) diff --git a/src/components/stats/RivalryList.tsx b/src/components/stats/RivalryList.tsx new file mode 100644 index 0000000..e26a9a6 --- /dev/null +++ b/src/components/stats/RivalryList.tsx @@ -0,0 +1,108 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' + +import type { RivalStat } from '@/services/stats.service' +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' + +function RivalRow({ + label, + rival, + onPress, + isLast, +}: { + label: string + rival: RivalStat | null + onPress?: (userId: string) => void + isLast?: boolean +}) { + if (!rival) { + return ( + + {label} + + + ) + } + + const body = ( + <> + + {label} + + {rival.display_name} + + + {`${rival.wins}–${rival.losses}`} + + ) + + if (onPress) { + return ( + onPress(rival.user_id)} + style={[styles.row, isLast && styles.rowLast]}> + {body} + + ) + } + + return {body} +} + +export function RivalryList({ + nemesis, + bestVictim, + mostFaced, + onPressRival, +}: { + nemesis: RivalStat | null + bestVictim: RivalStat | null + mostFaced: RivalStat | null + onPressRival?: (userId: string) => void +}) { + return ( + + + + + + ) +} + +const styles = StyleSheet.create({ + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Colors.border, + }, + rowLast: { + borderBottomWidth: 0, + }, + info: { + flex: 1, + marginRight: 12, + }, + label: { + fontFamily: Fonts.regular, + fontSize: 12, + color: Colors.textSecondary, + }, + name: { + marginTop: 2, + fontFamily: Fonts.medium, + fontSize: 14, + color: Colors.textPrimary, + }, + record: { + fontFamily: Fonts.semiBold, + fontSize: 14, + color: Colors.primary, + }, + empty: { + fontFamily: Fonts.regular, + fontSize: 14, + color: Colors.textSecondary, + }, +}) diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx new file mode 100644 index 0000000..17ecb25 --- /dev/null +++ b/src/components/stats/StatCard.tsx @@ -0,0 +1,37 @@ +import { StyleSheet, Text, View } from 'react-native' + +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' + +export function StatCard({ label, value }: { label: string; value: string }) { + return ( + + {value} + {label} + + ) +} + +const styles = StyleSheet.create({ + statCard: { + flex: 1, + minWidth: '45%', + backgroundColor: Colors.surface, + borderRadius: 12, + paddingVertical: 14, + paddingHorizontal: 12, + alignItems: 'center', + }, + statValue: { + fontFamily: Fonts.bold, + fontSize: 22, + color: Colors.primary, + }, + statLabel: { + marginTop: 4, + fontFamily: Fonts.medium, + fontSize: 12, + color: Colors.textSecondary, + textAlign: 'center', + }, +}) diff --git a/src/components/stats/StatsGrid.tsx b/src/components/stats/StatsGrid.tsx new file mode 100644 index 0000000..ce43665 --- /dev/null +++ b/src/components/stats/StatsGrid.tsx @@ -0,0 +1,23 @@ +import { StyleSheet, View, type ViewStyle } from 'react-native' + +import { StatCard } from '@/components/stats/StatCard' + +type StatItem = { label: string; value: string } + +export function StatsGrid({ items, style }: { items: StatItem[]; style?: ViewStyle }) { + return ( + + {items.map((item) => ( + + ))} + + ) +} + +const styles = StyleSheet.create({ + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, +}) diff --git a/src/components/stats/TournamentPodiumSection.tsx b/src/components/stats/TournamentPodiumSection.tsx new file mode 100644 index 0000000..eb1ae79 --- /dev/null +++ b/src/components/stats/TournamentPodiumSection.tsx @@ -0,0 +1,267 @@ +import { Pressable, StyleSheet, Text, View } from 'react-native' + +import type { Podium, PodiumEntry, PodiumSource } from '@/services/stats.service' +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' }, +} as const + +const SOURCE_LABEL: Record = { + tournament: 'Torneo', + league: 'Liga', +} + +function MedalBadge({ + kind, + count, + compact, +}: { + kind: keyof typeof MEDAL + count: number + compact?: boolean +}) { + const meta = MEDAL[kind] + return ( + + {meta.emoji} + {count} + {!compact ? {meta.label} : null} + + ) +} + +function formatEventDate(iso: string): string { + return new Date(iso).toLocaleDateString('es-ES', { + day: '2-digit', + month: 'short', + year: 'numeric', + }) +} + +function SourceChip({ source }: { source: PodiumSource }) { + return ( + + {SOURCE_LABEL[source]} + + ) +} + +function PodiumList({ + kind, + entries, + onPressEntry, +}: { + kind: keyof typeof MEDAL + entries: PodiumEntry[] + onPressEntry?: (entry: PodiumEntry) => void +}) { + const meta = MEDAL[kind] + + if (!entries.length) { + return ( + + + {meta.emoji} + {meta.label} + + Sin podios + + ) + } + + return ( + + + {meta.emoji} + {meta.label} + + {entries.map((entry, index) => { + const row = ( + + + + {entry.title} + + {formatEventDate(entry.start_at)} + + + + ) + + return onPressEntry ? ( + onPressEntry(entry)} + style={[styles.row, index === entries.length - 1 && styles.rowLast]}> + {row} + + ) : ( + + {row} + + ) + })} + + ) +} + +export function PodiumMedalsRow({ podium }: { podium: Podium }) { + return ( + + + + + + ) +} + +/** @deprecated Use PodiumMedalsRow */ +export const TournamentMedalsRow = PodiumMedalsRow + +export function PodiumSection({ + podium, + onPressEntry, + showMedalCounts = true, +}: { + podium: Podium + onPressEntry?: (entry: PodiumEntry) => void + showMedalCounts?: boolean +}) { + 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 ( + + {showMedalCounts ? ( + + + + + + ) : null} + + + + + ) +} + +/** @deprecated Use PodiumSection */ +export const TournamentPodiumSection = PodiumSection + +const styles = StyleSheet.create({ + wrap: { + gap: 16, + }, + medalsRow: { + flexDirection: 'row', + justifyContent: 'space-around', + gap: 8, + }, + medalsRowExpanded: { + flexDirection: 'row', + justifyContent: 'space-around', + gap: 8, + paddingBottom: 4, + }, + medalBadge: { + alignItems: 'center', + flex: 1, + gap: 2, + }, + medalBadgeCompact: { + flexDirection: 'row', + gap: 4, + flex: 0, + }, + medalEmoji: { + fontSize: 22, + }, + medalCount: { + fontFamily: Fonts.bold, + fontSize: 18, + }, + medalLabel: { + fontFamily: Fonts.medium, + fontSize: 11, + color: Colors.textSecondary, + }, + section: { + gap: 4, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 2, + }, + sectionEmoji: { + fontSize: 16, + }, + sectionTitle: { + fontFamily: Fonts.semiBold, + fontSize: 13, + color: Colors.textPrimary, + }, + row: { + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Colors.border, + }, + rowLast: { + borderBottomWidth: 0, + }, + rowContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + rowInfo: { + flex: 1, + }, + rowTitle: { + fontFamily: Fonts.medium, + fontSize: 14, + color: Colors.textPrimary, + }, + rowMeta: { + marginTop: 2, + fontFamily: Fonts.regular, + fontSize: 12, + color: Colors.textSecondary, + }, + sourceChip: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + backgroundColor: Colors.surface, + }, + sourceChipLeague: { + backgroundColor: Colors.border, + }, + sourceChipText: { + fontFamily: Fonts.medium, + fontSize: 11, + color: Colors.textSecondary, + }, + empty: { + fontFamily: Fonts.regular, + fontSize: 13, + color: Colors.textSecondary, + paddingVertical: 4, + }, + emptyBlock: { + fontFamily: Fonts.regular, + fontSize: 13, + color: Colors.textSecondary, + }, +}) diff --git a/src/components/stats/VenueList.tsx b/src/components/stats/VenueList.tsx new file mode 100644 index 0000000..32adc7c --- /dev/null +++ b/src/components/stats/VenueList.tsx @@ -0,0 +1,75 @@ +import { StyleSheet, Text, View } from 'react-native' + +import type { VenueStat } from '@/services/stats.service' +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' + +function venueLabel(venue: VenueStat): string { + if (venue.place_text) return `${venue.city} · ${venue.place_text}` + return venue.city || 'Sin lugar' +} + +export function VenueList({ venues }: { venues: VenueStat[] }) { + if (!venues.length) { + return Sin sitios registrados + } + + return ( + + {venues.map((venue, index) => ( + + + + {venueLabel(venue)} + + {`${venue.matches} partidas`} + + {`${venue.win_rate}%`} + + ))} + + ) +} + +const styles = StyleSheet.create({ + list: { + gap: 0, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: Colors.border, + }, + rowLast: { + borderBottomWidth: 0, + }, + info: { + flex: 1, + marginRight: 12, + }, + title: { + fontFamily: Fonts.medium, + fontSize: 14, + color: Colors.textPrimary, + }, + meta: { + marginTop: 2, + fontFamily: Fonts.regular, + fontSize: 12, + color: Colors.textSecondary, + }, + rate: { + fontFamily: Fonts.semiBold, + fontSize: 14, + color: Colors.primary, + }, + empty: { + fontFamily: Fonts.regular, + fontSize: 13, + color: Colors.textSecondary, + }, +}) diff --git a/src/components/stats/WinRateBar.tsx b/src/components/stats/WinRateBar.tsx new file mode 100644 index 0000000..35f8496 --- /dev/null +++ b/src/components/stats/WinRateBar.tsx @@ -0,0 +1,51 @@ +import { StyleSheet, Text, View } from 'react-native' + +import { Colors } from '@/theme/colors' +import { Fonts } from '@/theme/typography' + +export function WinRateBar({ + winRate, + wins, + losses, +}: { + winRate: number + wins: number + losses: number +}) { + const pct = Math.max(0, Math.min(100, winRate)) + return ( + + + {`${pct}% victorias`} + {`${wins}G · ${losses}P`} + + + + + + ) +} + +const styles = StyleSheet.create({ + metaRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 6, + }, + meta: { + fontFamily: Fonts.medium, + fontSize: 12, + color: Colors.textSecondary, + }, + track: { + height: 8, + borderRadius: 4, + backgroundColor: Colors.historyLostBackground, + overflow: 'hidden', + }, + fill: { + height: '100%', + backgroundColor: Colors.primary, + borderRadius: 4, + }, +}) diff --git a/src/hooks/useMatches.ts b/src/hooks/useMatches.ts index be06e19..c4d1acc 100644 --- a/src/hooks/useMatches.ts +++ b/src/hooks/useMatches.ts @@ -30,6 +30,7 @@ import { } from '@/services/tournaments.service' import { useAuthStore } from '@/hooks/useAuth' import { invalidateTournamentQueries } from '@/hooks/useTournaments' +import { invalidatePlayerStatsCaches } from '@/hooks/useStats' import { MATCH_PAGE_SIZE, MATCH_STATUS, @@ -413,6 +414,7 @@ export function useRecordMatchResultDirect() { invalidateMyMatchesDashboard(queryClient, sessionUserId) } invalidatePublicMatchesExplore(queryClient) + invalidatePlayerStatsCaches(queryClient) }, }) } diff --git a/src/hooks/useResults.ts b/src/hooks/useResults.ts index 6269f00..220be3a 100644 --- a/src/hooks/useResults.ts +++ b/src/hooks/useResults.ts @@ -9,6 +9,7 @@ import { invalidateMyMatchesDashboard, invalidatePublicExplore, } from '@/hooks/useMatches' +import { invalidatePlayerStatsCaches } from '@/hooks/useStats' import { invalidateTournamentQueries } from '@/hooks/useTournaments' import { fetchMatchResultBundle, @@ -53,6 +54,7 @@ export function useSubmitResult() { queryClient.invalidateQueries({ queryKey: userMatchesQueryKey(sessionUserId) }) invalidateMyMatchesDashboard(queryClient, sessionUserId) } + invalidatePlayerStatsCaches(queryClient) }, }) } @@ -80,6 +82,7 @@ export function useSubmitConfirmation() { queryClient.invalidateQueries({ queryKey: userMatchesQueryKey(sessionUserId) }) invalidateMyMatchesDashboard(queryClient, sessionUserId) } + invalidatePlayerStatsCaches(queryClient) }, }) } diff --git a/src/hooks/useStats.ts b/src/hooks/useStats.ts new file mode 100644 index 0000000..69e1767 --- /dev/null +++ b/src/hooks/useStats.ts @@ -0,0 +1,51 @@ +import type { QueryClient } from '@tanstack/react-query' +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' + +export function playerStatsQueryKey(userId: string) { + return ['player-stats', userId] as const +} + +export function matchInsightsQueryKey(matchId: string) { + return ['match-insights', matchId] as const +} + +export function leaderboardQueryKey(city?: string | null) { + return ['leaderboard', city?.trim() || 'all'] 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 }) +} + +export function usePlayerStats(userId?: string | null) { + return useQuery({ + queryKey: playerStatsQueryKey(userId ?? ''), + queryFn: () => getPlayerStats(userId!), + enabled: Boolean(userId), + staleTime: QUERY_STALE_TIME, + }) +} + +export function useMatchInsights(matchId?: string | null) { + const viewerId = useAuthStore((s) => s.session?.user.id) + return useQuery({ + queryKey: matchInsightsQueryKey(matchId ?? ''), + queryFn: () => getMatchInsights(matchId!, viewerId), + enabled: Boolean(matchId), + staleTime: QUERY_STALE_TIME, + }) +} + +export function useLeaderboard(city?: string | null) { + return useQuery({ + queryKey: leaderboardQueryKey(city), + queryFn: () => getLeaderboard(city), + staleTime: QUERY_STALE_TIME, + }) +} diff --git a/src/lib/invalidateExploreCaches.ts b/src/lib/invalidateExploreCaches.ts index 21c887c..3f47043 100644 --- a/src/lib/invalidateExploreCaches.ts +++ b/src/lib/invalidateExploreCaches.ts @@ -6,6 +6,7 @@ import { matchQueryKey, userMatchesQueryKey, } from '@/hooks/useMatches' +import { invalidatePlayerStatsCaches } from '@/hooks/useStats' import { invalidateTournamentQueries } from '@/hooks/useTournaments' export type { RealtimeListTable } from '@/lib/realtimeRowIds' @@ -45,6 +46,9 @@ export function invalidateAllExploreListQueries( queryKey: [...matchQueryKey(matchId), 'match_result'], exact: false, }) + queryClient.invalidateQueries({ queryKey: ['match-insights', matchId] }) + // Confirmed results update ELO / aggregates; refresh player stats + leaderboard. + invalidatePlayerStatsCaches(queryClient) if (tournamentId) { invalidateTournamentQueries(queryClient, tournamentId) } diff --git a/src/services/matches.service.ts b/src/services/matches.service.ts index 8e00a34..96eb838 100644 --- a/src/services/matches.service.ts +++ b/src/services/matches.service.ts @@ -511,7 +511,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) diff --git a/src/services/stats.service.ts b/src/services/stats.service.ts new file mode 100644 index 0000000..893e02e --- /dev/null +++ b/src/services/stats.service.ts @@ -0,0 +1,392 @@ +import { supabase } from '@/lib/supabase' + +export type FormOutcome = 'won' | 'lost' + +export type PlayerBadge = { + key: string + earned_at: string +} + +export type VenueStat = { + city: string + place_text: string | null + matches: number + wins: number + win_rate: number +} + +export type PartnerStat = { + user_id: string + display_name: string + photo_url: string | null + matches: number + wins: number + win_rate: number +} + +export type RivalStat = { + user_id: string + display_name: string + photo_url: string | null + matches: number + wins: number + losses: number +} + +export type PodiumSource = 'tournament' | 'league' + +export type PodiumEntry = { + id: string + title: string + start_at: string + source: PodiumSource +} + +export type Podium = { + gold: PodiumEntry[] + silver: PodiumEntry[] + bronze: PodiumEntry[] +} + +/** @deprecated Use PodiumEntry */ +export type TournamentPodiumEntry = PodiumEntry & { tournament_id?: string } + +/** @deprecated Use Podium */ +export type TournamentPodium = Podium + +export type PlayerStats = { + user_id: string + elo_rating: number + matches_played: number + wins: number + losses: number + win_rate: number + current_streak: number + best_win_streak: number + last_form: FormOutcome[] + badges: PlayerBadge[] + tournaments_won: number + tournament_finals: number + tournament_thirds: number + tournaments_participated: number + leagues_participated: number + podium: Podium + venues: VenueStat[] + partners: PartnerStat[] + rivalries: { + nemesis: RivalStat | null + best_victim: RivalStat | null + most_faced: RivalStat | null + } +} + +export type MatchInsightPlayer = { + user_id: string + display_name: string + photo_url: string | null + team: 'A' | 'B' | string + elo_rating: number + matches_played: number + wins: number + losses: number + win_rate: number + current_streak: number + last_form: FormOutcome[] +} + +export type IndividualH2H = { + user_a: string + user_b: string + wins_a: number + wins_b: number + last_meeting: string | null +} + +export type PairH2H = { + wins_a: number + wins_b: number + last_meeting: string | null +} + +export type MatchInsights = { + match_id: string + players: MatchInsightPlayer[] + individual_h2h: IndividualH2H[] + pair_h2h: PairH2H | null +} + +export type LeaderboardEntry = { + user_id: string + display_name: string + photo_url: string | null + city: string | null + elo_rating: number + matches_played: number + wins: number + losses: number + win_rate: number +} + +function asForm(value: unknown): FormOutcome[] { + if (!Array.isArray(value)) return [] + return value.filter((v): v is FormOutcome => v === 'won' || v === 'lost') +} + +function asBadges(value: unknown): PlayerBadge[] { + if (!Array.isArray(value)) return [] + return value + .map((item) => { + if (!item || typeof item !== 'object') return null + const row = item as Record + if (typeof row.key !== 'string') return null + return { + key: row.key, + earned_at: typeof row.earned_at === 'string' ? row.earned_at : '', + } + }) + .filter((b): b is PlayerBadge => b != null) +} + +function asRival(value: unknown): RivalStat | null { + if (!value || typeof value !== 'object') return null + const row = value as Record + if (typeof row.user_id !== 'string' || typeof row.display_name !== 'string') return null + return { + user_id: row.user_id, + display_name: row.display_name, + photo_url: typeof row.photo_url === 'string' ? row.photo_url : null, + matches: Number(row.matches ?? 0), + wins: Number(row.wins ?? 0), + losses: Number(row.losses ?? 0), + } +} + +function asPodiumSource(value: unknown): PodiumSource { + return value === 'league' ? 'league' : 'tournament' +} + +function asPodiumEntries(value: unknown): PodiumEntry[] { + if (!Array.isArray(value)) return [] + return value + .map((item) => { + const row = (item ?? {}) as Record + const id = + typeof row.id === 'string' + ? row.id + : typeof row.tournament_id === 'string' + ? row.tournament_id + : typeof row.league_id === 'string' + ? row.league_id + : null + if (!id || typeof row.title !== 'string') return null + return { + id, + title: row.title, + start_at: typeof row.start_at === 'string' ? row.start_at : '', + source: asPodiumSource(row.source), + } + }) + .filter((e): e is PodiumEntry => e != null) +} + +function asPodium(value: unknown): Podium { + const row = value && typeof value === 'object' ? (value as Record) : {} + return { + gold: asPodiumEntries(row.gold), + silver: asPodiumEntries(row.silver), + bronze: asPodiumEntries(row.bronze), + } +} + +function parsePlayerStats(raw: unknown): PlayerStats | null { + if (!raw || typeof raw !== 'object') return null + const row = raw as Record + if (typeof row.user_id !== 'string') return null + + const rivalriesRaw = + row.rivalries && typeof row.rivalries === 'object' + ? (row.rivalries as Record) + : {} + + return { + user_id: row.user_id, + elo_rating: Number(row.elo_rating ?? 1200), + matches_played: Number(row.matches_played ?? 0), + wins: Number(row.wins ?? 0), + losses: Number(row.losses ?? 0), + win_rate: Number(row.win_rate ?? 0), + current_streak: Number(row.current_streak ?? 0), + best_win_streak: Number(row.best_win_streak ?? 0), + last_form: asForm(row.last_form), + badges: asBadges(row.badges), + tournaments_won: Number(row.tournaments_won ?? 0), + tournament_finals: Number(row.tournament_finals ?? 0), + tournament_thirds: Number(row.tournament_thirds ?? 0), + tournaments_participated: Number(row.tournaments_participated ?? 0), + leagues_participated: Number(row.leagues_participated ?? 0), + podium: asPodium(row.podium ?? row.tournament_podium), + venues: Array.isArray(row.venues) + ? row.venues.map((v) => { + const venue = (v ?? {}) as Record + return { + city: String(venue.city ?? ''), + place_text: typeof venue.place_text === 'string' ? venue.place_text : null, + matches: Number(venue.matches ?? 0), + wins: Number(venue.wins ?? 0), + win_rate: Number(venue.win_rate ?? 0), + } + }) + : [], + partners: Array.isArray(row.partners) + ? row.partners.map((p) => { + const partner = (p ?? {}) as Record + return { + user_id: String(partner.user_id ?? ''), + display_name: String(partner.display_name ?? ''), + photo_url: typeof partner.photo_url === 'string' ? partner.photo_url : null, + matches: Number(partner.matches ?? 0), + wins: Number(partner.wins ?? 0), + win_rate: Number(partner.win_rate ?? 0), + } + }) + : [], + rivalries: { + nemesis: asRival(rivalriesRaw.nemesis), + best_victim: asRival(rivalriesRaw.best_victim), + most_faced: asRival(rivalriesRaw.most_faced), + }, + } +} + +function parseMatchInsights(raw: unknown): MatchInsights | null { + if (!raw || typeof raw !== 'object') return null + const row = raw as Record + if (typeof row.match_id !== 'string') return null + + const players: MatchInsightPlayer[] = Array.isArray(row.players) + ? row.players.map((p) => { + const player = (p ?? {}) as Record + return { + user_id: String(player.user_id ?? ''), + display_name: String(player.display_name ?? ''), + photo_url: typeof player.photo_url === 'string' ? player.photo_url : null, + team: String(player.team ?? ''), + elo_rating: Number(player.elo_rating ?? 1200), + matches_played: Number(player.matches_played ?? 0), + wins: Number(player.wins ?? 0), + losses: Number(player.losses ?? 0), + win_rate: Number(player.win_rate ?? 0), + current_streak: Number(player.current_streak ?? 0), + last_form: asForm(player.last_form), + } + }) + : [] + + const individual_h2h: IndividualH2H[] = Array.isArray(row.individual_h2h) + ? row.individual_h2h.map((h) => { + const item = (h ?? {}) as Record + return { + user_a: String(item.user_a ?? ''), + user_b: String(item.user_b ?? ''), + wins_a: Number(item.wins_a ?? 0), + wins_b: Number(item.wins_b ?? 0), + last_meeting: typeof item.last_meeting === 'string' ? item.last_meeting : null, + } + }) + : [] + + let pair_h2h: PairH2H | null = null + if (row.pair_h2h && typeof row.pair_h2h === 'object') { + const pair = row.pair_h2h as Record + pair_h2h = { + wins_a: Number(pair.wins_a ?? 0), + wins_b: Number(pair.wins_b ?? 0), + last_meeting: typeof pair.last_meeting === 'string' ? pair.last_meeting : null, + } + } + + return { + match_id: row.match_id, + players, + individual_h2h, + pair_h2h, + } +} + +function parseLeaderboard(raw: unknown): LeaderboardEntry[] { + if (!Array.isArray(raw)) return [] + return raw.map((item) => { + const row = (item ?? {}) as Record + return { + user_id: String(row.user_id ?? ''), + display_name: String(row.display_name ?? ''), + photo_url: typeof row.photo_url === 'string' ? row.photo_url : null, + city: typeof row.city === 'string' ? row.city : null, + elo_rating: Number(row.elo_rating ?? 1200), + matches_played: Number(row.matches_played ?? 0), + wins: Number(row.wins ?? 0), + losses: Number(row.losses ?? 0), + win_rate: Number(row.win_rate ?? 0), + } + }) +} + +export async function getPlayerStats(userId: string): Promise { + const { data, error } = await supabase.rpc('get_player_stats', { p_user_id: userId }) + if (error) { + const details = + typeof error === 'object' && error && 'code' in error ? String(error.code) : undefined + throw new Error(details ? `Estadísticas (${details}): ${error.message}` : error.message) + } + const parsed = parsePlayerStats(data) + if (!parsed) throw new Error('No se pudieron cargar las estadísticas') + return parsed +} + +export async function getMatchInsights( + matchId: string, + viewerId?: string | null +): Promise { + const { data, error } = await supabase.rpc('get_match_player_insights', { + p_match_id: matchId, + p_viewer_id: viewerId ?? undefined, + }) + if (error) throw new Error(error.message) + const parsed = parseMatchInsights(data) + if (!parsed) throw new Error('No se pudieron cargar las estadísticas de la partida') + return parsed +} + +export async function getLeaderboard( + city?: string | null, + limit = 50 +): Promise { + const { data, error } = await supabase.rpc('get_leaderboard', { + p_city: city?.trim() ? city.trim() : undefined, + p_limit: limit, + }) + if (error) throw new Error(error.message) + return parseLeaderboard(data) +} + +export const BADGE_LABELS: Record = { + first_win: 'Primera victoria', + wins_10: '10 victorias', + wins_25: '25 victorias', + wins_50: '50 victorias', + wins_100: '100 victorias', + tournament_winner: 'Campeón de torneo', + tournament_finalist: 'Finalista', + streak_5: 'Racha de 5', + streak_10: 'Racha de 10', + veteran_50: 'Veterano (50)', + veteran_100: 'Veterano (100)', + explorer_5: 'Explorador', + nemesis_confirmed: 'Cazador de rivales', +} + +export function formatStreak(streak: number): string { + if (streak === 0) return '—' + if (streak > 0) return `${streak}V` + return `${Math.abs(streak)}D` +} diff --git a/src/types/database.types.ts b/src/types/database.types.ts index 1fd8bb2..f4c78c9 100644 --- a/src/types/database.types.ts +++ b/src/types/database.types.ts @@ -245,6 +245,7 @@ export type Database = { tournament_bracket_position: number | null tournament_id: string | null tournament_is_bye: boolean + tournament_is_third_place: boolean tournament_pair_a_id: string | null tournament_pair_b_id: string | null tournament_round_size: number | null @@ -275,6 +276,7 @@ export type Database = { tournament_bracket_position?: number | null tournament_id?: string | null tournament_is_bye?: boolean + tournament_is_third_place?: boolean tournament_pair_a_id?: string | null tournament_pair_b_id?: string | null tournament_round_size?: number | null @@ -305,6 +307,7 @@ export type Database = { tournament_bracket_position?: number | null tournament_id?: string | null tournament_is_bye?: boolean + tournament_is_third_place?: boolean tournament_pair_a_id?: string | null tournament_pair_b_id?: string | null tournament_round_size?: number | null @@ -417,6 +420,69 @@ export type Database = { }, ] } + player_stats: { + Row: { + badges: Json + best_win_streak: number + current_streak: number + elo_rating: number + last_form: Json + losses: number + matches_played: number + tournament_finals: number + tournament_thirds: number + tournaments_won: number + updated_at: string + user_id: string + wins: number + } + Insert: { + badges?: Json + best_win_streak?: number + current_streak?: number + elo_rating?: number + last_form?: Json + losses?: number + matches_played?: number + tournament_finals?: number + tournament_thirds?: number + tournaments_won?: number + updated_at?: string + user_id: string + wins?: number + } + Update: { + badges?: Json + best_win_streak?: number + current_streak?: number + elo_rating?: number + last_form?: Json + losses?: number + matches_played?: number + tournament_finals?: number + tournament_thirds?: number + tournaments_won?: number + updated_at?: string + user_id?: string + wins?: number + } + Relationships: [ + { + foreignKeyName: 'player_stats_user_id_fkey' + columns: ['user_id'] + isOneToOne: true + referencedRelation: 'profiles' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'player_stats_user_id_fkey' + columns: ['user_id'] + isOneToOne: true + referencedRelation: 'profiles_public' + referencedColumns: ['id'] + }, + ] + } profiles: { Row: { city: string | null @@ -1089,6 +1155,18 @@ export type Database = { isSetofReturn: true } } + get_leaderboard: { + Args: { p_city?: string; p_limit?: number } + Returns: Json + } + get_match_player_insights: { + Args: { p_match_id: string; p_viewer_id?: string } + Returns: Json + } + get_player_stats: { + Args: { p_user_id: string } + Returns: Json + } get_public_profile: { Args: { p_profile_id: string } Returns: { diff --git a/supabase/migrations/20260810120000_086_player_stats.sql b/supabase/migrations/20260810120000_086_player_stats.sql new file mode 100644 index 0000000..203c2cd --- /dev/null +++ b/supabase/migrations/20260810120000_086_player_stats.sql @@ -0,0 +1,910 @@ +-- Migration 086: Player statistics, ELO, H2H insights, leaderboard + +-- ─── Table ─────────────────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS public.player_stats ( + user_id UUID PRIMARY KEY REFERENCES public.profiles (id) ON DELETE CASCADE, + elo_rating INT NOT NULL DEFAULT 1200, + matches_played INT NOT NULL DEFAULT 0, + wins INT NOT NULL DEFAULT 0, + losses INT NOT NULL DEFAULT 0, + current_streak INT NOT NULL DEFAULT 0, + best_win_streak INT NOT NULL DEFAULT 0, + tournaments_won INT NOT NULL DEFAULT 0, + tournament_finals INT NOT NULL DEFAULT 0, + tournament_thirds INT NOT NULL DEFAULT 0, + last_form JSONB NOT NULL DEFAULT '[]'::jsonb, + badges JSONB NOT NULL DEFAULT '[]'::jsonb, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE public.player_stats ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS player_stats_select_authenticated ON public.player_stats; +CREATE POLICY player_stats_select_authenticated + ON public.player_stats + FOR SELECT + TO authenticated + USING (true); + +-- No INSERT/UPDATE/DELETE policies for authenticated — only SECURITY DEFINER writes. + +CREATE INDEX IF NOT EXISTS player_stats_elo_rating_idx + ON public.player_stats (elo_rating DESC); + +-- ─── Helpers ───────────────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.ensure_player_stats_row(p_user_id UUID) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + INSERT INTO public.player_stats (user_id) + VALUES (p_user_id) + ON CONFLICT (user_id) DO NOTHING; +END; +$$; + +REVOKE ALL ON FUNCTION public.ensure_player_stats_row(UUID) FROM PUBLIC; + +-- Confirmed finished matches for a user (no byes). +CREATE OR REPLACE FUNCTION public._player_confirmed_match_rows(p_user_id UUID) +RETURNS TABLE ( + match_id UUID, + start_at TIMESTAMPTZ, + team TEXT, + team_a_games INT, + team_b_games INT, + city TEXT, + place_text TEXT, + tournament_id UUID, + tournament_round_size INT, + tournament_is_third_place BOOLEAN, + tournament_winner_pair_id UUID, + tournament_pair_a_id UUID, + tournament_pair_b_id UUID +) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT + m.id AS match_id, + m.start_at, + mp.team, + mr.team_a_games, + mr.team_b_games, + m.city, + m.place_text, + m.tournament_id, + m.tournament_round_size, + COALESCE(m.tournament_is_third_place, FALSE) AS tournament_is_third_place, + m.tournament_winner_pair_id, + m.tournament_pair_a_id, + m.tournament_pair_b_id + 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' + AND m.status = 'finished' + AND COALESCE(m.tournament_is_bye, FALSE) = FALSE + ORDER BY m.start_at ASC, m.id ASC; +$$; + +REVOKE ALL ON FUNCTION public._player_confirmed_match_rows(UUID) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION public._player_won_match( + p_team TEXT, + p_team_a_games INT, + p_team_b_games INT +) +RETURNS BOOLEAN +LANGUAGE sql +IMMUTABLE +SET search_path = '' +AS $$ + SELECT CASE + WHEN p_team_a_games = p_team_b_games THEN NULL + WHEN p_team = 'A' THEN p_team_a_games > p_team_b_games + ELSE p_team_b_games > p_team_a_games + END; +$$; + +REVOKE ALL ON FUNCTION public._player_won_match(TEXT, INT, INT) FROM PUBLIC; + +-- Rebuild non-ELO aggregates + badges for one user from full history. +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; +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; + + -- Tournament finals (round_size=2, not third-place match) + 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; + + -- Third place wins + IF r.tournament_id IS NOT NULL + AND r.tournament_is_third_place + AND r.tournament_winner_pair_id IS NOT NULL + AND ( + (r.team = 'A' AND r.tournament_winner_pair_id = r.tournament_pair_a_id) + OR (r.team = 'B' AND r.tournament_winner_pair_id = r.tournament_pair_b_id) + ) THEN + v_t_thirds := v_t_thirds + 1; + END IF; + END LOOP; + + v_streak := v_run; + + IF cardinality(v_form_arr) > 5 THEN + v_form_arr := v_form_arr[(cardinality(v_form_arr) - 4):cardinality(v_form_arr)]; + END IF; + v_form := to_jsonb(v_form_arr); + + SELECT COUNT(DISTINCT (city || '|' || COALESCE(place_text, '')))::INT + INTO v_venues + FROM public._player_confirmed_match_rows(p_user_id); + + -- Best individual H2H wins vs a single rival (for nemesis_confirmed badge) + 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 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_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; + +-- Apply ELO delta for one confirmed match (team avg, K=32). +CREATE OR REPLACE FUNCTION public.apply_match_elo(p_match_id UUID) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_match public.matches%ROWTYPE; + v_result public.match_results%ROWTYPE; + 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_a NUMERIC; + v_delta_b NUMERIC; + v_uid UUID; + k CONSTANT NUMERIC := 32; +BEGIN + SELECT * INTO v_match FROM public.matches WHERE id = p_match_id; + IF NOT FOUND THEN + RETURN; + END IF; + IF v_match.status <> 'finished' OR COALESCE(v_match.tournament_is_bye, FALSE) 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 v_result.team_a_games = v_result.team_b_games THEN + RETURN; + END IF; + + SELECT COALESCE(array_agg(mp.user_id), ARRAY[]::UUID[]) + INTO v_team_a + FROM public.match_participants mp + WHERE mp.match_id = p_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 = p_match_id AND mp.state = 'confirmed' AND mp.team = 'B'; + + IF cardinality(v_team_a) = 0 OR cardinality(v_team_b) = 0 THEN + RETURN; + END IF; + + FOREACH v_uid IN ARRAY (v_team_a || v_team_b) + LOOP + PERFORM public.ensure_player_stats_row(v_uid); + END LOOP; + + SELECT AVG(ps.elo_rating)::NUMERIC INTO v_elo_a + FROM public.player_stats ps WHERE ps.user_id = ANY (v_team_a); + + SELECT AVG(ps.elo_rating)::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 v_result.team_a_games > v_result.team_b_games THEN 1.0 ELSE 0.0 END; + v_delta_a := ROUND(k * (v_score_a - v_expected_a)); + v_delta_b := -v_delta_a; + + FOREACH v_uid IN ARRAY v_team_a + LOOP + UPDATE public.player_stats + SET elo_rating = GREATEST(100, elo_rating + v_delta_a::INT), updated_at = NOW() + WHERE user_id = v_uid; + END LOOP; + + FOREACH v_uid IN ARRAY v_team_b + LOOP + UPDATE public.player_stats + SET elo_rating = GREATEST(100, elo_rating + v_delta_b::INT), updated_at = NOW() + WHERE user_id = v_uid; + END LOOP; +END; +$$; + +REVOKE ALL ON FUNCTION public.apply_match_elo(UUID) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION public.recompute_player_stats_for_match(p_match_id UUID) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_uid UUID; +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM public.matches m + JOIN public.match_results mr ON mr.match_id = m.id AND mr.status = 'confirmed' + WHERE m.id = p_match_id + AND m.status = 'finished' + AND COALESCE(m.tournament_is_bye, FALSE) = FALSE + ) THEN + RETURN; + END IF; + + -- ELO first (uses pre-recompute ratings), then full aggregate rebuild. + PERFORM public.apply_match_elo(p_match_id); + + FOR v_uid IN + SELECT DISTINCT mp.user_id + FROM public.match_participants mp + WHERE mp.match_id = p_match_id + AND mp.state = 'confirmed' + AND mp.user_id IS NOT NULL + LOOP + PERFORM public.recompute_player_stats_aggregates(v_uid); + END LOOP; +END; +$$; + +REVOKE ALL ON FUNCTION public.recompute_player_stats_for_match(UUID) FROM PUBLIC; + +CREATE OR REPLACE FUNCTION public.fn_on_match_result_confirmed() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF NEW.status = 'confirmed' + AND (TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM 'confirmed') THEN + PERFORM public.recompute_player_stats_for_match(NEW.match_id); + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_player_stats_on_result_confirmed ON public.match_results; +CREATE TRIGGER trg_player_stats_on_result_confirmed + AFTER INSERT OR UPDATE OF status ON public.match_results + FOR EACH ROW + EXECUTE FUNCTION public.fn_on_match_result_confirmed(); + +-- One-shot backfill: reset ELO, process matches chronologically, rebuild aggregates. +CREATE OR REPLACE FUNCTION public.backfill_player_stats() +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + r RECORD; + v_uid UUID; +BEGIN + INSERT INTO public.player_stats (user_id) + SELECT p.id FROM public.profiles p + ON CONFLICT (user_id) DO NOTHING; + + UPDATE public.player_stats + SET + elo_rating = 1200, + matches_played = 0, + wins = 0, + losses = 0, + current_streak = 0, + best_win_streak = 0, + tournaments_won = 0, + tournament_finals = 0, + tournament_thirds = 0, + last_form = '[]'::jsonb, + badges = '[]'::jsonb, + updated_at = NOW(); + + FOR r IN + SELECT m.id + FROM public.matches m + JOIN public.match_results mr ON mr.match_id = m.id AND mr.status = 'confirmed' + WHERE m.status = 'finished' + AND COALESCE(m.tournament_is_bye, FALSE) = FALSE + ORDER BY m.start_at ASC, m.id ASC + LOOP + PERFORM public.apply_match_elo(r.id); + END LOOP; + + FOR v_uid IN SELECT user_id FROM public.player_stats + LOOP + PERFORM public.recompute_player_stats_aggregates(v_uid); + END LOOP; +END; +$$; + +REVOKE ALL ON FUNCTION public.backfill_player_stats() FROM PUBLIC; +-- Intentionally not granted to authenticated — run via service role / SQL editor. + +-- ─── get_player_stats ──────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.get_player_stats(p_user_id UUID) +RETURNS JSONB +LANGUAGE plpgsql +STABLE +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.ensure_player_stats_row(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; +$$; + +REVOKE ALL ON FUNCTION public.get_player_stats(UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.get_player_stats(UUID) TO authenticated; + +-- ─── get_match_player_insights ─────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.get_match_player_insights( + p_match_id UUID, + p_viewer_id UUID DEFAULT NULL +) +RETURNS JSONB +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_players JSONB := '[]'::jsonb; + v_h2h JSONB := '[]'::jsonb; + v_pair JSONB := NULL; + v_team_a UUID[]; + v_team_b UUID[]; + ua UUID; + ub UUID; + v_wins_a INT; + v_wins_b INT; + v_last TIMESTAMPTZ; + v_pair_wins_a INT; + v_pair_wins_b INT; + v_pair_last TIMESTAMPTZ; +BEGIN + IF p_match_id IS NULL THEN + RETURN NULL; + END IF; + + IF NOT public.viewer_can_access_match(p_match_id) THEN + RAISE EXCEPTION 'match_access_denied' USING ERRCODE = '42501'; + END IF; + + SELECT COALESCE(jsonb_agg(row_to_json(p)::jsonb ORDER BY p.team, p.display_name), '[]'::jsonb) + INTO v_players + FROM ( + SELECT + mp.user_id, + pr.display_name, + pr.photo_url, + mp.team, + COALESCE(ps.elo_rating, 1200) AS elo_rating, + COALESCE(ps.matches_played, 0) AS matches_played, + COALESCE(ps.wins, 0) AS wins, + COALESCE(ps.losses, 0) AS losses, + CASE WHEN COALESCE(ps.matches_played, 0) > 0 THEN + ROUND((ps.wins::NUMERIC / ps.matches_played::NUMERIC) * 100, 1) + ELSE 0 END AS win_rate, + COALESCE(ps.current_streak, 0) AS current_streak, + COALESCE(ps.last_form, '[]'::jsonb) AS last_form + FROM public.match_participants mp + JOIN public.profiles pr ON pr.id = mp.user_id + LEFT JOIN public.player_stats ps ON ps.user_id = mp.user_id + WHERE mp.match_id = p_match_id AND mp.state = 'confirmed' + ) p; + + SELECT COALESCE(array_agg(mp.user_id ORDER BY mp.joined_at), ARRAY[]::UUID[]) + INTO v_team_a + FROM public.match_participants mp + WHERE mp.match_id = p_match_id AND mp.state = 'confirmed' AND mp.team = 'A'; + + SELECT COALESCE(array_agg(mp.user_id ORDER BY mp.joined_at), ARRAY[]::UUID[]) + INTO v_team_b + FROM public.match_participants mp + WHERE mp.match_id = p_match_id AND mp.state = 'confirmed' AND mp.team = 'B'; + + FOREACH ua IN ARRAY COALESCE(v_team_a, ARRAY[]::UUID[]) + LOOP + FOREACH ub IN ARRAY COALESCE(v_team_b, ARRAY[]::UUID[]) + LOOP + SELECT + COUNT(*) FILTER ( + WHERE public._player_won_match(me.team, mr.team_a_games, mr.team_b_games) IS TRUE + )::INT, + COUNT(*) FILTER ( + WHERE public._player_won_match(me.team, mr.team_a_games, mr.team_b_games) IS FALSE + )::INT, + MAX(m.start_at) + INTO v_wins_a, v_wins_b, v_last + FROM public.match_participants me + JOIN public.match_participants opp + ON opp.match_id = me.match_id + AND opp.user_id = ub + 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 = ua + AND me.state = 'confirmed' + AND m.id <> p_match_id + 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; + + v_h2h := v_h2h || jsonb_build_array( + jsonb_build_object( + 'user_a', ua, + 'user_b', ub, + 'wins_a', COALESCE(v_wins_a, 0), + 'wins_b', COALESCE(v_wins_b, 0), + 'last_meeting', v_last + ) + ); + END LOOP; + END LOOP; + + -- Pair H2H: same 4 registered players, opposite teams (order within team ignored) + IF cardinality(v_team_a) = 2 AND cardinality(v_team_b) = 2 THEN + SELECT + COUNT(*) FILTER ( + WHERE ( + ( + hist.pair_a = (SELECT array_agg(u ORDER BY u) FROM unnest(v_team_a) u) + AND hist.team_a_won + ) + OR ( + hist.pair_b = (SELECT array_agg(u ORDER BY u) FROM unnest(v_team_a) u) + AND NOT hist.team_a_won + ) + ) + )::INT, + COUNT(*) FILTER ( + WHERE ( + ( + hist.pair_a = (SELECT array_agg(u ORDER BY u) FROM unnest(v_team_a) u) + AND NOT hist.team_a_won + ) + OR ( + hist.pair_b = (SELECT array_agg(u ORDER BY u) FROM unnest(v_team_a) u) + AND hist.team_a_won + ) + ) + )::INT, + MAX(hist.start_at) + INTO v_pair_wins_a, v_pair_wins_b, v_pair_last + FROM ( + SELECT + m.start_at, + ( + SELECT array_agg(x.user_id ORDER BY x.user_id) + FROM public.match_participants x + WHERE x.match_id = m.id AND x.state = 'confirmed' AND x.team = 'A' + ) AS pair_a, + ( + SELECT array_agg(x.user_id ORDER BY x.user_id) + FROM public.match_participants x + WHERE x.match_id = m.id AND x.state = 'confirmed' AND x.team = 'B' + ) AS pair_b, + (mr.team_a_games > mr.team_b_games) AS team_a_won + FROM public.matches m + JOIN public.match_results mr ON mr.match_id = m.id AND mr.status = 'confirmed' + WHERE m.id <> p_match_id + AND m.status = 'finished' + AND COALESCE(m.tournament_is_bye, FALSE) = FALSE + AND mr.team_a_games <> mr.team_b_games + ) hist + WHERE ( + ( + hist.pair_a = (SELECT array_agg(u ORDER BY u) FROM unnest(v_team_a) u) + AND hist.pair_b = (SELECT array_agg(u ORDER BY u) FROM unnest(v_team_b) u) + ) + OR ( + hist.pair_a = (SELECT array_agg(u ORDER BY u) FROM unnest(v_team_b) u) + AND hist.pair_b = (SELECT array_agg(u ORDER BY u) FROM unnest(v_team_a) u) + ) + ); + + IF COALESCE(v_pair_wins_a, 0) + COALESCE(v_pair_wins_b, 0) > 0 THEN + v_pair := jsonb_build_object( + 'wins_a', COALESCE(v_pair_wins_a, 0), + 'wins_b', COALESCE(v_pair_wins_b, 0), + 'last_meeting', v_pair_last + ); + END IF; + END IF; + + RETURN jsonb_build_object( + 'match_id', p_match_id, + 'players', COALESCE(v_players, '[]'::jsonb), + 'individual_h2h', COALESCE(v_h2h, '[]'::jsonb), + 'pair_h2h', v_pair + ); +END; +$$; + +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; + +-- ─── get_leaderboard ───────────────────────────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.get_leaderboard( + p_city TEXT DEFAULT NULL, + p_limit INT DEFAULT 50 +) +RETURNS JSONB +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_lim INT := GREATEST(1, LEAST(COALESCE(p_limit, 50), 100)); + v_result JSONB; +BEGIN + SELECT COALESCE(jsonb_agg(row_to_json(r)::jsonb), '[]'::jsonb) + INTO v_result + FROM ( + SELECT + ps.user_id, + pr.display_name, + pr.photo_url, + pr.city, + ps.elo_rating, + ps.matches_played, + ps.wins, + ps.losses, + CASE WHEN ps.matches_played > 0 THEN + ROUND((ps.wins::NUMERIC / ps.matches_played::NUMERIC) * 100, 1) + ELSE 0 END AS win_rate + 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 pr.city ILIKE p_city) + ORDER BY ps.elo_rating DESC, ps.wins DESC, ps.matches_played DESC + LIMIT v_lim + ) r; + + RETURN COALESCE(v_result, '[]'::jsonb); +END; +$$; + +REVOKE ALL ON FUNCTION public.get_leaderboard(TEXT, INT) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.get_leaderboard(TEXT, INT) TO authenticated; + +-- Seed rows + backfill existing confirmed results (best-effort on migrate). +DO $$ +BEGIN + PERFORM public.backfill_player_stats(); +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE 'player_stats backfill skipped: %', SQLERRM; +END; +$$; diff --git a/supabase/migrations/20260810130000_089_tournament_podium_lists.sql b/supabase/migrations/20260810130000_089_tournament_podium_lists.sql new file mode 100644 index 0000000..25117f0 --- /dev/null +++ b/supabase/migrations/20260810130000_089_tournament_podium_lists.sql @@ -0,0 +1,276 @@ +-- Migration 089: Tournament podium lists (gold / silver / bronze) in get_player_stats + +CREATE OR REPLACE FUNCTION public.get_player_stats(p_user_id UUID) +RETURNS JSONB +LANGUAGE plpgsql +STABLE +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_gold JSONB; + v_silver JSONB; + v_bronze JSONB; + v_t_part INT; + v_win_rate NUMERIC; +BEGIN + IF p_user_id IS NULL THEN + RETURN NULL; + END IF; + + PERFORM public.ensure_player_stats_row(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; + + 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; + + -- Tournament podium: one row per tournament per placement tier + WITH tm AS ( + SELECT + m.tournament_id, + t.title, + t.start_at, + m.tournament_round_size, + COALESCE(m.tournament_is_third_place, FALSE) AS is_third, + m.tournament_winner_pair_id, + CASE mp.team + WHEN 'A' THEN m.tournament_pair_a_id + ELSE m.tournament_pair_b_id + END AS user_pair_id, + public._player_won_match(mp.team, mr.team_a_games, mr.team_b_games) AS won + 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' + JOIN public.tournaments t ON t.id = m.tournament_id + WHERE mp.user_id = p_user_id + AND mp.state = 'confirmed' + AND m.status = 'finished' + AND m.tournament_id IS NOT NULL + AND COALESCE(m.tournament_is_bye, FALSE) = FALSE + ), + deduped AS ( + SELECT DISTINCT ON (tournament_id, placement) + tournament_id, + title, + start_at, + placement + FROM ( + SELECT + tournament_id, + title, + start_at, + 'gold'::TEXT AS placement + FROM tm + WHERE tournament_round_size = 2 + AND NOT is_third + AND tournament_winner_pair_id IS NOT NULL + AND user_pair_id IS NOT NULL + AND user_pair_id = tournament_winner_pair_id + UNION ALL + SELECT + tournament_id, + title, + start_at, + 'silver'::TEXT AS placement + FROM tm + WHERE tournament_round_size = 2 + AND NOT is_third + AND tournament_winner_pair_id IS NOT NULL + AND user_pair_id IS NOT NULL + AND user_pair_id <> tournament_winner_pair_id + UNION ALL + SELECT + tournament_id, + title, + start_at, + 'bronze'::TEXT AS placement + FROM tm + WHERE is_third + AND won IS TRUE + AND tournament_winner_pair_id IS NOT NULL + AND user_pair_id IS NOT NULL + AND user_pair_id = tournament_winner_pair_id + ) ranked + ORDER BY tournament_id, placement, start_at DESC + ) + SELECT + COALESCE( + (SELECT jsonb_agg(row_to_json(g)::jsonb ORDER BY g.start_at DESC) + FROM ( + SELECT tournament_id, title, start_at + FROM deduped WHERE placement = 'gold' + ) g), + '[]'::jsonb + ), + COALESCE( + (SELECT jsonb_agg(row_to_json(s)::jsonb ORDER BY s.start_at DESC) + FROM ( + SELECT tournament_id, title, start_at + FROM deduped WHERE placement = 'silver' + ) s), + '[]'::jsonb + ), + COALESCE( + (SELECT jsonb_agg(row_to_json(b)::jsonb ORDER BY b.start_at DESC) + FROM ( + SELECT tournament_id, title, start_at + FROM deduped WHERE placement = 'bronze' + ) b), + '[]'::jsonb + ) + INTO v_gold, v_silver, v_bronze; + + 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), + 'tournament_podium', jsonb_build_object( + 'gold', COALESCE(v_gold, '[]'::jsonb), + 'silver', COALESCE(v_silver, '[]'::jsonb), + 'bronze', COALESCE(v_bronze, '[]'::jsonb) + ), + '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; +$$; + +REVOKE ALL ON FUNCTION public.get_player_stats(UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.get_player_stats(UUID) TO authenticated; diff --git a/supabase/migrations/20260810140000_090_unified_podium_leagues.sql b/supabase/migrations/20260810140000_090_unified_podium_leagues.sql new file mode 100644 index 0000000..7128813 --- /dev/null +++ b/supabase/migrations/20260810140000_090_unified_podium_leagues.sql @@ -0,0 +1,308 @@ +-- Migration 090: Unified podium (tournaments + leagues) in get_player_stats + +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_gold JSONB; + v_silver JSONB; + v_bronze JSONB; + v_t_part INT; + v_leagues_part INT; + v_win_rate NUMERIC; +BEGIN + IF p_user_id IS NULL THEN + RETURN NULL; + END IF; + + PERFORM public.ensure_player_stats_row(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; + + 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; + + -- Unified podium: tournaments + finished leagues (standings rank 1/2/3) + WITH tm AS ( + SELECT + m.tournament_id, + t.title, + t.start_at, + m.tournament_round_size, + COALESCE(m.tournament_is_third_place, FALSE) AS is_third, + m.tournament_winner_pair_id, + CASE mp.team + WHEN 'A' THEN m.tournament_pair_a_id + ELSE m.tournament_pair_b_id + END AS user_pair_id, + public._player_won_match(mp.team, mr.team_a_games, mr.team_b_games) AS won + 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' + JOIN public.tournaments t ON t.id = m.tournament_id + WHERE mp.user_id = p_user_id + AND mp.state = 'confirmed' + AND m.status = 'finished' + AND m.tournament_id IS NOT NULL + AND COALESCE(m.tournament_is_bye, FALSE) = FALSE + ), + tournament_deduped AS ( + SELECT DISTINCT ON (tournament_id, placement) + tournament_id AS id, + title, + start_at, + placement, + 'tournament'::TEXT AS source + FROM ( + SELECT + tournament_id, + title, + start_at, + 'gold'::TEXT AS placement + FROM tm + WHERE tournament_round_size = 2 + AND NOT is_third + AND tournament_winner_pair_id IS NOT NULL + AND user_pair_id IS NOT NULL + AND user_pair_id = tournament_winner_pair_id + UNION ALL + SELECT + tournament_id, + title, + start_at, + 'silver'::TEXT AS placement + FROM tm + WHERE tournament_round_size = 2 + AND NOT is_third + AND tournament_winner_pair_id IS NOT NULL + AND user_pair_id IS NOT NULL + AND user_pair_id <> tournament_winner_pair_id + UNION ALL + SELECT + tournament_id, + title, + start_at, + 'bronze'::TEXT AS placement + FROM tm + WHERE is_third + AND won IS TRUE + AND tournament_winner_pair_id IS NOT NULL + AND user_pair_id IS NOT NULL + AND user_pair_id = tournament_winner_pair_id + ) ranked + ORDER BY tournament_id, placement, start_at DESC + ), + league_placements AS ( + SELECT + l.id, + l.title, + l.start_at, + CASE s.rank + WHEN 1 THEN 'gold' + WHEN 2 THEN 'silver' + WHEN 3 THEN 'bronze' + END AS placement, + 'league'::TEXT AS source + FROM public.leagues l + JOIN public.league_pairs lp ON lp.league_id = l.id + JOIN public.list_league_standings(l.id) s ON s.pair_id = lp.id + WHERE l.status = 'finished' + AND (lp.player_a_user_id = p_user_id OR lp.player_b_user_id = p_user_id) + AND s.rank BETWEEN 1 AND 3 + ), + unified AS ( + SELECT id, title, start_at, placement, source FROM tournament_deduped + UNION ALL + SELECT id, title, start_at, placement, source FROM league_placements + ) + SELECT + COALESCE( + (SELECT jsonb_agg(row_to_json(g)::jsonb ORDER BY g.start_at DESC) + FROM ( + SELECT id, title, start_at, source + FROM unified WHERE placement = 'gold' + ) g), + '[]'::jsonb + ), + COALESCE( + (SELECT jsonb_agg(row_to_json(s)::jsonb ORDER BY s.start_at DESC) + FROM ( + SELECT id, title, start_at, source + FROM unified WHERE placement = 'silver' + ) s), + '[]'::jsonb + ), + COALESCE( + (SELECT jsonb_agg(row_to_json(b)::jsonb ORDER BY b.start_at DESC) + FROM ( + SELECT id, title, start_at, source + FROM unified WHERE placement = 'bronze' + ) b), + '[]'::jsonb + ) + INTO v_gold, v_silver, v_bronze; + + 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; + + SELECT COUNT(DISTINCT lp.league_id)::INT + INTO v_leagues_part + FROM public.league_pairs lp + WHERE lp.player_a_user_id = p_user_id + OR lp.player_b_user_id = p_user_id; + + 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), + 'leagues_participated', COALESCE(v_leagues_part, 0), + 'podium', jsonb_build_object( + 'gold', COALESCE(v_gold, '[]'::jsonb), + 'silver', COALESCE(v_silver, '[]'::jsonb), + 'bronze', COALESCE(v_bronze, '[]'::jsonb) + ), + '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; +$$; + +REVOKE ALL ON FUNCTION public.get_player_stats(UUID) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION public.get_player_stats(UUID) TO authenticated; diff --git a/supabase/migrations/20260810150000_091_player_stats_rpc_reload.sql b/supabase/migrations/20260810150000_091_player_stats_rpc_reload.sql new file mode 100644 index 0000000..29c734a --- /dev/null +++ b/supabase/migrations/20260810150000_091_player_stats_rpc_reload.sql @@ -0,0 +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 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 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 anon, authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260810160000_092_get_player_stats_volatile.sql b/supabase/migrations/20260810160000_092_get_player_stats_volatile.sql new file mode 100644 index 0000000..2f84a1d --- /dev/null +++ b/supabase/migrations/20260810160000_092_get_player_stats_volatile.sql @@ -0,0 +1,5 @@ +-- Fix: get_player_stats calls ensure_player_stats_row (INSERT) so it cannot be STABLE. + +ALTER FUNCTION public.get_player_stats(uuid) VOLATILE; + +NOTIFY pgrst, 'reload schema'; From 31c0405c3d00cf9a14632e72a7b13b87335bb677 Mon Sep 17 00:00:00 2001 From: Japegomez Date: Mon, 10 Aug 2026 16:48:49 +0200 Subject: [PATCH 08/24] fix(ci): restrict GITHUB_TOKEN permissions in workflows Satisfy CodeQL actions/missing-workflow-permissions by declaring least-privilege permissions on CI, Quality, Release, and Sentry health workflows. Co-authored-by: Cursor --- .github/workflows/ci.yml | 4 ++++ .github/workflows/eas.yml | 4 ++++ .github/workflows/quality.yml | 3 +++ .github/workflows/sentry-health.yml | 3 +++ 4 files changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c40c57d..5774ac2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [develop, main] +# Caller workflows that only invoke reusable jobs need an empty permissions block. +# The called workflow (quality.yml) declares its own token scope. +permissions: {} + jobs: quality: uses: ./.github/workflows/quality.yml diff --git a/.github/workflows/eas.yml b/.github/workflows/eas.yml index ca2ff79..8ba4712 100644 --- a/.github/workflows/eas.yml +++ b/.github/workflows/eas.yml @@ -5,8 +5,12 @@ on: push: branches: [main] +permissions: + contents: read + jobs: quality: + permissions: {} uses: ./.github/workflows/quality.yml release-tag: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 284a196..be3d6d5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -3,6 +3,9 @@ name: Quality on: workflow_call: +permissions: + contents: read + jobs: quality: name: Quality diff --git a/.github/workflows/sentry-health.yml b/.github/workflows/sentry-health.yml index 395356f..e1fe1f1 100644 --- a/.github/workflows/sentry-health.yml +++ b/.github/workflows/sentry-health.yml @@ -9,6 +9,9 @@ on: - cron: '*/5 * * * *' workflow_dispatch: +permissions: + contents: read + jobs: edge-function-health: runs-on: ubuntu-latest From ba75453f4e49dcdbb2370506898d6736e677101b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Pe=C3=B1a=20G=C3=B3mez?= <60773775+Japegomez@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:14:05 +0200 Subject: [PATCH 09/24] feat(leagues): ligas, stats de perfil y acceso admin a privados (#143) * feat(leagues): add league format with round-robin, Elo, and UX polish Introduce leagues end-to-end (schema, services, screens, explore integration) with pair management, standings, and match grouping by jornadas. Includes profile visibility fixes, auto-finish when all fixtures complete, result screen name resolution, icon-based pair actions, and navigation from matches to their league. Co-authored-by: Cursor * fix(nav): return to match from league and to profile from history Preserve origin when closing match/league/tournament screens with X so league detail returns to the match and profile history returns to the profile. Co-authored-by: Cursor * feat(profile): polish stats UX, badge showcase, ranking and admin access Improve profile statistics with visual podium, featured badges, unlock popup, city/global ranking, safer account deletion, history-based navigation, and admin bypass for private password-protected events. Co-authored-by: Cursor * feat(stats): refresh ELO on profile load and fix match timers Keep league fixtures planned until played, extend tournament no-result timeout to 24h, and recalculate player stats when opening the profile. Also polish add-pair UX, ELO help copy, and empty podium messaging. Co-authored-by: Cursor --------- Co-authored-by: Cursor --- locales/es.json | 2 +- package-lock.json | 7 + package.json | 1 + src/app/(tabs)/_layout.tsx | 4 + src/app/(tabs)/explore/index.tsx | 131 +- src/app/(tabs)/leaderboard/index.tsx | 12 +- src/app/(tabs)/leagues/[id].tsx | 680 +++++++ src/app/(tabs)/leagues/create.tsx | 580 ++++++ src/app/(tabs)/leagues/edit/[id].tsx | 334 ++++ src/app/(tabs)/matches/[id].tsx | 53 +- src/app/(tabs)/matches/create.tsx | 10 +- src/app/(tabs)/matches/edit/[id].tsx | 14 +- src/app/(tabs)/matches/index.tsx | 44 +- src/app/(tabs)/matches/scoreboard/[id].tsx | 8 +- src/app/(tabs)/profile/[userId].tsx | 99 +- src/app/(tabs)/profile/edit.tsx | 16 +- src/app/(tabs)/profile/index.tsx | 86 +- src/app/(tabs)/profile/stats/[userId].tsx | 35 +- src/app/(tabs)/tournaments/[id].tsx | 23 +- src/app/(tabs)/tournaments/create.tsx | 18 +- src/app/(tabs)/tournaments/edit/[id].tsx | 13 +- src/app/l/[id].tsx | 12 + src/app/leagues/[id].tsx | 12 + src/components/DeleteAccountModal.tsx | 50 +- src/components/ShareInviteButton.tsx | 14 +- src/components/leagues/AddLeaguePairModal.tsx | 230 +++ src/components/leagues/CancelLeagueModal.tsx | 91 + src/components/leagues/ChallengeList.tsx | 81 + src/components/leagues/ChallengeModal.tsx | 126 ++ .../leagues/EditLeaguePairModal.tsx | 240 +++ src/components/leagues/EloRanking.tsx | 74 + src/components/leagues/LeaguePairCard.tsx | 102 + src/components/leagues/StandingsTable.tsx | 112 ++ src/components/legal/LegalScreenLayout.tsx | 13 +- src/components/stats/BadgeList.tsx | 187 +- src/components/stats/BadgeShowcase.tsx | 312 +++ src/components/stats/BadgeShowcaseSection.tsx | 61 + src/components/stats/BadgeUnlockPopup.tsx | 128 ++ src/components/stats/ELOBadge.tsx | 25 +- src/components/stats/FormBadges.tsx | 96 +- src/components/stats/ProfileStatsCard.tsx | 27 +- src/components/stats/RankingSection.tsx | 120 ++ .../stats/TournamentPodiumSection.tsx | 156 +- src/components/tournaments/AddPairModal.tsx | 46 +- src/components/tournaments/PairCard.tsx | 33 +- src/components/ui/AddPairButton.tsx | 57 + src/components/ui/CreateFab.tsx | 9 +- src/components/ui/IconButton.tsx | 69 + src/constants/index.ts | 29 +- src/hooks/useBadgeUnlocks.ts | 48 + src/hooks/useLeagues.ts | 299 +++ src/hooks/useMatches.ts | 48 +- src/hooks/useResults.ts | 16 +- src/hooks/useStats.ts | 30 +- src/lib/inviteLinks.ts | 4 + src/lib/shareInvite.ts | 5 +- src/services/leagues.service.ts | 694 +++++++ src/services/matches.service.ts | 17 +- src/services/profiles.service.ts | 5 +- src/services/stats.service.ts | 83 + src/types/database.types.ts | 553 ++++++ src/utils/elo.test.ts | 25 + src/utils/elo.ts | 46 + src/utils/exploreFilters.ts | 9 +- src/utils/leagueDisplay.test.ts | 28 + src/utils/leagueDisplay.ts | 35 + src/utils/leagueFixtures.test.ts | 31 + src/utils/leagueFixtures.ts | 72 + src/utils/leagueForm.ts | 19 + src/utils/leagueStandings.test.ts | 43 + src/utils/leagueStandings.ts | 126 ++ src/utils/matchTeamNames.test.ts | 23 + src/utils/matchTeamNames.ts | 23 +- src/utils/navigation.ts | 28 + .../migrations/20260810120000_086_leagues.sql | 1754 +++++++++++++++++ ...000_087_explore_exclude_league_matches.sql | 224 +++ ...810140000_088_league_pair_member_names.sql | 271 +++ ...50000_089_league_finish_on_all_matches.sql | 71 + ...810160000_090_match_team_display_names.sql | 191 ++ .../20260810180000_094_league_badges.sql | 281 +++ ...20260810181000_095_league_badges_apply.sql | 5 + .../20260810190000_096_hard_badges.sql | 304 +++ .../20260810200000_097_badge_updates.sql | 215 ++ .../20260810210000_098_badge_showcase.sql | 71 + ...260810220000_099_badge_showcase_grants.sql | 17 + .../20260810230000_100_player_ranking.sql | 135 ++ ...0260810240000_101_admin_private_access.sql | 103 + ...000_102_match_timers_tournament_league.sql | 383 ++++ ...20000_103_refresh_player_stats_on_read.sql | 156 ++ 89 files changed, 10885 insertions(+), 288 deletions(-) create mode 100644 src/app/(tabs)/leagues/[id].tsx create mode 100644 src/app/(tabs)/leagues/create.tsx create mode 100644 src/app/(tabs)/leagues/edit/[id].tsx create mode 100644 src/app/l/[id].tsx create mode 100644 src/app/leagues/[id].tsx create mode 100644 src/components/leagues/AddLeaguePairModal.tsx create mode 100644 src/components/leagues/CancelLeagueModal.tsx create mode 100644 src/components/leagues/ChallengeList.tsx create mode 100644 src/components/leagues/ChallengeModal.tsx create mode 100644 src/components/leagues/EditLeaguePairModal.tsx create mode 100644 src/components/leagues/EloRanking.tsx create mode 100644 src/components/leagues/LeaguePairCard.tsx create mode 100644 src/components/leagues/StandingsTable.tsx create mode 100644 src/components/stats/BadgeShowcase.tsx create mode 100644 src/components/stats/BadgeShowcaseSection.tsx create mode 100644 src/components/stats/BadgeUnlockPopup.tsx create mode 100644 src/components/stats/RankingSection.tsx create mode 100644 src/components/ui/AddPairButton.tsx create mode 100644 src/components/ui/IconButton.tsx create mode 100644 src/hooks/useBadgeUnlocks.ts create mode 100644 src/hooks/useLeagues.ts create mode 100644 src/services/leagues.service.ts create mode 100644 src/utils/elo.test.ts create mode 100644 src/utils/elo.ts create mode 100644 src/utils/leagueDisplay.test.ts create mode 100644 src/utils/leagueDisplay.ts create mode 100644 src/utils/leagueFixtures.test.ts create mode 100644 src/utils/leagueFixtures.ts create mode 100644 src/utils/leagueForm.ts create mode 100644 src/utils/leagueStandings.test.ts create mode 100644 src/utils/leagueStandings.ts create mode 100644 src/utils/navigation.ts create mode 100644 supabase/migrations/20260810120000_086_leagues.sql create mode 100644 supabase/migrations/20260810130000_087_explore_exclude_league_matches.sql create mode 100644 supabase/migrations/20260810140000_088_league_pair_member_names.sql create mode 100644 supabase/migrations/20260810150000_089_league_finish_on_all_matches.sql create mode 100644 supabase/migrations/20260810160000_090_match_team_display_names.sql create mode 100644 supabase/migrations/20260810180000_094_league_badges.sql create mode 100644 supabase/migrations/20260810181000_095_league_badges_apply.sql create mode 100644 supabase/migrations/20260810190000_096_hard_badges.sql create mode 100644 supabase/migrations/20260810200000_097_badge_updates.sql create mode 100644 supabase/migrations/20260810210000_098_badge_showcase.sql create mode 100644 supabase/migrations/20260810220000_099_badge_showcase_grants.sql create mode 100644 supabase/migrations/20260810230000_100_player_ranking.sql create mode 100644 supabase/migrations/20260810240000_101_admin_private_access.sql create mode 100644 supabase/migrations/20260811000000_102_match_timers_tournament_league.sql create mode 100644 supabase/migrations/20260811120000_103_refresh_player_stats_on_read.sql 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..b92eeac 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 } from '@/utils/leagueDisplay' import { Colors } from '@/theme/colors' import { Fonts } from '@/theme/typography' import { screenTopPadding } from '@/theme/layout' @@ -97,6 +108,25 @@ function tournamentStatusTone(tournament: TournamentRow): StatusDotTone { return 'upcoming' } +function leagueStatusLabel(league: LeagueRow) { + switch (league.status) { + case LEAGUE_STATUS.REGISTRATION: + return 'Inscripción abierta' + case LEAGUE_STATUS.IN_PROGRESS: + return 'En curso' + case LEAGUE_STATUS.FINISHED: + return 'Finalizada' + default: + return league.status + } +} + +function leagueStatusTone(league: LeagueRow): StatusDotTone { + if (league.status === LEAGUE_STATUS.IN_PROGRESS) return 'active' + if (league.status === LEAGUE_STATUS.REGISTRATION) return 'upcoming' + return 'upcoming' +} + function ExploreMatchRow({ row, onPress }: { row: PublicMatchExplorerRow; onPress: () => void }) { const tone = matchStatusTone(row.status) return ( @@ -158,6 +188,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)} + + + + + {leagueStatusLabel(row)} + + {formatDisplay(row.start_at)} + + {row.status === LEAGUE_STATUS.REGISTRATION ? 'Sin iniciar' : 'Ver clasificación'} + + + + ) +} + export default function ExploreScreen() { const router = useRouter() const insets = useSafeAreaInsets() @@ -217,6 +278,28 @@ export default function ExploreScreen() { 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, + isRefetching: leaguesRefetching, + refetch: refetchLeagues, + } = usePublicLeaguesExplore(leagueFilters) + const matchRows = useMemo(() => data?.pages.flatMap((p) => p.rows) ?? [], [data?.pages]) const exploreItems = useMemo((): ExploreItem[] => { @@ -232,16 +315,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 +413,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,12 +462,13 @@ 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… ) } @@ -409,11 +504,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 +521,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 +533,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.