# 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.
