Notas de versión

Cambios y releases

Histórico de versiones de Turfdex. Lo que se agregó, cambió, arregló o removió en cada release.

Formato Keep a Changelog · Versionado SemVer pre-1.0.

v0.2.0

7 de mayo de 2026

Segundo milestone. Cierra Fase 3.1 (admin & herramientas editoriales) al 100% + bases SEO/ops + revisión integral del proyecto. La estructura del producto está completa para abordar Fase 3.2 (carga de contenido masivo) y los cambios visuales planeados. Resumen del release: ImageUploader admin con Supabase Storage funcional, render público de hero image en los 6 perfiles, refactor del admin /historias y /admin/glosario a backend real (post-mock), bases SEO completas (sitemap.xml, robots.txt, OG images dinámicas, custom 404/500, render público de /historias y /glosario), backup cron Supabase a repo privado verificado, calendario funcional día + mes estilo Revista Palermo, búsqueda híbrida pg_trgm + tsvector, dark mode con toggle Sistema/Claro/Oscuro, sources público citados en perfiles, PedigreeTree 5 generaciones, sistema de contribuciones públicas free-form, community edits + cola moderación. Adicionalmente: doc 27 (QA checklist v0.1.0) y doc 28 (revisión integral pre-cambios visuales con 8 áreas auditadas) escritos para guiar el QA manual y la priorización de mejoras.

