--- asset_id: MIN-EL-Gustavo-20260630-v01 tipo: MIN — Minuta del día owner: Gustavo sherpa: GusX ratificador: Gustavo fecha: 2026-06-30 intellibank: IB-EL-EmpowerLabs subbank: EQ-EL-Equipo proyecto: RCH — Rebelocity Club · perfil-usuario --- ## Qué se hizo ### Revisión de pendientes del 2026-06-29 - Leída minuta `MIN-EL-Gustavo-20260629-v01` y backlog `BCK-EL-Gustavo-RCH-v01`. - Identificados 3 pendientes abiertos: condición de bloqueo del toggle "Quiero registrar mi club", panel de miembros backend+Angular, estado `loading` en `app-mi-club`. --- ### Pendiente #1 — Toggle "Quiero registrar mi club": lógica de 3 estados **Análisis del problema:** - El toggle tenía un rol ambiguo: controlaba tanto la *intención de crear* un club (antes de que existiera) como el *acceso* al club ya creado. - Resultado: si el usuario ya tenía un club y apagaba el toggle, el tab desaparecía sin poder volver a él fácilmente. **Decisión:** reemplazar el toggle por lógica de 3 estados determinados por el backend, no por control local. **Implementación — `perfil-usuario.component.ts`:** - Propiedad `hasClub = false` añadida junto a `myClub`. - Nuevo método `loadMyClub()`: llama `club_serv.getMyClub(captainId)` al iniciar; si responde con data → `hasClub = true` y activa `visibility` del tab `mi-club` en `opsList` de forma permanente. - Constructor: `loadMyClub()` llamado junto a `loadMyMembership()`, ambos bajo el mismo guard `!isProfilePublic && app === 'Rebelocity Club'`. **Implementación — `perfil-usuario.component.html`:** - Bloque del toggle reemplazado por 3 `ng-container`: - **Estado A** (`hasClub`): row estático "Ya tienes un club registrado → Ir a Mi club". Tab siempre visible, sin toggle. - **Estado B** (`!hasClub && myClub`): toggle visualmente bloqueado (`opacity: 0.45; pointer-events: none`) con hint "Para registrar tu propio club primero debes salir del club al que perteneces". - **Estado C** (`!hasClub && !myClub`): toggle libre original + hint condicional "Edita la información de tu club en la pestaña Mi club →" cuando `wantsClubRegister = true`. **Implementación — `perfil-usuario.component.less`:** - Nueva clase `.prof-mi-club-info` para el Estado A: acento azul primario, consistente con el sistema visual existente (`prof-club-block`). - `.prof-club-register-hint` ya existía — reutilizado sin cambios. --- ### Pendiente #2 — Panel de miembros: backend + conexión Angular **Contexto técnico analizado:** - Tablas: `club_members` (genniux_rch) y `clubs` (genniux_rch). Datos de usuario en `genniux_board.perfil` (email, displayName) y `genniux_board.puesto` (id = user_id en club_members). - Conexiones: `$pcn` → genniux_rch · `$pcn2` → genniux_board. Cross-DB JOINs válidos desde `$pcn` usando `genniux_board.tablename` (mismo servidor, root). - Regla de negocio confirmada: **un atleta = un club activo a la vez**. Se valida en backend al agregar. **Backend — `general.php` (3 funciones nuevas):** - `getClubMembers($clubId)` — SELECT con JOIN cross-DB `genniux_board.puesto` + `genniux_board.perfil`. Filtra `status = 'active'`. Devuelve `member_id`, `user_id`, `nombre`, `image`, `miembro_desde`. - `addClubMember($clubId, $searchTerm)`: 1. Busca usuario en `$pcn2` por `email` exacto o `displayName LIKE %term%`. 2. Valida regla un-club: si ya tiene `status = 'active'` en cualquier club → rechaza con mensaje específico (mismo club vs. otro). 3. INSERT `club_members` con `status = 'active'` + `resolved_at = NOW()` + UPDATE `clubs.active_members + 1`. Transacción. - `removeClubMember($memberId, $clubId)` — UPDATE `status = 'left'` validando `club_id` + `status = 'active'`. UPDATE `active_members = GREATEST(active_members - 1, 0)`. Transacción. **Backend — `index.php` (3 rutas nuevas):** - `POST /rch/clubs/members` → `getClubMembers($clubId)` - `POST /rch/clubs/members/add` → `addClubMember($clubId, $searchTerm)` - `POST /rch/clubs/members/remove` → `removeClubMember($memberId, $clubId)` **Frontend — `club.service.ts` (3 métodos nuevos):** - `getClubMembers(clubId)` → `POST /rch/clubs/members` - `addClubMember(clubId, searchTerm)` → `POST /rch/clubs/members/add` - `removeClubMember(memberId, clubId)` → `POST /rch/clubs/members/remove` **Frontend — `mi-club.component.ts`:** - Import `alertOptions` añadido. - Interface `ClubMember` extendida con `member_id: number`. - `loadMembers()` — llamado desde `ngOnInit` una vez que `clubId` está disponible. Mapea respuesta del backend a `ClubMember[]` con `getInitials()`. - `onAddMember()` — valida query no vacío, llama `addClubMember`, refresca lista con `loadMembers()` al éxito. Muestra mensaje de error del backend si falla. - `onRemoveMember(member)` — swal de confirmación con nombre del atleta, llama `removeClubMember`, refresca lista al éxito. - Helper privado `getInitials(nombre)`. **Frontend — `mi-club.component.html`:** - Botón "Agregar" conectado a `onAddMember()`. - Botón "Eliminar" conectado a `onRemoveMember(m)`. --- ### Debugging — `getClubMembers` no mostraba miembros **Síntoma:** el panel de miembros quedaba vacío pese a que el flujo Angular (service → component → template) estaba correctamente conectado. **Causa raíz:** la dirección del cross-DB JOIN estaba invertida. La conexión nativa de cada tabla determina qué nombres llevan prefijo de base de datos: - `$pcn` → conectado a `genniux_rch`: `club_members` y `clubs` **sin prefijo**. - `genniux_board.puesto` y `genniux_board.perfil` **con prefijo**, por ser cross-DB desde `$pcn`. La versión inicial usaba `$pcn2` (genniux_board) como base y prefijaba `genniux_rch.*`, lo cual resultaba en errores de `prepare()` silenciosos (PDO en modo `ERRMODE_SILENT`) que llegaban a la función como un `Fatal error` al llamar `->execute()` sobre `false`. **Método de diagnóstico:** aislar la variable probando primero una consulta sin JOIN (`SELECT * FROM genniux_rch.club_members WHERE club_id = :club_id` vía `$pcn2`) para confirmar que la conectividad cross-DB en sí funcionaba, y luego reintroducir JOINs uno a la vez (`puesto` → `perfil` → `clubs`) hasta aislar el punto de falla. **Query final correcta** (`general.php`, `getClubMembers`): ```sql SELECT cm.id AS member_id, cm.resolved_at AS miembro_desde, p.displayName AS nombre FROM club_members cm JOIN clubs c ON c.id = cm.club_id JOIN genniux_board.puesto pu ON pu.id = cm.user_id JOIN genniux_board.perfil p ON p.wikiname = pu.colaborador WHERE cm.club_id = :club_id AND cm.status = 'active' ORDER BY cm.resolved_at ASC ``` Conexión: `$pcn` (genniux_rch). Se añadió `try/catch` para que cualquier error de `prepare()`/`execute()` se devuelva como JSON en vez de un fatal error silencioso. **Fix adicional — formato de fecha:** el front mostraba la fecha completa (`2026-05-15 14:20:00`). Se agregó `formatMesAnio()` en `mi-club.component.ts` (usa `Intl`/`toLocaleDateString('es-ES', { month: 'long' })`) para mostrar "Miembro desde mayo 2026". --- ## Decisiones - La visibilidad del tab "Mi club" se determina exclusivamente por el backend (`getMyClub`), no por el estado local del toggle. Una vez que `hasClub = true`, el tab es permanente durante esa sesión. - El Estado B (toggle bloqueado) implementa la regla de negocio del mockup: un usuario miembro de otro club no puede registrar el suyo sin salir primero. - No se contempla eliminar un club propio — el Estado A es siempre de solo acceso/edición. - Regla un-atleta-un-club aplicada en backend (`addClubMember`); el front muestra el mensaje de error devuelto por el servidor. - Cross-DB JOIN desde `$pcn` (genniux_rch) hacia `genniux_board.puesto` y `genniux_board.perfil` — válido porque ambas DBs están en el mismo servidor con credenciales root. - **Regla de prefijo cross-DB:** la base de datos nativa de la conexión PDO (`$pcn` → genniux_rch, `$pcn2` → genniux_board) nunca lleva prefijo; solo la base "remota" en el JOIN lo lleva. Invertir esto rompe la query. - PDO en `configrch.php` usa `ERRMODE_SILENT` por defecto → `prepare()` puede devolver `false` sin excepción. Envolver queries cross-DB en `try/catch` + chequeo explícito de `$query === false` para evitar fatal errors silenciosos. --- ### Pendiente #3 — Estado `loading` en `app-mi-club` **Solución:** spinner CSS usando la propiedad `loading: boolean` que ya existía en el componente (iniciaba en `true`, pasaba a `false` en el callback de `getMyClub()`). Solo faltaba usarla en el template. **Implementación — `mi-club.component.html`:** - Bloque `
` con spinner visible mientras resuelve. - `
` oculta el contenido hasta que `loading = false`. **Implementación — `mi-club.component.less`:** - Clase `.mc-loading`: flexbox centrado, padding 48px. - `.mc-loading__spinner`: círculo 36px, borde `#4B6070` (color existente del sistema), animación `mc-spin` (0.7s linear infinite). --- --- ### Directorio de miembros — alineación de filtros Atletas con mockup **Contexto:** se tomó como referencia `MOCKUP-RCH-Directorio-v01.html` (3 tabs: Atletas, Coaches, Clubs). El componente existente `athlete-directory` solo tenía 2 tabs (Atletas, Clubes) y sus filtros diferían del mockup. **Archivos modificados:** - `athlete.service.ts` - `athlete-directory.component.ts` - `athlete-directory.component.html` - `athlete-directory.component.less` **Cambios aplicados:** 1. **Interfaz `AthleteFilters`** — eliminado `rol?: string`, agregado `busco_equipo?: boolean`. 2. **Filtros eliminados del tab Atletas:** - `rolOpts` removido completo (en el mockup el rol se separa en tabs distintos, no en un select). 3. **Opciones de filtros reemplazadas completamente (alineadas con mockup):** - `paisOpts`: México · Colombia · Argentina · Paraguay · España (quitado "Otro", reordenado). - `eventoOpts` → ahora "Carrera": IRONMAN 70.3 Encarnación (`703enc`) · Triatlón 51.50 (`5150`). - `nivelOpts`: Principiante · Aficionado · Avanzado · Élite (antes: Amateur · Competitivo). 4. **Nuevo filtro "Busco equipo"** — chip toggle basado en `.fchip` del mockup: - Método `toggleBuscoEquipo()` en el componente. - Lógica de filtrado: `if (f.busco_equipo && !a.busca_equipo) return false`. - `hasActiveFilters` y `activeFilterCount` actualizados para incluirlo. - `clearFilters()` lo resetea a `false`. 5. **Botón de orden** — cambiado de dos botones con iconos (segmented control) a un único botón de texto que alterna entre "Reciente" y "A–Z" al hacer click, igual que el `.sort-btn` del mockup. Eliminado el wrapper `.btn-orden-group`. 6. **Estilos (`.less`):** - `.btn-chip-toggle` nuevo: pill `border-radius: 100px`, basado en `.fchip` del mockup. Activo: fondo azul translúcido `rgba(74,128,214,0.12)` + borde + texto `#4A80D6`. - `.btn-orden` actualizado: botón individual con `border-radius: 8px`, basado en `.sort-btn` del mockup. Eliminado `.btn-orden-group` y los estados `is-active` (ya no aplican con botón único). --- ## Decisiones - La visibilidad del tab "Mi club" se determina exclusivamente por el backend (`getMyClub`), no por el estado local del toggle. Una vez que `hasClub = true`, el tab es permanente durante esa sesión. - El Estado B (toggle bloqueado) implementa la regla de negocio del mockup: un usuario miembro de otro club no puede registrar el suyo sin salir primero. - No se contempla eliminar un club propio — el Estado A es siempre de solo acceso/edición. - Regla un-atleta-un-club aplicada en backend (`addClubMember`); el front muestra el mensaje de error devuelto por el servidor. - Cross-DB JOIN desde `$pcn` (genniux_rch) hacia `genniux_board.puesto` y `genniux_board.perfil` — válido porque ambas DBs están en el mismo servidor con credenciales root. - **Regla de prefijo cross-DB:** la base de datos nativa de la conexión PDO (`$pcn` → genniux_rch, `$pcn2` → genniux_board) nunca lleva prefijo; solo la base "remota" en el JOIN lo lleva. Invertir esto rompe la query. - PDO en `configrch.php` usa `ERRMODE_SILENT` por defecto → `prepare()` puede devolver `false` sin excepción. Envolver queries cross-DB en `try/catch` + chequeo explícito de `$query === false` para evitar fatal errors silenciosos. - **Filtros del directorio:** el mockup es la fuente de verdad para opciones de selects y controles de filtro. El filtro `rol` queda fuera del tab Atletas porque en el mockup la separación de roles se hace por tabs (Atletas / Coaches / Clubs). - **Tarjeta Atleta — naming de campo relay:** el campo en BD es `busco_relay`, no `busca_equipo`. El parámetro de filtro de la UI conserva el nombre `busco_equipo` (concepto de usuario); el campo del objeto atleta que llega del backend usa `busco_relay` (nombre real de columna). - **Gradiente de avatar:** determinista por nombre (no aleatorio) — pool de 8 pares de colores, índice calculado con `charCode[0] + charCode[1]`. Garantiza consistencia entre sesiones sin requerir dato en BD. - **Grid del listado:** migrado de flex con ancho fijo a CSS grid `auto-fill minmax(210px, 1fr)` para que las tarjetas se adapten al ancho disponible, igual que el mockup. --- ### Tarjeta Atleta — Paso 3: backend `getListAtletas()` **Archivo:** `api_rch/controllers/rebelocity/general.php` · función `getListAtletas()`. **Cambios aplicados:** 1. **SELECT — 5 columnas nuevas:** - `ped.busco_relay`, `ped.relay_disciplina`, `ped.relay_tipo`, `ped.relay_carrera` - `TIMESTAMPDIFF(YEAR, ped.fecha_nacimiento, CURDATE()) AS edad_calc` 2. **WHERE — filtro de visibilidad:** - `AND ped.visible_directorio = 1` — solo aparecen en el directorio quienes lo tienen activado. 3. **Mapeo de respuesta PHP:** - `age`: usa `edad_calc` primero (dinámico); fallback a `p.edad` si `fecha_nacimiento` es NULL. - `busco_coach`: corregido de `busca_coach` → `busco_coach` (alineado con el binding del frontend). - `busco_relay`, `relay_disciplina`, `relay_tipo`, `relay_carrera`: mapeados como campos nuevos. --- ### Paso 4 — Backlog items Coach Ítems registrados en backlog como B01–B03 (todos completados en esta misma sesión — ver abajo). --- ### Fix estilos — tarjetas de igual altura **Problema:** tarjetas con distinto contenido tenían alturas diferentes dentro de la misma fila del grid. El avatar aparecía a distintas alturas entre tarjetas. **Causa:** la cadena `height: 100%` estaba rota — `app-athlete-card` (host element) se estiraba al alto de la fila por el grid, pero `.athlete-card` dentro no heredaba ese alto. **Solución aplicada — 3 archivos:** - `athlete-list.component.less`: `app-athlete-card { height: 100%; }` (ya tenía `width: 100%`) - `athlete-card.component.less`: `.athlete-card { height: 100%; box-sizing: border-box; }` - `athlete-card.component.html` + `.less`: badges y disciplinas agrupados en `.athlete-card__footer` con `margin-top: auto` → elementos anclados al fondo de la tarjeta, avatar siempre a la misma altura. - Footer usa `*ngIf="busco_relay || busco_coach || disciplines.length > 0 || acepta_atletas !== null || especialidad.length > 0"` para no renderizarse cuando está vacío (evita gap fantasma). --- ### Tab Coaches — integración completa **Archivos modificados:** 7 archivos (backend + service + 4 componentes Angular). #### Backend **`general.php` — nueva función `getListCoaches()`:** - Filtra `ped.rol = 'coach'` y `ped.visible_directorio = 1` en WHERE. - SELECT incluye: `especialidad`, `acepta_atletas`, `edad_calc` (`TIMESTAMPDIFF`), `disciplina`, `city`, `country`, campos de perfil básico. - Respuesta PHP mapea `especialidad` como `json_decode` array y `acepta_atletas` como `(bool)`. **`index.php` — nueva ruta:** - `GET /rch/rebelocity/directorio/coaches` → `getListCoaches()` #### Service — `athlete.service.ts` - Interface `CoachFilters`: `disciplina`, `pais`, `especialidad`, `disponible` (bool), `nombre`, `orden`. - Método `getCoachesList()` → `GET /api_rch/rch/rebelocity/directorio/coaches`. #### athlete-card — nuevos inputs y elementos **`athlete-card.component.ts`:** - `@Input() especialidad: string[] = []` - `@Input() acepta_atletas: boolean | null = null` **`athlete-card.component.html`:** en el footer — - Badge "✓ Acepta atletas" (verde) cuando `acepta_atletas === true` - Badge "✗ No disponible" (gris) cuando `acepta_atletas === false` - Sección `.athlete-card__specs` con label "Especialidad" + chips neutros cuando `especialidad.length > 0` - Disciplinas solo se muestran si `especialidad.length === 0` (atletas) **`athlete-card.component.less` — estilos nuevos:** - `&--avail-yes`: fondo/borde/texto verde (`#059669`) - `&--avail-no`: fondo `#EEF1F6`, texto `#9BA8B4` - `&__spec-chip`: fondo `rgba(0,0,0,.04)`, borde `rgba(0,0,0,.12)`, texto `#0F1923` — estilo neutro/oscuro, distinto de los chips azules de disciplina #### athlete-list — modo atleta / coach **`athlete-list.component.ts`:** - `@Input() mode: 'atleta' | 'coach' = 'atleta'` - `getDisciplines()`: devuelve `[]` en modo coach - `getEspecialidad()`: devuelve `athlete.especialidad || []` **`athlete-list.component.html`:** bindings modo-aware — - `[age]`: `null` en modo coach (no se muestra) - `[showGender]`: `false` en modo coach - `[busco_relay]` / `[busco_coach]`: `false` en modo coach - `[acepta_atletas]`: `!!athlete.acepta_atletas` en modo coach, `null` en modo atleta #### athlete-directory — tab + filtros + lista **`athlete-directory.component.ts`:** - `activeTab` tipado como `'atletas' | 'coaches' | 'clubes'` - Estado: `allCoaches`, `coaches`, `loadingCoaches`, `coachesLoaded`, `totalCoaches`, `selectedCoach` - `coachFilters` con `disponible: true` por defecto (chip "Disponibles" activo al entrar) - `especialidadOpts` (8 opciones del mockup) - Getters: `hasActiveCoachFilters`, `activeCoachFilterCount` - Métodos: `getCoaches()`, `filterCoaches()`, `onCoachSearchChange()`, `onCoachSelectChange()`, `toggleCoachDisponible()`, `clearCoachFilters()`, `onCoachCardClick()` - `setActiveTab()`: lazy load coaches la primera vez que se entra al tab **`athlete-directory.component.html`:** - Tab "Coaches" entre Atletas y Clubes - Filter bar: búsqueda + orden + Disciplina + País + Especialidad + chip "Disponibles" - Counter: "N coach/coaches encontrado/s" - `` --- ## Decisiones - La visibilidad del tab "Mi club" se determina exclusivamente por el backend (`getMyClub`), no por el estado local del toggle. Una vez que `hasClub = true`, el tab es permanente durante esa sesión. - El Estado B (toggle bloqueado) implementa la regla de negocio del mockup: un usuario miembro de otro club no puede registrar el suyo sin salir primero. - No se contempla eliminar un club propio — el Estado A es siempre de solo acceso/edición. - Regla un-atleta-un-club aplicada en backend (`addClubMember`); el front muestra el mensaje de error devuelto por el servidor. - Cross-DB JOIN desde `$pcn` (genniux_rch) hacia `genniux_board.puesto` y `genniux_board.perfil` — válido porque ambas DBs están en el mismo servidor con credenciales root. - **Regla de prefijo cross-DB:** la base de datos nativa de la conexión PDO (`$pcn` → genniux_rch, `$pcn2` → genniux_board) nunca lleva prefijo; solo la base "remota" en el JOIN lo lleva. Invertir esto rompe la query. - PDO en `configrch.php` usa `ERRMODE_SILENT` por defecto → `prepare()` puede devolver `false` sin excepción. Envolver queries cross-DB en `try/catch` + chequeo explícito de `$query === false` para evitar fatal errors silenciosos. - **Filtros del directorio:** el mockup es la fuente de verdad para opciones de selects y controles de filtro. - **Naming relay:** campo BD `busco_relay`, parámetro UI `busco_equipo`. Corregido también `busca_coach` → `busco_coach` en la respuesta PHP. - **Gradiente de avatar:** determinista por nombre — `charCode[0] + charCode[1]`, pool de 8 gradientes. - **Grid del listado:** `auto-fill minmax(210px, 1fr)`. Altura uniforme por fila vía cadena `height: 100%` + `margin-top: auto` en footer. - **Tab Coaches:** chip "Disponibles" activo por defecto al entrar al tab (`disponible: true` en `coachFilters`). - **Tarjeta Coach vs Atleta:** coach no muestra género ni edad; muestra badge disponibilidad + chips especialidad con estilo neutro. Implementado vía `mode` input en `app-athlete-list`. - **acepta_atletas:** campo exclusivo de coaches. Pendiente ratificación Victor/Anahí (Backlog B03 — no implementado aún en su versión ratificada). --- ## NEXTs Pendientes abiertos → ver backlog vivo: 📋 [`BCK-EL-Gustavo-RCH-v01.md`](../BCK-EL-Gustavo-RCH-v01.md) _(Backlog sin pendientes abiertos al cierre de esta sesión)_