# F-136 — Implementer notes: Brand names Title Case + SEO title auto-fill ## Cambios ### `project/src/shared/text.ts` (nuevo) Helper puro `toTitleCase(input: string): string`: - Divide el string en palabras usando como separadores espacio, guión, punto, ampersand y slash. - Para cada palabra: primera letra mayúscula, resto minúscula (con `toLocaleUpperCase('es-ES')` / `toLocaleLowerCase('es-ES')` para manejar acentos correctamente). - Mantiene `y`, `e`, `o`, `u` en minúscula cuando NO son la primera palabra (tipografía española estándar). - **Idempotente sobre mixed-case**: si la entrada no es ALL-CAPS, la devuelve sin tocar (así `BioSana` se queda como `BioSana`, y aplicar dos veces da el mismo resultado). - Sin dependencias externas. Sin estado. Pura. ### `project/src/shared/tests/text.test.ts` (nuevo) 15 tests vitest que cubren: - String vacío → vacío. - Single word ALL CAPS (`BIOCOP` → `Biocop`). - Multi-word con espacios (`EL GRANERO INTEGRAL` → `El Granero Integral`). - Punto preservado (`A.VOGEL` → `A.Vogel`). - Guion preservado (`DAS-BROT` → `Das-Brot`). - Ampersand preservado (`TEA & INFUSIONS` → `Tea & Infusions`). - Acentos españoles (`COMPLEMENTOS Y NUTRICIÓN` → `Complementos y Nutrición`). - Idempotencia sobre Title Case ya aplicado. - Mixed-case sin tocar (`BioSana` → `BioSana`). - Dígitos intactos (`500 KILOS` → `500 Kilos`). - Separadores múltiples consecutivos. - String de solo separadores. - Single-letter conjunctions lowercase cuando no son primera palabra. - Single-letter conjunctions mayúscula cuando SÍ son primera palabra. - Single-letter words no-conjunción (`A SIDE B` → `A Side B`). Resultado: **15/15 passed**. ### `project/migrations/042_brand_title_case.js` (nuevo) Migración de datos node-pg-migrate: - Selecciona todas las filas de `brands_brands`. - Para cada fila calcula `titleCased = toTitleCase(name)` y `newSeoTitle = (seo_title || titleCased)`. - Solo ejecuta UPDATE si el nombre cambió o `seo_title` estaba vacío. - No toca slugs (ya están en lowercase vía `slugify()`). - Loggea cuántos rows actualizó de los totales. Inline la lógica de `toTitleCase` (en vez de importar de `src/shared/text.ts`) para que la migración sea autocontenida y no requiera `npm run build` antes de correr. Los 15 unit tests cubren la lógica y detectarían cualquier drift entre el helper compartido y la versión inline. ## Resultado de la migración ``` F-136: updated 28 brand row(s) (of 33 total) ``` | Métrica | Antes | Después | |---|---|---| | Total brands | 33 | 33 | | ALL-CAPS | 28 | **0** | | sin `seo_title` | 28 | **0** | | Slugs modificados | — | **0** (lowercase se preserva) | Re-ejecución es no-op: `node-pg-migrate up` reporta `No migrations to run!` (porque la migración ya está en `pgmigrations`). ## Ejemplos antes/después | Antes | Después | |---|---| | `A.VOGEL` | `A.Vogel` | | `BIOCOP` | `Biocop` | | `COMPLEMENTOS Y NUTRICIÓN` | `Complementos y Nutrición` | | `EL GRANERO INTEGRAL` | `El Granero Integral` | | `LA FINESTRA SUL CIELO` | `La Finestra Sul Cielo` | | `DULCES LISSEN` | `Dulces Lissen` | | `BioSana` (mixed) | `BioSana` (sin tocar) | Todos los `seo_title` se backfillearon con el nuevo nombre en Title Case. ## Archivos - `project/src/shared/text.ts` (nuevo) - `project/src/shared/tests/text.test.ts` (nuevo) - `project/migrations/042_brand_title_case.js` (nuevo) - `pgmigrations` table: nueva fila `042_brand_title_case` con `run_on` timestamp ## Evidencia ``` $ npx tsc --noEmit (exit 0) $ npx eslint src/shared/text.ts src/shared/tests/text.test.ts migrations/042_brand_title_case.js (exit 0) $ npx vitest run src/shared/tests/text.test.ts ✓ src/shared/tests/text.test.ts (15 tests) 2ms Test Files 1 passed (1) Tests 15 passed (15) $ node --env-file-if-exists=.env node_modules/node-pg-migrate/bin/node-pg-migrate.js up --migrations-dir migrations F-136: updated 28 brand row(s) (of 33 total) Migrations complete! $ psql -c "SELECT name, seo_title FROM brands_brands ORDER BY name LIMIT 35;" (muestra todas las marcas en Title Case con seo_title poblado) $ node-pg-migrate.js up (re-run) No migrations to run! ← idempotencia confirmada ``` ## Sin cambios en frontend El form de admin `brands/page.tsx` ya tenía `autoSeoTitle(name) → name` y `handleNameChange` que lo invoca cuando `!seoTitleManual`. Eso significa que las marcas NUEVAS ya reciben `seo_title = name` automáticamente. Para las marcas existentes (que era el problema real), la migración lo arregla en DB. No fue necesario tocar código del admin. ## Notas - El operador puede verificar el resultado con `psql -c "SELECT name, slug, seo_title FROM brands_brands ORDER BY name"` o navegando a `/brands` en el admin. - La columna `slug` no se tocó porque `slugify()` lowercasea; los slugs `a-vogel`, `biocop`, etc. ya estaban correctos desde el import inicial. - Si en el futuro el operador pide lo mismo para categorías, el mismo helper `toTitleCase` se puede reutilizar en una nueva migración (las traducciones al español ya están en `legacy-catalog.ts` desde F-116). - La migración es destructiva en sentido figurado: pasa `A.VOGEL` → `A.Vogel`. No hay forma de rollback automático porque el original está perdido. Si el operador pide revertir, sería con un script ad-hoc que lea el log de git para reconstruir.