Agregado

  • ·Doc 28 — Revisión integral del proyecto v0.2.0 (docs/turfdex-v2/inter_docs/28-review-v0.2.0.md). 8 áreas auditadas (tech stack al día, parity admin vs schema, parity seed vs admin, scope vs plan original, security audit, documentation audit, code health, competencia refresh). Total findings: 4 P0 / 14 P1 / 33 P2 / abundante info verificado OK. Doc operativo con recomendaciones priorizadas en bloques A-E. Apoyado por staging files temporales en .tmp/review-2026-05-07/ (no commiteado).
  • ·Doc 27 — QA checklist v0.1.0 (pre-Fase 3.2) (docs/turfdex-v2/inter_docs/27-qa-checklist-v0.1.0.md). ~180 checkboxes organizados en 22 secciones (auth, header, 6 listings, 6 detalles, hero image, calendario, búsqueda, historias, glosario, contribuciones, /sugerencias, admin CRUD, SourceCitationEditor, ImageUploader, admin historia/glosario, colas moderación, SEO, i18n, dark mode, backup, Lighthouse, pendientes conocidos). Cada item con criterio de aceptación + URL exacta. ⚠️ marca los críticos. Documento operativo para Kevin antes de cargar contenido masivo / cambios visuales. Doc 00-README actualizado con entries para los 6 docs que estaban faltando del index (22, 23, 25, 26, 27 + actualizó la frecuencia del 21).
  • ·Admin `/admin/historias` y `/admin/glosario` reales con backend Supabase — cierra el último item de "sistemas" (los forms estaban mockeados desde Fase 2). Ahora ambos listings + forms persisten en historia y glosario_termino con i18n JSONB.
    • ·Schemas Zod (lib/admin/schemas/historia.ts y glosario.ts) con I18nText (es required, en/pt/ja optional). Historia incluye superRefine para enforce doc 07 §11: si hay imagen_url, los 3 fields de attribution (imagen_credit + imagen_license + imagen_source_url) son obligatorios.
    • ·Queries admin (lib/admin/queries-historia.ts y queries-glosario.ts) con listXForAdmin() (incluye drafts/archivados via RLS admin OR) y getXForEdit(id) para precargar el form.
    • ·Server actions (app/admin/historias/actions.ts y glosario/actions.ts) con saveX(input, mode), setXEstado(id, estado), deleteX(id), manejo de unique violation 23505 con error mapeado a slug field, revalidate de paths públicos es+en. Trigger tg_log_edit ya existente registra audit en edits table automáticamente.
    • ·`uploadHistoriaImage` server action separada — historia usa los 4 campos directos en su row (no image_assets polymorphic como los 6 entities core). Sube a Storage path historia/{uuid}.{ext}, retorna URL pública. Si el form se cancela sin guardar, queda blob orphan en bucket — tradeoff aceptado (admin-only, 5MB cap).
    • ·`HistoriaImageField` client component (components/admin/image/HistoriaImageField.tsx): file picker → upload inmediato → imagen_url se setea en el state RHF; o pegás URL externa directa (Wikimedia). Cuando hay url, render condicional de los 3 fields de attribution con datalist de licencias comunes.
    • ·HistoriaForm + GlosarioForm refactoreados a EntityFormShell + FormSection + Field, mismo patrón que CaballoForm. Auto-slug desde titulo.es / termino.es con slugify(). Botones "Guardar borrador" / "Publicar" en el footer sticky setean estado antes de submit. Validación cliente con safeParse antes de viajar al server.
    • ·Listing pages (app/admin/historias/page.tsx y glosario/page.tsx): pickEs() helper para extraer título es del JSONB, formato Intl.DateTimeFormat('es-AR', tz: America/Argentina/Buenos_Aires) para updated_at, stats inline (total · publicados · borradores). Sin MockDataNotice ni mock arrays.
    • ·Cleanup: lib/admin/mock.ts borrado (era el último consumidor de HISTORIAS y GLOSARIO mock arrays). Ningún otro código importaba de ahí.
  • ·Render público del hero image en los 6 perfiles — completa el flujo end-to-end del ImageUploader. Ahora cuando un admin marca una imagen como hero (en image_assets con is_hero=true), aparece automáticamente arriba del perfil público (/caballos/{slug}, /jockeys, /studs, /hipodromos, /carreras, /entrenadores).
    • ·Componente `<HeroImage>` server async en components/profile/HeroImage.tsx. Toma entityType + entityId + alt + priority. Query via getHeroImageForEntity en lib/queries/images.ts con createPublicClient (RLS image_assets_public_read using (true) permite SELECT anon). Si no hay hero retorna null — no placeholder genérico, graceful empty.
    • ·Layout: <figure> con aspect-[16/9] + next/image fill + sizes="(min-width: 1024px) 1024px, 100vw". priority por defecto en perfiles para LCP. Mobile: bleed -mx-4 (full-width corner-to-corner) + sin border-radius. Desktop: rounded-md + container width.
    • ·Figcaption con doc 07 §11 enforced: credit · license · fuente ↗ (link target=_blank rel=noopener noreferrer). Visible siempre, no hover-only.
    • ·Wireado en los 6 perfiles públicos justo antes del <{Entity}Hero> existente. Insertado como server component, se ejecuta en build (SSG via generateStaticParams) — costo cero en runtime.
    • ·`next.config.ts` `images.remotePatterns`: agregado *.supabase.co/storage/v1/object/public/** (bucket público de imágenes admin) + upload.wikimedia.org + commons.wikimedia.org (URLs típicas que Kevin podría pegar en historia.imagen_url cuando refactoree ese form). Sin ** wildcard para mantener allowlist explícito.
  • ·ImageUploader admin con Supabase Storage funcional — cierra el último item de "sistemas" pre-Fase 3.2 (doc 05 §3.1). Hasta hoy Kevin subía imágenes a la consola de Supabase y pegaba URL a mano: friction garantizada cuando empiece a cargar volumen (30+ caballos, 15+ jockeys, etc.). Ahora hay flujo end-to-end desde el admin de cada entidad.
    • ·Bucket `images` + storage policies (supabase/migrations/20260507130000_storage_images.sql): bucket público con file_size_limit=5MB + allowed_mime_types=[jpeg,png,webp]. Policies en storage.objects: SELECT público (matchea bucket público + claridad explícita), INSERT/UPDATE/DELETE solo si bucket_id='images' and public.is_admin(). Idempotente vía on conflict do update + drop policy if exists. Doc 26 §"storage.rules" lo tenía planeado en migrations: ahora está.
    • ·Server actions en app/admin/images/actions.ts:
      • ·uploadImage(FormData): valida mime + size, genera path determinístico {entity_type}/{entity_id}/{uuid}.{ext}, sube a Storage, hace getPublicUrl, si is_hero=true unsetea heros previos del mismo entity, inserta row en image_assets. Rollback automático del file de Storage si el insert del row falla (evita orphans).
      • ·updateImageMeta({id, credit, license, source_url}): actualiza solo metadata, no toca el archivo.
      • ·setImageHero(id): unset hero anterior + set este → 1 hero por (entity_type, entity_id).
      • ·deleteImage(id): borra row + extrae path del public URL via regex /\/storage\/v1\/object\/public\/images\/(.+)$/ y borra el blob de Storage.
      • ·Todos llaman requireAdmin() y revalidan rutas públicas /{entity_path}/{slug} + /en/{...}.
    • ·Componentes en components/admin/image/:
      • ·ImagesSection.tsx (server): wrapper que carga image_assets ordenados (is_hero desc, ordinal asc, uploaded_at desc) y delega al client.
      • ·ImagesList.tsx (client): grid 1/2/3 cols con preview thumb 4:3, badge "Hero" sobre la marcada, botones [Marcar hero / Editar / Eliminar (con confirm)] por imagen. Form de upload con file picker + preview blob (revoke object URL al cancelar/saved), checkbox "marcar como hero" defaulteado a true cuando no hay hero previa, datalist de licencias comunes (CC0, CC BY 4.0, CC BY-SA 4.0, CC BY-NC 4.0, Dominio público, Permiso del autor, Uso editorial). Validación cliente del archivo antes del submit (mime + size) para evitar trip al server con files inválidos.
    • ·Schema lib/admin/schemas/image.ts con imageAssetInputSchema (zod) — credit y license requeridos min(1), source_url requerido z.url(), is_hero boolean default false. Doc 07 §11 enforced en schema + DB (NOT NULL) + UI.
    • ·Queries lib/admin/queries-image.ts con listImagesForEntity(entityType, entityId) ordenando hero primero.
    • ·Wireado en los 6 admin edit pages (/admin/{caballos,jockeys,studs,hipodromos,carreras,entrenadores}/[id]): <ImagesSection> debajo de <SourcesSection>. Patrón consistente con SourcesSection (server wrapper + client list).
    • ·Out of scope: render público de hero en perfiles (separable, no bloquea carga); admin historia/glosario (form todavía mock — ver "Pendiente / Conocido" abajo); galería con reorder ordinal (1 hero por entidad alcanza para MVP); resize / WebP conversion (Next 16 next/image lo hace on-render, no necesita server-side por ahora).
  • ·Backup cron de Supabase a repo privado — cierra el último blocker de Fase 1c (doc 10 §"Datos recuperables sin proveedor"). .github/workflows/backup-supabase.yml corre cron diario 03:00 UTC (= 00:00 ART, momento de menor uso) + manual dispatch. Tres dumps separados via supabase db dump: roles (con RLS), schema (DDL), data (INSERT-style). Gzipped y commitados a un path dumps/YYYY/MM/<ISO-timestamp>.{roles,schema,data}.sql.gz en el repo privado turfdex-web-backups. Auto-prune de archivos > 90 días para mantener el repo manejable; Kevin puede mover snapshots mensuales a /archive manual si quiere retención más larga. Failsafe: el step "Verify required secrets" falla con error explícito si faltan BACKUP_REPO_TOKEN / BACKUP_REPO_FULL (los 3 secrets de Supabase ya existen del workflow supabase-deploy). Setup pendiente de Kevin: crear repo privado turfdex-web-backups + PAT fine-grained con scope Contents: Read+Write apuntando solo a ese repo + 2 secrets nuevos en Actions. Hasta que esté, el cron falla limpio sin tocar nada.
  • ·OG images dinámicas con `next/og` ImageResponse (built-in Next 16, sin sumar @vercel/og — feedback_no_unprompted_tech respetado). Cuando alguien pegue /caballos/yatasto, /carreras/gran-premio-carlos-pellegrini, /historias/foo en Slack/WhatsApp/Twitter, ahora aparece card brandeada en lugar del genérico Vercel default.
    • ·Template compartido en lib/og/template.tsx: warm cream bg #FAF8F5 + accent stripe bordó #7B1F2F izquierda + wordmark TURFDEX top + kicker uppercase tracking-wider + título serif XL (auto-resize a 76 si >28 chars) + meta lines bottom con border-top sutil. Constraints Satori (sin Tailwind, solo flexbox, sin grid) — todos los estilos inline. OG_SIZE = 1200×630 (estándar). fallbackOgTemplate(reason) para cuando la query falla o el slug no existe.
    • ·8 routes OG generadas:
      • ·/caballos/[slug]/opengraph-image.tsx → kicker "Caballo · Argentina" + nombre + meta (range años nacimiento-muerte, padre × madre).
      • ·/jockeys/[slug]/... → "Jockey · Argentina" + nombre + meta (range años, nacionalidad).
      • ·/studs/[slug]/... → "Stud · Argentina" + nombre + meta (año fundación, ubicación ciudad+provincia).
      • ·/hipodromos/[slug]/... → "Hipódromo · Argentina" + nombre + meta (año fundación, ubicación).
      • ·/carreras/[slug]/... → "Carrera · Argentina" + nombre_corto/nombre + meta (grade, distancia + superficie, hipódromo).
      • ·/entrenadores/[slug]/... → "Entrenador · Argentina" + nombre + meta (stud principal, victorias documentadas).
      • ·/historias/[slug]/... → "Historia del turf" + título i18n + meta (era, tiempo de lectura).
      • ·/glosario/[slug]/... → "Glosario del turf" + término + meta (sumario corto truncado a 90 chars).
      • ·/opengraph-image.tsx (root) — fallback minimal con wordmark gigante para home/listings//sobre//contribuir//calendario. Las páginas individuales tienen versiones más ricas.
    • ·Por convención Next 16, params en los image routes son Promises — todos awaiteados. Las imágenes se generan en build cuando son estáticas (la mayoría), runtime cuando dependen de data fresca. Ningún custom font cargado todavía (system serif + sans bastan); migrar a Inter/serif custom embedded en /public queda como iteración futura si la calidad no convence.
  • ·Sitemap.xml + robots.txt dinámicos — invisible pero crítico para que Google indexe la wiki entera. Hasta hoy no había mapa explícito; el crawler se basaba en links internos solamente.
    • ·`app/sitemap.ts`: enumera 12 top-level pages (home + 6 listings + calendario + historias + glosario + sobre + contribuir) más una entry por slug de cada entity. 9 queries paralelas via Promise.all (caballo + jockey + stud + hipodromo + carrera + entrenador + historia + glosario + fechas con meetings). Cada caballo además suma su /pedigree. Cada fecha con ediciones cargadas suma /calendario/YYYY-MM-DD. Hreflang via alternates.languages para cada URL (es default sin prefijo, en con /en/). priority y changeFrequency calibrados por tipo (home 1.0/weekly, listings 0.8, perfiles 0.7, glosario 0.4/yearly, calendario daily). Failsafe safeSlugs() envuelve cada query — si una falla, el sitemap se genera con las otras + entries top-level (no cae todo el sitemap por un error en una entity).
    • ·`app/robots.ts`: producción permite / con disallow de /admin, /auth, /login, /sugerencias + apunta a /sitemap.xml. Preview/dev (SITE_ORIGIN !== 'https://turfdex.com') bloquea TODO con Disallow: /dev.turfdex.com y branches Vercel preview NO se indexan, evitando contenido duplicado en SERPs.
  • ·Render público de `/historias` y `/glosario` — cierra el último item de Capa A SEO (doc 04 §"Content surfaces in MVP"). Hasta hoy el admin existía pero las URLs públicas eran PlaceholderPage. Wiki ahora indexable end-to-end.
    • ·Listing `/historias` (app/[locale]/historias/page.tsx): grid de 1/2/3 cols con HistoriaCard por publicación. Cards con imagen 16:9 (lazy via next/image), tema chip, era, tiempo de lectura, título serif, sumario clamp-3. Sort: featured_rank asc nullsLast → published_at desc nullsLast.
    • ·Detalle `/historias/[slug]`: header con metadata chips + título serif XL + sumario lead + figura imagen con figcaption (credit + license + source link target=_blank), body markdown rendereado, breadcrumbs Inicio › Historias › título, JSON-LD Article (Schema.org). generateStaticParams desde getHistoriaSlugs() para SSG. OpenGraph type=article con imagen.
    • ·Listing `/glosario` (app/[locale]/glosario/page.tsx): índice alfabético sticky + agrupación por primera letra (con unaccent via NFD strip de combining marks). Cada letra = sección con anchor #letra-X + lista de términos con sumario_corto. Layout más denso que historias (definiciones cortas).
    • ·Detalle `/glosario/[slug]`: definición completa rendereada en markdown + sección "Términos relacionados" con chips (referencias_internas) — los chips muestran labels pero por ahora no linkean (entity_id requiere resolución a slug que se difere a sesión post-data). Nota explícita en UI explicando el gap. Breadcrumbs + JSON-LD DefinedTerm (Schema.org) con inDefinedTermSet apuntando al glossary completo.
    • ·Renderer markdown propio en lib/markdown/render.tsx — sin dependencias (feedback_no_unprompted_tech). Soporta paragraphs, h2/h3, bullets, bold, *italic*, code inline, text (interno via next/link, externo target=_blank rel=noreferrer noopener). Devuelve React elements (XSS-safe, no dangerouslySetInnerHTML). NO soporta: imágenes embedded (van por imagen_url separado), tablas, code blocks ``...``, blockquotes, HTML inline. Suficiente para wiki-style content; Kevin puede ampliar el subset cuando aparezca pain real.
    • ·Queries lib/queries/historia.ts (getHistorias, getHistoriaSlugs, getHistoriaBySlug) y lib/queries/glosario.ts (getGlosarioTerminos, getGlosarioSlugs, getGlosarioTerminoBySlug). Filtran estado='published'. Tipos i18n con pickI18n fallback es → en.
    • ·i18n: namespaces HistoriasList, Historia, HistoriaNotFound, GlosarioList, Glosario, GlosarioNotFound en es/en con sub-namespaces para tema, breadcrumbs, lecturaMin (ICU), referenciasTitle, etc. Strings legacy tipoHistorias / tipoGlosario en Placeholder quedan obsoletas — se removerán cuando se borre PlaceholderPage.
  • ·Páginas custom 404 + global-error 500 con marca Turfdex. Hasta hoy /cualquier-cosa-rota y /caballos/slug-inexistente caían en defaults Next sin branding. Ahora:
    • ·app/[locale]/not-found.tsx (server, i18n via getTranslations('NotFound')): kicker "404 — Página no encontrada" + h1 + body explicando posibles causas (slug cambiado / no publicado / URL mal escrita) + CTAs primario "Sumar información" (lleva a /contribuir) y secundario "Volver al inicio" + 3 hint cards a /caballos, /carreras, /calendario. Reutiliza tokens de tema (dark mode-aware automático).
    • ·app/global-error.tsx (client, root level — DEBE incluir <html> + <body> porque reemplaza el root layout cuando un error escapa del [locale]/error boundary): copy bilingüe hardcoded ES/EN (no tiene acceso a next-intl context — el root layout falló), botón "Reintentar / Try again" cableado a reset(), link a "/" via next/link. Muestra error.digest para debugging si Next lo provee. Estilos inline (no Tailwind compilation context garantizado en este nivel).
    • ·i18n: namespace NotFound con kicker, title, body, ctaHome, ctaContribuir, hints.{caballos,carreras,calendario} en es/en.
  • ·Vista mensual `/calendario/mes/[YYYY-MM]` estilo Revista Palermo + ISR para fechas con data. Cierre del "100%" del calendario.
    • ·Vista mensual con grilla 7 cols Lun→Dom (MonthlyView.tsx). Cada celda del mes muestra el día + por meeting [HIPÓDROMO] + si tiene G1 + count de carreras. Layout matchea la convención local (Revista Palermo) que es el único calendario funcional argentino consultable. Cells fuera del mes quedan opacity-60 para preservar la grilla 7×N completa. Hoy (en hora AR) se highlightea con dot accent. Click en cualquier celda → drill a /calendario/[YYYY-MM-DD] (vista día). Multi-hipódromo en mismo día: stacked dentro de la celda (Revista Palermo lo hace igual cuando coinciden ROSARIO + SAN ISIDRO).
    • ·Toggle Día ↔ Mes (ViewToggle.tsx) en el header de ambas vistas. role="tablist" + aria-selected para a11y. Vista día → toggle linkea al mes correspondiente (date.slice(0, 7)); vista mes → toggle linkea al día de hoy. Mantiene continuidad de navegación.
    • ·Navegación entre meses con ← Abr 2026 / Jun 2026 → arriba de la grilla (mes corto localizado). Reset de mes vía toggle Día → Hoy.
    • ·Query nueva `getMonthSummary(ym)` en lib/queries/calendario.ts. Una sola query trae todas las ediciones del mes con carrera.grade + hipodromo.{slug, nombre, nombre_corto} joined; agrupa server-side en Map<fecha, Map<hipodromo_slug, { count, hasG1 }>> para emitir CalendarioMonthCell[] listo para render. Filtra carrera.estado === 'published' (admin drafts no contaminan vista pública).
    • ·Strings nuevas en namespace Calendario: metadata.titleMonth / descriptionMonth, monthlySubtitle, nav.viewDay / viewMonth / viewToggleLabel. Localizadas en es y en. Formato del mes header via Intl.DateTimeFormat({ month: 'long', year: 'numeric' }) por locale.
  • ·`generateStaticParams` en `/calendario/[date]` para ISR. Función nueva getAllDatesWithMeetings() retorna todas las fechas únicas con ediciones cargadas (4 hoy: 1951-09-15, 2026-09-13, 2026-10-17, 2026-11-14, 2026-12-12 — wait, 5). Esas se generan estáticas en build → primer hit es instantáneo. Las fechas no listadas siguen rendeando dinámicamente — el empty state es barato (1 query a getNextScheduledMeeting). Try/catch defensivo: si la query falla en build, devuelve array vacío → todas dinámicas (no rompe build). Cuando la temporada se cargue completa (~150 fechas/año), serán todas pre-generadas para velocidad y SEO.
  • ·Calendario funcional mediano — filtros UI, region wiring y JSON-LD `SportsEvent`. Tres ítems atacados en un solo sprint para llegar al "100%" de la vista A.
    • ·Filtros UI hipódromo + grade vía URL params (?hipodromo=palermo&grade=G1). Componente CalendarioFilters (client) renderiza chips por cada hipódromo y grade que aparece en el día (derivados server-side desde los meetings antes de filtrar — los chips son "narrowing", no "discovery"). Click en chip → router.push a la URL filtrada. "Quitar filtros" reset rápido. Las filas de filtro sólo se renderizan si hay 2+ opciones (no tiene sentido un chip "todos / palermo" si Palermo es lo único). El query no se duplica: trae todo el día y filtra en JS sobre el array (la escala — máx ~30 ediciones/día — hace que filtrar 30 rows en memoria sea irrelevante en costo). Los meetings que quedan vacíos tras filtro por grade se omiten (no se renderea card "PALERMO 0 carreras"). El JSON-LD respeta el filtro — sólo emite los SportsEvents visibles.
    • ·`RegionFilterChip` + `RegionGated` + `RegionAwareList` wireados — última sección de descubrimiento que ignoraba el filtro de región (doc 23 §6) ahora alineada con /caballos, /jockeys, /studs, /hipodromos, /carreras, /entrenadores. Cada MeetingGroup se envuelve con RegionGated regiones={meeting.hipodromo.regiones} (filtrado por hipódromo, no por edición — la edición hereda la región de su sede). RegionAwareList envuelve la grilla y, si el filtro activo deja todos los meetings ocultos, renderea el empty state estándar "no hay perfiles cargados para {region} todavía — Ver todos los perfiles" con CTA reset. RegionFilterChip indica el filtro activo arriba de la grilla cuando ≠ INTERNACIONAL.
    • ·JSON-LD `SportsEvent` por edición (doc 07 lista JSON-LD como non-negotiable para contenido público). Componente EventsJsonLd server-side emite un solo <script type="application/ld+json"> con el array de todos los eventos visibles del día. Por evento: name (nombre carrera), startDate (YYYY-MM-DDTHH:MM:00-03:00 — Argentina UTC-3 sin DST), sport: "Horse racing", eventStatus: EventScheduled, eventAttendanceMode: OfflineEventAttendanceMode, url al perfil de carrera, location (Place con nombre + URL al perfil del hipódromo), description (distancia + superficie + grade), y competitor (caballo ganador si la edición ya corrió). Excluye ediciones canceladas/suspendidas. URLs respetan locale prefix (/en/... para inglés, sin prefijo para es por localePrefix: 'as-needed'). Cuando el campo hipodromo.timezone se popule por hipódromo (ya está en schema desde 0003 pero deferido), se cambia el offset hardcodeado por formatInTimeZone per-meeting para soportar Hipódromo de Tokio o Santa Anita correctamente.
  • ·Calendario polish quirúrgico — 5 fixes de pulido sobre el shell vista A. Ataca los detalles que separan "funcional" de "100%" después de Kevin ver el primer render.
    • ·Breadcrumbs visibles en CalendarioView (Inicio › Calendario › 7 mayo 2026). Las strings ya existían (Calendario.breadcrumbs); inconsistencia con el resto del sitio resuelta. Mismo pattern que PedigreeBreadcrumbs: nav inline con aria-label="breadcrumb" y typography uppercase tracking-wider.
    • ·Empty state con fecha humanizada en lugar del crudo "en 129 días". Lógica en EmptyState.tsx: daysUntil <= 0 → "hoy", === 1 → "mañana", <= 6 → "el sábado", > 6 → "el sábado 13 de septiembre" (Intl.DateTimeFormat localizado es-AR / en-US). String del namespace Calendario.empty.nextOne cambia de {days, plural, ...} a {when} — la decisión de cómo expresar el tiempo queda en el componente, no en ICU.
    • ·Entrenador + stud ganador en `EdicionRow` post-corrida. Cierra inconsistencia con cross-link audit Fase 2: edicion.ganador_entrenador_id y edicion.ganador_stud_id ya estaban en schema desde 0004 — ahora se pidan en el SELECT del query y se renderean linkeables a /entrenadores/[slug] y /studs/[slug] debajo del jockey. Format: "Ganador: Yatasto con Leguisamo · entrenado por Etchechoury · stud Vacación". Connectors ( con , · entrenado por , · stud ) externalizados a i18n para que en/es puedan usar fraseo natural distinto.
    • ·Going (`condiciones_pista`) elevado al header del meeting cuando es uniforme entre todas sus ediciones. Racing Post pattern: si las 12 carreras de Palermo dicen "arena buena", se muestra una sola vez en el header del meeting en lugar de 12 veces en cada fila. Si difieren entre ediciones (ej. cambia el estado a mitad de jornada), no se eleva — queda implícito el manejo per-fila para fase futura. Lógica: Set sobre los condiciones_pista no-vacíos; uniform si tamaño 1 y matchea total de ediciones.
    • ·Quick-link con dot indicator (●) en días que tienen ediciones cargadas. DateNav hace una sola query batched a getDatesWithMeetings(today, today+7) y pasa el Set<string> a cada QuickLink. Dot color: accent si quick link inactivo, on-accent si activo (siempre visible). Falla gracefully: si la query rompe, los dots se omiten pero los links siguen funcionando. Le da "respiración" al header sin agregar columnas — en el render con sólo 4 fechas seedeadas (sept-dic 2026), los dots están latentes pero la mecánica está lista para cuando se cargue la temporada.
  • ·`/calendario` funcional — vista A (lista por día agrupada por hipódromo). Hasta hoy /calendario era un PlaceholderPage. Ahora es la vista principal de descubrimiento de carreras: para cualquier fecha (/calendario = hoy en hora AR, /calendario/[YYYY-MM-DD] = fecha específica) lista los meetings agrupados por hipódromo, con cada edición mostrando hora, nombre de la carrera (linkeable a /carreras/[slug]), grade chip, distancia, superficie, premio + estado (programada / corrida / suspendida / cancelada). Estado corrida además muestra Ganador: <Caballo> con <Jockey> linkeados a sus perfiles — la misma URL sirve antes y después de la corrida (ataca el principal pain point del rubro: "previous races much harder to find" — Degree53 horse racing UX review).
    • ·Investigación competitiva primero per feedback_competitive_research_before_design: relevé Racing Post racecards, At The Races, Equibase, Revista Palermo (único calendario funcional argentino — grilla mensual con [HIPÓDROMO] + trofeo + count), TurfDiario, San Isidro. Patrón ganador del rubro: lista por día agrupada por hipódromo con date picker URL-driven + quick links (Today/Tomorrow/+5d) + filter chips por país. Adaptado a Turfdex sin el ruido de bookmaker logos / odds / popups (ventaja por no ser sitio de apuestas).
    • ·Migration 0014 (20260507120005_seed_ediciones_2026.sql): seed honesto de las 4 ediciones programadas 2026 de la Cuádruple Corona (Polla 13-sept, Jockey Club 17-oct, Nacional 14-nov, Pellegrini 12-dic), respetando el mes_tradicional real de cada G1. Estado programada, sin ganadores. Idempotente vía unique (carrera_id, ano). Cero data inventada — la data de hoy/esta-semana queda con empty state útil hasta que se carguen carreras menores. Decisión documentada vs alternativas (carreras genéricas mockup tipo "Handicap Mayo" rechazadas por contaminar data real, alineado con feedback_quality_bar y bases-no-negociables § "datos verificables").
    • ·Empty state es feature de primera clase: si la fecha consultada no tiene reuniones, llama a getNextScheduledMeeting(afterDate) y muestra "Sin reuniones programadas para esta fecha — Próxima reunión: GP Polla en Palermo (2026-09-13) — en 129 días" con CTA directo a esa fecha. Si no hay próximas tampoco, ofrece /contribuir para que un fan sume fechas históricas. Ataca otra queja del rubro: "no reminders" — al menos te decimos cuándo es lo próximo y te llevamos sin click adicional.
    • ·Query `lib/queries/calendario.ts` con tres helpers: getEdicionesByDate(date) (joins edicion → carrera → hipodromo + ganador_caballo + ganador_jockey, filtra carrera.estado='published', agrupa en JS por hipódromo), getNextScheduledMeeting(afterDate) (próxima programada con carrera.estado='published'), getDatesWithMeetings(start, end) (preparación para futura vista mensual). Todo via createPublicClient (anon, RLS) — la página entera es indexable.
    • ·Componentes nuevos en `components/calendario/`: CalendarioView (header + nav + lista o empty state), DateNav (server: prev/next + date picker + quick links Hoy/Mañana/+5 días con highlight del día activo), DatePicker (client: <input type="date"> con router.push(\/calendario/\${value}\) — uncontrolled para no violar react-hooks/set-state-in-effect), MeetingGroup (card por hipódromo con header linkeado, count plural ICU, ventana horaria primera→última carrera), EdicionRow (fila con monoespaciado para hora, chip grade, distancia + superficie + premio inline, sección ganador condicional), EmptyState (con next-meeting fallback + contribuir CTA). Todos los componentes fetchean traducciones server-side via getTranslations({ locale, namespace }).
    • ·Páginas: app/[locale]/calendario/page.tsx (rebind del placeholder existente) calcula "hoy AR" via formatInTimeZone(new Date(), 'America/Argentina/Buenos_Aires', 'yyyy-MM-dd') (date-fns-tz ya estaba en deps). app/[locale]/calendario/[date]/page.tsx valida formato YYYY-MM-DD con regex + chequeo de roundtrip antes de renderear (notFound() en input inválido). Ambas con generateMetadata + getSeoAlternates para hreflang correcto.
    • ·i18n: namespace Calendario en es/en con sub-namespaces nav, filters, meeting, edicion, empty, breadcrumbs. Plurales ICU para count de carreras y countdown de días. Days of week localizados. Estados de edición (programada/corrida/suspendida/cancelada) con strings propios.
    • ·Out of scope deliberado (per feedback_iterate_from_renders — shipear shell primero, iterar después): vista mensual estilo Revista Palermo, vista semanal, filtros por hipódromo + grade (UI ya pensada en messages), JSON-LD SportsEvent por edición (lo agrego cuando Kevin valide el shell). El query getDatesWithMeetings ya queda listo para alimentar una grilla mensual cuando llegue el momento.
  • ·Búsqueda global wireada con backend híbrido (Phase A + Phase B en un solo sprint). Cierra el último gran ítem invisible — el SearchModal Cmd-K era un shell sin wiring desde Fase 1c. Ahora funciona end-to-end y el backend está al nivel "best practice" del rubro para esta escala (Postgres-native, auto-update, future-replaceable).
    • ·Migration 0012 (20260507120003_search_hybrid.sql): índices GIN de expresión sobre to_tsvector('spanish', nombre + bio.es) para las 6 entidades + rewrite de search_global RPC. El RPC ahora combina (a) similarity() trigram para typo/partial/accent, (b) ts_rank() sobre tsvector para relevance phrase, (c) boost +0.2 cuando featured_rank IS NOT NULL. Match condition es OR entre los dos modos — un typo simple ("Forly") cae al trigram, "cuádruple corona 1951" cae al tsvector. Salida del RPC extendida con bio_snippet (160 chars) + score compuesto. Auto-update sin código de sync: Postgres re-indexa los expression GIN automáticamente en INSERT/UPDATE — alineado con pedido explícito de Kevin "se va actualizando de manera automática, no tenemos que actualizarlo nosotros de nuevo".
    • ·Server action `searchModalAction(query)` en lib/actions/search-modal.ts — única superficie cliente↔backend. Si mañana migramos a Meilisearch / Typesense / pg_search, reescribimos solo el cuerpo de esta función; el modal cliente no se entera. Decisión arquitectónica para el "probablemente lo terminemos borrando" del pedido original.
    • ·SearchModal refactor completo (components/site/SearchModal.tsx): cliente con query controlled, debounce 150ms, abort flag para race-conditions cuando el usuario tipea rápido, status derivado ('idle' | 'too-short' | 'loading' | 'results' | 'no-results') sin setState en effect (react-compiler-friendly). Resultados agrupados por entity_type en orden fijo (caballos, jockeys, studs, hipódromos, carreras, entrenadores) con conteo (N) por grupo aunque sea (0) — feedback de cobertura per doc 19 §3.2. Highlight de matches case+accent insensitive con <mark> (multi-word: cada palabra se busca por separado). Keyboard nav: ↑↓ navega cross-grupo, ↵ abre el seleccionado vía useRouter de next-intl (locale-aware), esc cierra. Hover también selecciona. Auto-scroll del item activo a la vista. Empty state con 3 sugerencias contextualizadas + CTA /contribuir/new-entity?nombre=<query> (passa el texto buscado para prefill). Loading state, hint de comandos al pie, label aria-modal.
    • ·SiteHeader: cambia de <SearchModal open={searchOpen} ...> a {searchOpen && <SearchModal ...>}. El conditional mount fuerza fresh state en cada apertura sin necesidad de un useEffect de reset (que violaría react-hooks/set-state-in-effect en React 19).
    • ·i18n: namespace Search reescrito con hintIdle, hintTooShort, loading, noResultsTitle/Tip1/Tip2/Tip3/Cta, groupCount, navHint y los 6 nombres de grupo (groupCaballo, groupJockey, etc.) en es/en. String legacy emptyShell removido (la espera terminó).
    • ·Tipo SearchResult extendido: bio_snippet: string + score: number (compuesto). Compatible con consumidores existentes (searchEntitiesForPicker en /contribuir) — solo agrega campos.
    • ·Investigación documentada en CHANGELOG: comparé pg_trgm-only / tsvector / pg_trgm+tsvector híbrido / pg_search (ParadeDB) / pg_textsearch / Meilisearch / Typesense / Algolia. Para escala MVP (~50-2K entidades) y requisito "auto-update sin sync layer", Postgres híbrido gana sin discusión. Path a Meili sigue abierto como Phase C cuando aparezca pain real (>5K entidades + phrase queries específicas).
  • ·Cross-link audit Fase 2 — 4 fixes de descubrimiento bidireccional (cierra los gaps obvios identificados al mapear las 6 entidades x 6 entidades). Hasta hoy varios FK existentes en DB no se renderizaban → un fan abriendo Yatasto no veía quién lo crió ni quién lo entrena, y la página de una carrera mostraba el jockey ganador pero NO el entrenador ganador (data ya estaba en edicion.ganador_entrenador_id).
    • ·Caballo Hero: 3 chips nuevos linkeados — Criador (stud_criador_id), Stud actual (stud_actual_id, omitido si igual al criador para no duplicar), Entrenador (entrenador_actual_id). Cuando criador = actual, el chip se muestra como "Stud" a secas. Query getCaballoBySlug extendida con los 3 joins; tipo CaballoDetail extendido con stud_criador, stud_actual, entrenador_actual (todos {slug, nombre} | null). i18n keys Caballo.chip.{stud,studCriador,studActual,entrenador} en es/en.
    • ·CarreraEdiciones: nueva línea "Entrenador:" debajo del jockey en cada edición ganada. edicion.ganador_entrenador_id ya existía en schema desde 0004 — solo faltaba pedirlo en el SELECT y renderizarlo. Query getCarreraBySlug y tipo CarreraEdicionEntry extendidos. i18n key Carrera.ediciones.entrenadorLabel.
    • ·Stud → entrenadores trabajando ahí: nueva sección en /studs/[slug] listando entrenadores donde stud_trabajo_principal_id = stud.id o stud_propio_id = stud.id. Cada entrada con vinculo: "Trabajo principal" o "Stud propio" (doc 18b §1). Query inversa con or(...) filter. Sección entera no renderiza si la lista está vacía. Componente nuevo components/stud/StudEntrenadores.tsx. i18n keys Stud.entrenadores.{title,subtitle,vinculoPrincipal,vinculoPropio}.
    • ·Jockey → top entrenadores frecuentes: nueva sección en /jockeys/[slug] con top 5 entrenadores con quienes el jockey llevó más monturas, mostrando # de montas y # de victorias por entrenador. Agregación en JS sobre la marcha (un solo loop sobre los rows de palmares — no segundo round-trip a la DB). Tie-break por victorias desc. Componente nuevo components/jockey/JockeyEntrenadoresFrecuentes.tsx. i18n keys Jockey.entrenadoresFrecuentes.{title,subtitle,countLine} con ICU plurals.
  • ·Render público de fuentes citadas en los 6 perfiles (cierra item de Fase 3.1 — el editor admin existía desde commit 575a412 pero las fuentes no se veían en público). Bloque "Fuentes" al pie de cada perfil (entre la última sección de datos y el FuenteFooter) listando todas las citas de la entidad. Cada item: badge con field_name formateado a uppercase con espacios (ej. fecha_nacimientoFECHA NACIMIENTO) o "Perfil completo" si el field es null, nombre de la fuente (o hostname como fallback si solo hay URL) clickeable a la URL original (target=_blank rel=noreferrer noopener), hostname auxiliar y fecha formateada via Intl.DateTimeFormat por locale. La sección entera no renderiza si la entidad no tiene sources cargadas (regla "secciones sin contenido desaparecen", doc 13). Falla resiliente: si la query rompe, loggea a console.error y oculta la sección — los datos secundarios nunca rompen el perfil entero.
    • ·Query getSourcesForEntity(entityType, entityId) en lib/queries/sources.ts usa createPublicClient (anon). Sort: nullsFirst por field_name (perfil completo arriba) + created_at desc dentro de cada bucket. RLS sources_public_read using (true) ya permitía SELECT a anon desde migration 0010 — no se tocó la DB.
    • ·Componente components/site/SourceCitations.tsx (server async). Tipo entityType restringido a las 6 entidades con perfil público. Helper humanizeFieldName para los badges, safeHostname con fallback al URL crudo si parsea mal.
    • ·Wireado en los 6 perfiles públicos: /caballos/{slug}, /jockeys/{slug}, /studs/{slug}, /hipodromos/{slug}, /carreras/{slug}, /entrenadores/{slug}.
    • ·i18n: namespace SourceCitations en es/en con title, subtitle, fullProfileLabel.
  • ·PedigreeTree 5 generaciones + página dedicada `/caballos/{slug}/pedigree` (cierra item de Fase 3.1 y matchea estándar industria). Hasta hoy el perfil mostraba solo 1 generación (padre + madre); ahora hay (a) preview inline de 3 generaciones en el perfil del caballo y (b) página dedicada con árbol completo de 5 generaciones (hasta tatara-bisabuelos = 31 nodos máx). Layout estándar del rubro: sire arriba / dam abajo, generaciones izquierda → derecha. Mobile: la grilla mantiene min-width fijo + overflow-x-auto, scrolleable con swipe — captura el hueco que dejan los rivales (Pedigree Online, Pedigree Query, Stud Book Argentino siguen siendo desktop-first o tienen mobile en beta). Cada celda con datos linkea al perfil del ancestro si está published; ancestros draft renderizan el nombre sin link + badge "Perfil no publicado"; ancestros desconocidos muestran card dashed con CTA "Sugerir perfil →" en G2 (en G3+ el flujo natural es navegar al ancestro intermedio). G1 destacado con bg accent-soft. Decisión documentada vs doc 12 ("no sub-páginas para profundidad"): pedigree dedicada SUMA SEO independiente para queries pedigree {nombre} (convención del rubro, ej. Stud Book Argentino /ejemplares/pedigree-desarrollado/...) — excepción justificada, no precedente para otras secciones.
    • ·Migration 20260507120002_pedigree_function.sql: función SQL get_pedigree_tree(root_id uuid, max_depth int) con CTE recursivo respetando RLS (anon ve solo published, admin ve todo via is_admin()). Convención Ahnentafel: n → padre = 2n, madre = 2n+1. Costo ~O(2^depth) ≈ 31 reads para 5 gen, todas por PK con índices caballo_padre_idx / caballo_madre_idx existentes.
    • ·Query helper lib/queries/pedigree.ts: getPedigreeTree(rootId, maxDepth) (anon, para rutas públicas) + getPedigreeTreeForAdmin(rootId, maxDepth) (server client con cookies — admin ve drafts). Tipos PedigreeNode y PedigreeTree con lookup byPosition.
    • ·Componente components/caballo/PedigreeTree.tsx (server) renderiza la grilla via CSS Grid: cada celda a profundidad d ocupa 2^(maxDepth-d) filas para alinear visualmente con sus descendientes. Tipografía decreciente con la profundidad (G1 serif xl → G5 serif sm). Año mostrado en G1-G3, omitido en G4-G5 para densidad.
    • ·CaballoPedigree (existente) reescrito como wrapper de preview 3 gen + CTA "Ver árbol completo de 5 generaciones →".
    • ·Página /[locale]/caballos/[slug]/pedigree/page.tsx con generateStaticParams desde getCaballoSlugs, metadata propia (title Pedigree de {nombre} — Turfdex, description SEO-friendly), breadcrumbs, swipe hint en mobile y back link al perfil. getSeoAlternates para hreflang correcto.
    • ·i18n: namespace Caballo.pedigree extendido con previewSubtitle, fullSubtitle, viewFullTree, noPedigree, draftNotice, fullPageTitle, fullPageMetadataTitle, fullPageMetadataDescription, backToProfile, swipeHint. Strings legacy firstGen y deferNote removidos (la nota de iteración pendiente quedó obsoleta — la iteración se shippeó).
    • ·Admin form de caballo (CaballoForm) suma link "Ver pedigree de 5 generaciones (sitio público) →" debajo de los pickers padre/madre cuando el caballo está published, para validación visual sin tener que ir al header. El editor de pedigree sigue siendo los pickers padre/madre existentes (EntityIdSelect con excludeId para evitar self-FK) — autocomplete diferido siguiendo "no tech without pain" (a escala actual ~15 caballos, el <select> nativo alcanza).
    • ·Investigación de mercado documentada en doc 02 §"Benchmark pedigree": 5 generaciones es estándar industria (Equineline FREE 5-Cross, Pedigree Query); Stud Book Argentino usa página dedicada con QR para vincular pedigrees impresos al digital; Pedigree Online tiene quejas explícitas en reviews ("archaic", "not mobile friendly", "trying to use it on devices smaller than iPad is extremely problematic") — confirma mobile como hueco real que podemos liderar.
  • ·Modo oscuro con detección automática del sistema y toggle en header: paleta editorial cálida (warm near-black #14110f en lugar de pure black) con bordó "lifted" a #c8485c para mantener WCAG AA sobre fondos oscuros (el bordó claro #7b1f2f no pasaba contraste en oscuro). Tres estados: Sistema (default — sigue prefers-color-scheme), Claro, Oscuro. Toggle cíclico en el header (entre LocaleToggle y UserMenu) con íconos sun/moon/monitor; tooltip y aria-label traducidos al estado actual. Persistencia en localStorage['turfdex-theme']. Anti-FOUC: script inline sincrónico en <head> aplica la clase .dark al <html> antes del primer paint, así el primer frame ya está en el tema correcto. La paleta se implementa via swap de CSS vars (:root.dark { --color-bg: ...; ... }) — todos los componentes existentes (header, footer, fichas de las 6 entidades, admin shell, search modal, drawer móvil, dev banner) se adaptan automáticamente porque ya consumían los tokens via var(--color-X). color-scheme: light/dark también seteado para que scrollbars y form controls nativos respeten el modo. Sin librería externa (descartado next-themes por regla "no tech without pain" — el problema cabía en ~80 LOC).
  • ·Link "Panel de administración" visible en avatar dropdown cuando el usuario es admin: hasta hoy se entraba a /admin solo via URL directa. Ahora el UserMenu chequea isAdminEmail(email) y, si sos admin, suma un link "Panel de administración" arriba del separador con la "Mis sugerencias". Resuelve fricción real (Kevin abriendo /admin a mano cada vez).
  • ·Refactor: ADMIN_EMAILS extraído a lib/admin/emails.ts (archivo neutro sin imports server-only) para reusar entre lib/admin/auth.ts (server, redirect en /admin/*) y components/site/UserMenu.tsx (client, decisión de mostrar el link). Helper isAdminEmail(email) para no repetir el .includes(). Source of truth sigue siendo la función SQL is_admin() — el array TS es defense-in-depth UX.
  • ·Doc operativo en supabase/README.md § "Admin access" explicando cómo se hace admin en Supabase (vs el modelo Firebase de custom claims) — qué hace is_admin(), dónde se duplica la lista, cómo sumar otro admin, cuándo migrar a tabla de roles.
  • ·SourceCitationEditor — admin para citar fuentes (cierra item de quality bar de Fase 3.1). La tabla sources estaba pre-armada desde 0002 pero las fuentes se cargaban via SQL. Ahora cada admin form de entidad tiene una sección "Fuentes" debajo del form principal donde se pueden agregar / editar / eliminar citas inline. Cada fuente vincula un field_name opcional (ej: fecha_nacimiento, padre, palmares_1951) — vacío significa "avala el perfil entero" — más URL (required) + nombre (recomendado) + fecha + notas.
    • ·Schema Zod sourceInputSchema en lib/admin/schemas/source.ts con discriminator entre insert y update via id opcional.
    • ·Server actions saveSource (insert/update unificado por id) y deleteSource en app/admin/sources/actions.ts. Ambas resuelven slug del entity target via looseClient y revalidatePath del perfil público en es y en (ya que las sources eventualmente se renderizan ahí).
    • ·Query listSourcesForEntity(entityType, entityId) en lib/admin/queries-source.ts con sort: perfil entero primero (nullsFirst), después por field_name.
    • ·Componente SourcesSection (server) que carga las sources y delega al client SourcesList. Lista con cada fuente como row colapsada (badge field_name + nombre + fecha + URL clickeable) y acciones inline Editar / × (con confirm-twice para borrar). "+ Agregar fuente" abre form inline arriba de la lista. Edit reusa el mismo form en lugar de la row.
    • ·Wireado en las 6 admin pages de entidad: /admin/{caballos,jockeys,studs,hipodromos,carreras,entrenadores}/[id]/edit ahora muestran SourcesSection debajo del form principal. En carrera, queda debajo de EdicionesSection.
  • ·PalmaresEditor — admin para ediciones + inscriptos (cierra item bloqueante de Fase 3.1). Hoy las relaciones caballo_carrera y las filas de edicion se cargaban vía SQL directo; con esto Kevin puede armar el palmarés histórico de cualquier carrera desde admin sin tocar SQL.
    • ·Schemas Zod: edicionInputSchema (año, fecha, hora, premio_*, ganador_*, tiempo_ganador, condiciones, observaciones, estado) e inscriptoInputSchema (caballo_id, jockey_id, entrenador_id, puesto, tiempo, diferencia, peso_kg, dividendos, notas) en lib/admin/schemas/edicion.ts.
    • ·Server actions en app/admin/carreras/[id]/ediciones/actions.ts: saveEdicion(input, mode) con manejo de unique constraint (carrera_id, ano), deleteEdicion(id) con resolve previo del slug para revalidatePath, y saveInscriptos(edicionId, rows[]) que sincroniza la lista (delete missing + upsert resto). Todas con requireAdmin() y revalidatePath('/carreras/{slug}').
    • ·Queries lib/admin/queries-edicion.ts: listEdicionesForCarrera, getEdicionForEdit, listInscriptosForEdicion con joins a caballo/jockey/entrenador para mostrar nombres, getCarreraSummary para contexto, pickers de jockey y entrenador.
    • ·Sección "Ediciones" en /admin/carreras/[id]/edit (debajo del CarreraForm) con tabla de ediciones existentes (año, fecha, estado pill, ganador con cross-link al perfil público, tiempo, link "Editar →") + botón "+ Nueva edición".
    • ·/admin/carreras/[id]/ediciones/nuevo — form de edición con datos básicos + ganador. Save → redirect a [edicionId] (donde aparece el grid de inscriptos).
    • ·/admin/carreras/[id]/ediciones/[edicionId] — form de edición + grid de inscriptos. Grid con scroll horizontal (min-width 1100px), columnas: puesto, caballo (required), jockey, entrenador, tiempo, diferencia, peso_kg, notas, [×]. "Agregar inscripto" suma row vacía. "Guardar inscriptos" hace bulk save vía la action — el algoritmo detecta deletes (rows que ya no están en el array) y hace insert/update del resto. Confirm-twice para borrar la edición completa.
  • ·`/sugerencias` — vista user-side de contribuciones: nueva página /sugerencias (auth gate server-side, robots noindex) con dos secciones — "Correcciones de biografía" lista los pending_edits propios del usuario logueado, "Contribuciones libres" lista las submissions propias. Cada item con status pill (color por estado), tipo, target con cross-link al perfil público, fecha (Intl.DateTimeFormat es-AR), preview del payload truncado a 280 chars + admin notes si fueron escritas. Empty states accionables linkeando a /contribuir. RLS hace su trabajo automático: la query trae solo lo del usuario porque las policies restringen user_id = auth.uid() / submitter_id = auth.uid().
  • ·Helper listMyContributions() en lib/queries/my-contributions.ts que trae pending_edits + submissions del usuario logueado en paralelo + resuelve nombres/slugs de las entidades target en una sola pasada batched (sin N+1).
  • ·UserMenu: link "Mis sugerencias" → /sugerencias re-habilitado entre el header del email y el botón de cerrar sesión. Cierra el dropdown al click. i18n key Nav.user.mySuggestions en es/en.
  • ·Etapa 3 — Roadmap update (3.5): doc 05 §3.1 actualizado para reflejar Etapa 3 cerrada (3.0–3.4), ImageUploader bajado en prioridad con justificación (Kevin carga imágenes via consola Storage mientras carga contenido seed). Memoria project_turfdex.md actualizada al estado 2026-05-07.
  • ·Etapa 3 — Página /sobre real (3.4): reemplaza el placeholder por una página con misión + qué somos / qué no somos (sin betting, sin foros, capa de experiencia sobre datos públicos) + cómo construimos contenido (curaduría editorial + fuentes obligatorias + audit trail) + sección "cómo podés contribuir" con CTA al /contribuir + créditos. Server component, i18n via namespace Sobre (es/en). Items con bullets renderizados via t.markup para soportar bold dentro del JSON sin tener que dividir cada string en 3 keys.
  • ·Etapa 3 — Hooks contextuales (3.3): enchufamos los puntos de entrada al sistema de contribuciones desde el resto del sitio. Footer de los 6 perfiles ahora monta ProfileFuenteActions (combo: SuggestBioEdit existente + 2 links nuevos "Sumar info" → /contribuir/extra-info?et=X&eid=Y + "Reportar error" → /contribuir/error-report?et=X&eid=Y). Los links nuevos son Link simples — el auth gate lo maneja la sub-page server-side via redirect a /login?next=. Empty state del SearchModal global ahora tiene footer con link "¿No encontrás algo? Proponé un perfil →" → /contribuir/new-entity (sin nombre porque la search real todavía es shell). PedigreeCard de caballo ahora muestra link "Sugerir perfil →" en la card del padre/madre cuando no existe → /contribuir/new-entity?et=caballo (cierra el escape hatch de doc 14 §C "ancestro NO tiene perfil"). Strings nuevos en Contribuir.profileActions, Contribuir.searchEmpty, Contribuir.pedigree (es/en).
  • ·Etapa 3 — Cola admin /admin/submissions (3.2): nueva sección admin para revisar contribuciones libres. /admin/submissions lista filtrable por status (pending/in_review/needs_info/applied/rejected) y por type (new_entity_proposal/extra_info/error_report) vía URL search params. Por defecto muestra status=pending, todos los tipos. Filtros como chips con badge de color por status. Tabla con tipo, summary del payload, target (entidad si aplica), submitter UUID corto, fecha. /admin/submissions/[id] detalle con: header de status + payload card específico por tipo (NewEntityProposal muestra nombre/descripción/datos extra; ExtraInfo muestra texto formateado; ErrorReport muestra campo + valor_actual + valor_correcto + descripción), source card si hay, admin notes card si ya fue moderada, y panel de acciones lateral con dropdown de status + textarea de admin notes + sección colapsable "vincular entidad" (applied_entity_type + UUID para trazabilidad cuando se aplica la submission al crear/editar la entidad). Botón Guardar dispara updateSubmissionStatus que actualiza la submission + escribe en moderation_log.
  • ·Sidebar admin: grupo "Moderación" ahora tiene 2 entries — "Bio edits" → /admin/pending-edits (renombrado desde "Sugerencias" para distinguir del nuevo) y "Contribuciones" → /admin/submissions.
  • ·Etapa 3 — UI pública /contribuir (3.1): nueva página /contribuir (server, sin auth gate — hub público invitando) con 3 cards que linkean a 3 sub-pages dedicadas: /contribuir/new-entity, /contribuir/extra-info, /contribuir/error-report. Cada sub-page hace auth gate server-side (redirect a /login?next=/contribuir/{tipo} si no logueado), lee query params para prefill (?et=<entity_type>, ?eid=<entity_id>, ?nombre=<...>, ?campo=<...>) y renderiza el form correspondiente. Robots noindex en las sub-pages (no son contenido para SEO).
  • ·Componentes en components/contribuir/: NewEntityForm, ExtraInfoForm, ErrorReportForm (client, React Hook Form-style con state local + useTransition + server action). EntityTargetPicker shared para los 2 forms con target — autocomplete debounced (200ms) sobre el RPC search_global filtrado por tipo, fallback "no encontramos / proponé crear este perfil →" linkeando a /contribuir/new-entity?et=X&nombre=Y. SourceFields shared (URL + nombre fuente, recomendado, no obligatorio). SubmissionResultBanner shared (idle/submitting/success/error/invalid).
  • ·Server action searchEntitiesForPicker(query, type) en lib/actions/search-entity.ts (wrapper que filtra el resultado de searchGlobal por tipo, per_entity=8). Helper getEntityDisplay(type, id) en lib/queries/entity-display.ts para resolver (entity_type, entity_id){display_name, slug} desde URL prefill server-side, sin asumir que el hook contextual conozca el nombre. Soporta los 8 tipos (6 dim + historia + glosario_termino — los últimos leen de titulo.es/termino.es).
  • ·i18n: namespace Contribuir completo en messages/{es,en}.json (hub, common, entityType, newEntity, extraInfo, errorReport, picker — ~80 strings).
  • ·Etapa 3 — Schema submissions + plomería (3.0): nueva tabla submissions (migration 20260507120001_submissions.sql) para contribuciones públicas free-form. Tres tipos vía enum submission_type: new_entity_proposal ("este caballo/jockey/etc no existe"), extra_info ("tengo info adicional sobre tal entidad"), error_report ("esto está mal, debería decir esto otro"). Workflow status: pending | in_review | applied | rejected | needs_info. Separada de pending_edits a propósito: pending_edits es diff a un field conocido con auto-aplicación, submissions es free-form que el admin lee y traduce manualmente a action (crear entidad / editar campo / cerrar). RLS: usuario logueado inserta solo como sí mismo, lee las propias; admin lee/escribe todas. Indexes para cola admin (status+created), "mis sugerencias" (submitter+created), submissions por entidad. Trigger tg_set_updated_at() reusado.
  • ·Server action pública createSubmission en lib/actions/submissions.ts con validación Zod por discriminated union (cada tipo tiene su payload schema). Devuelve success | unauthenticated | invalid | error. Valida que entity_id sea uuid para extra_info/error_report; new_entity_proposal lo deja null (la entidad no existe todavía).
  • ·Queries admin listSubmissions({status?,type?}) y getSubmissionById en lib/queries/submissions.ts con join manual a la entidad target (mismo patrón que pending-edits.ts). Server action admin updateSubmissionStatus en app/admin/submissions/actions.ts que actualiza status + notes + opcionalmente la entidad creada/editada (para trazabilidad), y registra en moderation_log. NO aplica writes automáticos al pasar a applied — el admin crea/edita la entidad con el flow normal y deja constancia acá. Tipos Submission, SubmissionType, SubmissionStatus, SubmissionPayload (discriminated union de los 3 payloads) agregados a lib/supabase/types.ts.
  • ·Etapa 2 — community edits replicado a las 6 entidades: /jockeys/[slug], /studs/[slug], /hipodromos/[slug], /carreras/[slug] y /entrenadores/[slug] ahora montan el mismo SuggestBioEdit que caballo en su FuenteFooter. Cobertura de community editing completa para todos los perfiles públicos. Sin código duplicado: el componente, el server action y la cola admin sirven a las 6 entidades vía EditableEntityType (= FavoriteEntityType). Mensajes Jockey.fuente.contactLine, Carrera.fuente.contactLine y Entrenador.fuente.contactLine actualizados en es/en para no duplicar el call-to-action de "sugerir corrección" (ya lo hace el botón). Stud e hipódromo conservan su contactLine porque pide info específica del rubro (información del stud, info histórica del hipódromo) — no compite con el botón.
  • ·Etapa 2 — community edits (vertical slice) sobre /caballos/[slug]: cualquier usuario logueado (Google OAuth) puede sugerir una corrección a bio.es desde el footer del perfil. La sugerencia entra a pending_edits (RLS pre-armada — user_id = auth.uid() enforced en INSERT). Modal con textarea pre-llenada con la bio actual, validación cliente (50–5000 caracteres, debe diferir de la actual) + server (re-valida + check de auth). El botón redirige a /login?next=… si no hay sesión.
  • ·Cola de moderación admin en /admin/pending-edits (lista todas las pendientes con entidad, slug, fecha) y /admin/pending-edits/[id] (review side-by-side con diff bio actual vs propuesta + botones Aprobar/Rechazar). Aprobar = merge de proposed_changes.bio.es sobre entity.bio preservando otras locales (en/pt/ja se mantienen) + update entidad + pending_edits.status='approved' con reviewed_by/reviewed_at + insert en moderation_log. Rechazar = solo marca status + log con motivo opcional. revalidatePath post-aprobación invalida el SSG público en es y en. Cadena de auditoría completa: pending_edits (quien propuso) + moderation_log (quien moderó) + edits (escritura real, vía trigger tg_log_edit).
  • ·UserMenu ahora reactivo con sesión: server-rendered link a /login para visitas sin sesión, avatar dropdown (inicial del email + email completo + Cerrar sesión) cuando el usuario está logueado. Subscribe a onAuthStateChange para reflejar login/logout sin recargar. Reusa el logoutAction ya existente.
  • ·Componentes nuevos: components/profile/SuggestBioEdit.tsx (auth-aware client component con modal embebido) y lib/actions/suggest-bio.ts (server action con su EditableEntityType — alias de FavoriteEntityType, las 6 entidades con perfil + bio editable).
  • ·i18n keys nuevas: Nav.user.{menuLabel,signedInAs,signOut} y bloque SuggestBio completo en messages/{es,en}.json. Mensaje Caballo.fuente.contactLine actualizado: ya no anuncia el formulario "Wikipedia-style" como pendiente (ahora coexiste como acción primaria). Otras 5 entidades mantienen el mensaje legacy hasta replicar el botón.
  • ·Bulk CSV importer CLI (npm run import -- <entity> <archivo.csv> [--dry-run]) para cargar entidades en masa desde CSV. Soporta los 6 tipos públicos. Reusa los mismos Zod schemas del admin como fuente de verdad de validación. Idempotente vía UPSERT ON CONFLICT (slug). Reporta por fila (inserted/updated/error con mensaje específico) y exit code 2 en errores. Templates en scripts/import/templates/{entity}.csv y guía en scripts/import/README.md. Auth via SUPABASE_SERVICE_ROLE_KEY del .env.local (bypassea RLS para operaciones bulk; edits.user_id queda NULL — comportamiento documentado en doc 07). Multiplica la velocidad de carga manual ~10x para sesiones de research.
  • ·Convenciones del CSV: i18n splitea en columnas con sufijo locale (bio_es, bio_en, etc.), regiones pipe-separated (LATAM|US|EU), relaciones por slug (padre_slug, madre_slug, stud_criador_slug, hipodromo_slug, serie_slug, stud_trabajo_principal_slug) resueltas pre-import via maps in-memory para evitar N+1. Single-pass: las entidades referenciadas deben aparecer en filas previas o ya estar cargadas — error claro si falta el target. Recarga el map del entity actual después de cada inserción para soportar referencias dentro del mismo CSV (caballos hijo→padre).
  • ·Devdep tsx (^4.21) para correr scripts TypeScript directos (necesario para reusar Zod schemas en el CLI). .env.local se carga via tsx --env-file nativo, sin agregar dotenv.
  • ·Admin completo wireado a Supabase para las 6 entidades/admin/jockeys, /admin/entrenadores, /admin/studs, /admin/hipodromos, /admin/carreras siguen el mismo patrón que /admin/caballos: lista real con drafts (RLS is_admin()), form con auto-slug + Zod defensa-en-profundidad + region multi-select, server actions con saveX / setXEstado / deleteX que revalidatePath para el SSG público, audit trail vía DB triggers (sin código en server actions). Cada entidad tiene su Zod schema en lib/admin/schemas/{entity}.ts. Pickers cross-entity (stud para entrenador, hipodromo + serie para carrera) cargados server-side desde lib/admin/queries.ts.
  • ·Componentes compartidos extraídos a components/admin/forms/: RegionesMultiSelect (chips toggle, vocabulario doc 23) y EntityIdSelect (FK picker genérico). Reusados por los 6 forms.
  • ·Admin caballos wireado a Supabase real (CRUD vertical slice)/admin/caballos reemplaza la lista mock por query real (incluye drafts via RLS is_admin()), /admin/caballos/nuevo y /admin/caballos/[id] cargan/guardan caballos reales con server actions (saveCaballo, setCaballoEstado, deleteCaballo). Validación con Zod schema (lib/admin/schemas/caballo.ts) defensa-en-profundidad: cliente parsea local antes de viajar al server, server re-valida con el mismo schema. Slug auto-generado desde nombre via lib/admin/slug.ts (NFD + strip diacritics + ASCII lowercase, sin libs externas). El audit trail en edits y el rename log en slug_history son automáticos vía triggers de DB (tg_log_edit / tg_track_slug_rename) — el código no los toca. revalidatePath('/caballos') y /caballos/[slug] post-save invalidan el SSG.
  • ·Form CRUD wireado con React Hook Form (useForm + useWatch + Controller) sin @hookform/resolvers — validación manual con Zod en submit. Errores se setean por field path. Multi-select de regiones como chips toggle. Pickers de padre/madre/stud cargan desde listCaballosForPicker / listStudsForPicker (server-side). Botones "Guardar borrador" / "Publicar" setean estado correspondiente y submitean.
  • ·Ruta real `/entrenadores/[slug]` con hero (icono perfil con gorra de entrenador + chips de nacionalidad, rango de carrera y stud principal cross-link), biografía narrativa, palmarés agrupado por año (cross-link a /carreras/{slug}, /hipodromos/{slug} y /caballos/{slug} por la montura entrenada via caballo_carrera.entrenador_id), sección "Caballos entrenados" leyendo de caballo_entrenador con períodos desde/hasta, JSON-LD Schema.org Person con jobTitle: "Entrenador" y página 404 dedicada.
  • ·Listing /entrenadores reemplaza el placeholder por cards reales con EntrenadorCard (icono + nombre + nacionalidad + rango + bio recortada), filtro de región. Juan Carlos Etchechoury seedeado con regiones=[LATAM] como primer entrenador histórico publicado (perfil preliminar — atribuciones a caballos del seed esperan verificación documental).
  • ·Componentes nuevos en components/entrenador/: EntrenadorHero, EntrenadorCard, EntrenadorPalmares, EntrenadorCaballos, EntrenadorIcon, PersonJsonLd. Todos Server Components.
  • ·i18n keys nuevas: EntrenadoresList, EntrenadorCard, Entrenador, EntrenadorNotFound en messages/{es,en}.json con plurales ICU.
  • ·Cobertura completa de los 6 tipos de perfil públicos: /caballos, /jockeys, /studs, /hipodromos, /carreras y ahora /entrenadores siguen el mismo patrón (hero + chips + bio + sección de cross-link específica + JSON-LD + 404 + listing con region filter). Todas las rutas placeholder de la fase original quedaron reemplazadas por rutas reales.
  • ·Ruta real `/carreras/[slug]` con hero (icono copa SVG + chips de grade, distancia, superficie, hipódromo cross-link, mes tradicional, edad y categoría/sexo admitido), historia narrativa, sección "Palmarés histórico" año-por-año con cross-link a /caballos/{slug} y /jockeys/{slug} para cada ganador, sección "Otras carreras de la serie" (Cuádruple Corona) con cross-link a las 3 carreras hermanas, JSON-LD Schema.org SportsEvent (con location, additionalProperty para distancia/grade/superficie, superEvent para la serie, subEvent[] con últimas 50 ediciones) y página 404 dedicada.
  • ·Listing /carreras reemplaza el placeholder por cards reales con CarreraCard (icono + nombre corto + grade · distancia · superficie · hipódromo + bio recortada), filtro de región. Las 4 G1 (Polla de Potrillos, Jockey Club, Nacional, Pellegrini) seedeadas con regiones=[LATAM] + bio + narrativa larga.
  • ·Componentes nuevos en components/carrera/: CarreraHero, CarreraCard, CarreraEdiciones, CarreraSerie, CarreraIcon, CarreraChips (helper), SportsEventJsonLd. Todos Server Components.
  • ·i18n keys nuevas: CarrerasList, CarreraCard, CarreraSuperficie, CarreraSexoAdmitido, Carrera, CarreraNotFound en messages/{es,en}.json.
  • ·Cadena de cross-links de Yatasto cerrada: el palmarés de Yatasto ahora linkea a fichas reales de carrera (Polla, Jockey Club, Nacional, Pellegrini), jockey (Leguisamo) e hipódromo (Palermo, San Isidro). Los 4 perfiles de carrera muestran a Yatasto como ganador 1951 con cross-link al perfil del caballo. Cobertura de SEO interno completa para el seed inicial.
  • ·Ruta real `/hipodromos/[slug]` con hero (icono pista oval + chips de ubicación, operador, capacidad, web), historia narrativa, sección "Carreras tradicionales" agrupada por grade (G1, G2, G3, Listed, Otra) con cross-link a /carreras/{slug}, JSON-LD Schema.org SportsActivityLocation (sport: "Horse racing" + foundingDate + manager Organization + PostalAddress + maximumAttendeeCapacity) y página 404 dedicada.
  • ·Listing /hipodromos reemplaza el placeholder por cards reales con HipodromoCard (icono + nombre corto + ubicación + bio recortada), filtro de región. Palermo y San Isidro seedeados con regiones=[LATAM].
  • ·Componentes nuevos en components/hipodromo/: HipodromoHero, HipodromoCard, HipodromoCarreras, HipodromoIcon, PlaceJsonLd. Todos Server Components.
  • ·i18n keys nuevas: HipodromosList, HipodromoCard, Hipodromo, HipodromoNotFound en messages/{es,en}.json. La carrera nombre_corto también se usa en cards para evitar el "Hipódromo Argentino de Palermo" tan largo en grid.
  • ·Ruta real `/studs/[slug]` con hero (icono SVG haras + chips de fundador, ubicación, hectáreas, web), historia narrativa, sección "Caballos criados" (cross-link a /caballos/{slug} — el activo de SEO interno más importante del stud), JSON-LD Schema.org Organization (con founder + foundingDate + address) y página 404 dedicada.
  • ·Listing /studs reemplaza el placeholder por cards reales con StudCard (icono + nombre + ubicación + bio recortada), filtro de región. Haras Vacación seedeado con regiones=[LATAM].
  • ·Componentes nuevos en components/stud/: StudHero, StudCard, StudCaballos, StudIcon, OrganizationJsonLd. Todos Server Components.
  • ·i18n keys nuevas: StudsList, StudCard, Stud, StudNotFound en messages/{es,en}.json.
  • ·Forli ahora cross-linkea a su criador: caballo.stud_criador_id poblado para Forli → Haras Vacación (atribución histórica documentada). Las demás atribuciones de criador del seed quedan vacías hasta tener fuentes — consistente con el footer "atribuciones de criador requieren verificación documental".
  • ·Ruta real `/jockeys/[slug]` con hero (silueta SVG + chips de nacionalidad y rango de carrera), biografía, palmarés agrupado por año (cross-link a /carreras/{slug}, /hipodromos/{slug} y /caballos/{slug} por la montura), estadísticas (win/place/show rate sobre el palmarés cargado), JSON-LD Schema.org Person y página 404 dedicada. Mismo patrón que /caballos/[slug].
  • ·Listing /jockeys reemplaza el placeholder por cards reales con JockeyCard (silueta + nombre + nacionalidad + bio recortada), filtro de región en chip + RegionGated + RegionAwareList (idéntico patrón a /caballos). Leguisamo seedeado con regiones = [LATAM].
  • ·Componentes nuevos en components/jockey/: JockeyHero, JockeyCard, JockeyPalmares, JockeyStats, JockeySilhouette, PersonJsonLd. Ningún componente nuevo de runtime — todo Server Component salvo lo que ya hereda RegionGated/RegionAwareList client-side.
  • ·i18n keys nuevas: JockeysList, JockeyCard, Jockey, JockeyNotFound en messages/{es,en}.json con plural rules ICU para victorias/carreras y todos los breadcrumbs traducidos. Cross-link Yatasto → Leguisamo (que existía pero apuntaba a placeholder) ahora resuelve a una ficha real.
  • ·Sistema de patch notes / changelog público (/changelog) con parser manual de Keep a Changelog y rendering server-side.
  • ·Footer muestra el número de versión actual como link directo a /changelog (un solo elemento clickeable, sin etiqueta "Cambios" adicional).
  • ·DevBanner arriba del header en todos los ambientes no-producción (preview, development, local), con copy traducido es/en y variante hardcoded ES en el shell admin.
  • ·Filtro de región en el header (Internacional / LatAm / EE.UU. / Europa / Japón) con persistencia en cookie tf_region. Picker desktop como dropdown + variante mobile como chips dentro del drawer.
  • ·Listing /caballos aplica el filtro client-side: cards no-matcheadas se ocultan, chip "Filtrado por: X" aparece arriba con botón × para resetear, empty state dedicado cuando la región seleccionada deja la lista vacía.
  • ·Caballos seedeados ahora tienen regiones asignadas: Yatasto / Telescópico / Yataguara = [LATAM], Forli = [LATAM, US, EU], Selim Hassan = [EU, LATAM]. Filtrado lee regiones directo de Supabase (la migration 20260506120001 ya está aplicada vía CI auto-deploy y los tipos TypeScript regenerados).

Infraestructura

  • ·Roadmap (doc 05) refrescado al estado 2026-05-06: Fases 0/1/2 marcadas como cerradas (la Fase 1c absorbió Fase 1+2 originales), sub-bloques 3.1/3.2/3.3 explícitos para distinguir herramientas editoriales (admin + bulk + community edits) vs volumen de contenido vs infra editorial. Referencias actualizadas a Supabase / Next 16 / Edge Functions (antes hablaba de Firestore / Next 15 / Cloud Functions). Pre-launch checklist consolidado (INPI, Vercel env vars, repo backups, rotar service_role, custom auth domain, cookie banner, cacheComponents). Estimaciones de tiempo restante recalculadas (~3–5 meses al lanzamiento). Mapeo Fase 1a/1b/1c (granularidad de CLAUDE.md) → Fase 1+2 original explicitado.
  • ·Documento vinculante doc 25 — Versioning + Changelog discipline define SemVer pre-1.0, formato Keep a Changelog, workflow de updates, excepción i18n para entries históricas, y reglas de release ceremony.
  • ·Documento vinculante doc 23 — Filtro de región define las 5 decisiones cerradas (scope solo listings, multi-region por entidad, cookie no URL, default Internacional, perfiles siempre completos), vocabulario controlado de 5 buckets (LATAM / US / EU / JP + INTERNACIONAL como filtro UI), modelo de datos (regiones text[] + GIN index + constraint check), patrones de filtering, interacción locale × region, y future enhancement de smart suggestion basada en locale.
  • ·Migration SQL 20260506120001_add_regiones.sql aplicada a Supabase live vía CI auto-deploy (primer push automático del nuevo workflow). Agrega columna regiones text[] a las 6 entidades geográficas (caballo, jockey, stud, hipodromo, carrera, entrenador) + constraint check + índices GIN + UPDATE seed para entidades existentes. lib/supabase/database.types.ts regenerado para incluir el nuevo campo. lib/queries/caballo.ts ahora hace select('regiones') directo y se eliminó el SEED_REGIONES_FALLBACK hardcoded.
  • ·Lección aprendida (workflow supabase-deploy.yml): NO escribir GitHub secrets a $GITHUB_ENV cuando la password contiene caracteres especiales (&, #). El formato KEY=VALUE del env file los interpreta como comentarios después del # y manglea el valor. La solución que andó: pasar password directo al CLI via flag --password "$VAR", sin re-exportarla a env file. Documentado como nota en el workflow.
  • ·Documento vinculante doc 26 — Supabase-as-code: source of truth + CD documenta el patrón Firebase-style aplicado a Supabase: schema/RLS/índices/funciones SQL viven en supabase/migrations/ (única fuente de verdad), GitHub Action supabase-deploy.yml aplica migrations automáticamente a Supabase live en cada push a development/master, regen de tipos manual documentada, rollback strategy forward-only, lista honesta de risks/limitations.
  • ·GitHub Actions workflow .github/workflows/supabase-deploy.yml corre supabase db push en cada push que toca supabase/** o el workflow mismo. Verifica secrets primero con error claro si falta alguno.
  • ·GitHub Actions workflow .github/workflows/ci.yml movido al lugar correcto dentro del repo git (antes vivía en E:/Development/hipica-hub/.github/, fuera del repo, por lo que CI nunca corrió desde el rebrand). Ahora corre typecheck + lint en cada PR y push a development/master.
  • ·Migration SQL 20260506120002_seed_jockey_regiones.sql aplica regiones = [LATAM] a Irineo Leguisamo (idempotente). CI auto-deploy la sube en el push.
  • ·Migration SQL 20260506120003_seed_studs.sql crea Haras Vacación (familia de Alvear, criador documentado de Forli) con bio + narrativa larga + regiones=[LATAM] y asigna Forli.stud_criador_id al haras. Política conservadora: solo se seedean haras con fuentes verificables; el resto se carga vía admin.
  • ·Migration SQL 20260506120004_seed_hipodromos.sql agrega bio + narrativa larga + operador (Hipódromo Argentino de Palermo S.A. / Jockey Club Argentino) + regiones=[LATAM] a Palermo (1876) y San Isidro (1935). Idempotente — solo escribe si los campos están vacíos.
  • ·Migration SQL 20260506120005_seed_carreras.sql agrega bio + narrativa larga + regiones=[LATAM] a las 4 G1 de la Cuádruple Corona (Polla de Potrillos, Jockey Club, Nacional, Pellegrini). Idempotente.
  • ·Migration SQL 20260506120006_seed_entrenadores.sql siembra Juan Carlos Etchechoury como primer perfil histórico de entrenador (Argentina, sin atribuciones a caballos hasta verificación documental, regiones=[LATAM]). Política conservadora: el slot existe, las relaciones se cargan después.

v0.1.0

6 de mayo de 2026

Primer milestone usable. Backfill consolidado de todo el trabajo previo del proyecto, desde la creación del codebase como hipica-hub hasta el rebrand a Turfdex y la primera ruta pública real.

Agregado

  • ·Producto:
    • ·Landing page (/) con hero, próximas carreras, perfiles emblemáticos, glosario destacado, secciones de misión y anti-betting.
    • ·Header con navegación principal + dropdown "Más" + drawer mobile.
    • ·Footer con 4 columnas (Contenido, Proyecto, Legal, Idioma) + copyright + crédito Barrios.
    • ·Modal de búsqueda global (Cmd/Ctrl+K o /) con shell vacía pendiente de wiring.
    • ·Toggle de idioma en header y footer (es / en).
  • ·Auth:
    • ·Login + registro split-screen (/login, /en/login) con Google OAuth vía Supabase.
    • ·Página explica privilegios (favoritos, alertas, resumen semanal) y disclaimer anti-betting.
    • ·/auth/callback route handler intercambia code por session.
    • ·UserMenu en header muestra avatar + email + logout.
    • ·requireAdmin() real con check de email contra tabla admin_user.
  • ·Admin:
    • ·Shell privado en /admin con sidebar (Caballos, Jockeys, Carreras, Studs, Hipódromos, Entrenadores, Historias, Usuarios, Auditoría, Configuración) + topbar.
    • ·robots: noindex,nofollow para no aparecer en buscadores.
    • ·Layout tree separado del público (no pasa por next-intl).
    • ·Mock data, wiring a Supabase queries pendiente para Fase 1c.
  • ·Primera ruta real `/caballos/[slug]`:
    • ·Hero con chips (sexo, pelaje, padre/madre, fechas).
    • ·Sección de historia (markdown / narrativa_larga).
    • ·Palmarés agrupado por año con cross-links a carreras y jockeys.
    • ·Pedigree de 1 generación (padre/madre con cards clickeables si tienen perfil).
    • ·Estadísticas: win/place/show rate sobre el palmarés cargado.
    • ·Footer del perfil con disclaimer de fuentes + CTA de corrección.
    • ·JSON-LD Schema.org Animal inyectado en <head> para SEO.
    • ·SVG fallback (CaballoSilhouette) cuando no hay foto cargada.
    • ·5 caballos seed (Yatasto con datos completos; Forli, Telescópico, Selim Hassan, Yataguara con datos básicos).
    • ·Páginas listing (/caballos) renderizando cards.
  • ·i18n discipline (doc 22):
    • ·next-intl v4 con localePrefix: 'as-needed' (es default sin prefijo, /en/ explícito).
    • ·Routing locale-aware con SEO: hreflang + canonical URLs vía lib/seo/alternates.ts.
    • ·generateStaticParams para SSG en rutas dinámicas.
    • ·Mensajes ES + EN traducidos para todas las strings UI (~150 keys).
    • ·Helper pickI18n para JSONB fields con fallback a ES.
  • ·Páginas placeholder traducidas para rutas no implementadas: /jockeys, /studs, /hipodromos, /carreras, /entrenadores, /historias, /glosario, /calendario, /sobre, /contacto, /fuentes, /terminos, /privacidad.
  • ·Página 404 custom para caballo no encontrado.

Cambiado

  • ·Rebrand completo de hipica-hub (codename) a Turfdex:
    • ·Toda copy, metadata, footer, header, slogans actualizados.
    • ·El folder del workspace sigue siendo hipica-hub/ (artifact path-stability), pero el producto y todos los strings dicen Turfdex.
  • ·Estructura de URLs ahora locale-aware con next-intl: / (es default) + /en/* (English explícito).
  • ·`middleware.ts` → `proxy.ts` (rename obligatorio en Next.js 16).

Infraestructura

  • ·Stack consolidado (ver doc 06):
    • ·Next.js 16.2.4 + React 19.2.4 + Turbopack default.
    • ·TypeScript strict mode.
    • ·Tailwind CSS v4 (@import "tailwindcss" + @theme inline en globals.css).
    • ·Supabase (Postgres + Auth + Storage) — migrado desde Firebase original.
    • ·next-intl v4 (RSC-compatible).
    • ·Zustand (state), React Hook Form + Zod (forms), date-fns + date-fns-tz.
  • ·Diseño:
    • ·Paleta off-white / charcoal / bordó (institucional, doc 12).
    • ·Tipografía Source Serif 4 (titulares) + Geist (body, mono).
    • ·Sticky header con efecto frosted glass.
    • ·Design tokens en CSS vars consumidos vía Tailwind.
  • ·Database (Supabase, ref `hbzfuvhhwjjypktvglun`):
    • ·10 migraciones SQL aplicadas en orden cronológico (caballos, jockeys, studs, hipodromos, carreras, ediciones, series, entrenadores, audit edits table, RLS + is_admin() SQL function).
    • ·1 migración de seed con 5 caballos, 2 hipódromos, 1 serie, 4 carreras G1, 4 ediciones 1951, 1 jockey (Leguisamo), 4 victorias.
    • ·Tipos TypeScript generados en lib/supabase/database.types.ts.
    • ·Slugs en español (caballos/{slug}), JSONB para campos i18n, timestamptz UTC, audit trail desde día 1.
  • ·Auth Google OAuth vía Supabase Auth provider:
    • ·Cliente OAuth en Google Cloud Console + secret en Supabase.
    • ·Callback URL y Authorized JS origins configurados para localhost + producción.
    • ·Pendiente pre-launch: custom domain auth.turfdex.com (requiere upgrade a Supabase Pro).
  • ·Dominio + DNS:
    • ·turfdex.com registrado en Namecheap (2026-05-05).
    • ·DNS centralizado en Vercel (nameservers migrados 2026-05-06).
    • ·Cloudflare diferido (no MVP need, doc 21 §1).
    • ·Mail vía Zoho Mail Lite a contact@turfdex.com (alias: contacto@).
  • ·CI: .github/workflows/ci.yml corre typecheck + lint en cada PR/push.
  • ·Documentación interna completa: docs 00-22 cubriendo visión, market research, target users, scope MVP, roadmap, stack, no-negociables, monetización, conversaciones clave, calidad comercial / bus factor, SEO map, dirección estética, Cache Components, login flow, OAuth setup, i18n discipline.

Removido

  • ·Cache Components (cacheComponents: true + 'use cache' directives + cacheLife/cacheTag) diferido después de un intento que reveló incompatibilidades múltiples con next-intl + Suspense + dynamic params + ICU formatting. Se re-habilitará como migración dedicada post-i18n estabilizado. Ver doc 06 §"Features de Next 16" + doc 22 §11.

Turfdex usa cookies estrictamente necesarias para autenticarte y recordar tus preferencias (región e idioma). Medimos el rendimiento del sitio sin cookies persistentes ni perfiles de usuario. Ver política de cookies.