122 lines
5.3 KiB
Markdown
122 lines
5.3 KiB
Markdown
# 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.
|