# SPECS.md — Especificaciones de construcción · Metodología SDD

> **Metodología:** Spec Driven Development — primero se define la especificación, luego se implementa, luego se verifica. No se añade nada que no esté en la spec.

---

## Fase 1 — Núcleo del sistema

### Spec 1.1 · Estructura de directorios

Crea la siguiente estructura de directorios vacía. No generes ningún archivo todavía:

```
/project-onetoone
  /public
    /css
    /js
  /src
    /Controllers
    /Services
    /Repositories
    /Helpers
    /Views
      /partials
  /data
  /docker
  /tests
    /Services
```

Convenciones:
- Todo el código PHP usa `declare(strict_types=1)`
- Namespaces PSR-4: `Controllers\`, `Services\`, `Repositories\`, `Helpers\`
- Sin Composer, sin frameworks. El autoloader es manual en cada punto de entrada público.

---

### Spec 1.2 · Datos simulados

Crea los cuatro archivos de datos en `/data/`. Cada uno devuelve un array PHP con `return [...]`.

**`data/users.php`** — asesores de la red ONEtoONE:
- Campos: `id` (int), `name` (string), `email` (string), `phone` (string)
- Mínimo 5 usuarios con nombres realistas en español

**`data/companies.php`** — empresas conocidas:
- Campos: `id` (int), `name` (string), `domain` (string), `sector` (string), `country` (string), `city` (string), `employees` (int), `ebitda_m` (float, puede estar vacío), `aum_m` (float, puede estar vacío), `web` (string), `phone` (string), `notes` (string)
- Mínimo 6 empresas con dominios reales o plausibles

**`data/interactions.php`** — historial de interacciones de los asesores:
- Campos: `user_id` (int), `email` (string|null), `company_name` (string|null), `contact_name` (string|null), `date` (string YYYY-MM-DD)
- Regla: cada interacción tiene `email` O `company_name` (o ambos), nunca los dos nulos
- Mínimo 20 interacciones distribuidas entre varios usuarios y empresas
- Las fechas deben abarcar al menos 3 años distintos para que las reglas de scoring sean demostrables

**`data/web_content.php`** — contenido web indexado de las empresas (simula scraping/Clearbit):
- Estructura: array indexado por dominio → `['extracted_names' => [['name' => '...', 'role' => '...']]]`
- Al menos 2-3 nombres con cargos reales de M&A (CEO, CFO, Director de M&A) por empresa

---

### Spec 1.3 · Capa de repositorios

Crea cuatro repositorios en `src/Repositories/`. Cada uno carga su archivo de datos en el constructor con `require`. Ningún repositorio contiene lógica de negocio.

**`CompanyRepository`**:
- `findByDomain(string $domain): ?array` — búsqueda exacta por dominio
- `findById(int $id): ?array` — búsqueda por ID
- `findByName(string $name): ?array` — búsqueda insensible a mayúsculas por nombre

**`UserRepository`**:
- `findById(int $id): ?array`
- `all(): array`

**`InteractionRepository`**:
- `all(): array` — devuelve todas las interacciones sin filtrar

**`WebContentRepository`**:
- `findByDomain(string $domain): ?array` — usa el dominio como clave del array

Todos los métodos `find*` devuelven `null` si no se encuentra el registro. Usar tipos de retorno nullable (`?array`) en todos los casos.

---

### Spec 1.4 · Servicio de matching

Crea `src/Services/CompanyMatcherService.php`.

**Método:** `matches(array $interaction, string $domain, string $companyName): bool`

Implementa dos estrategias en orden de prioridad:

1. **Dominio de email** (mayor precisión): si la interacción tiene `email`, extrae la parte después del `@` y compara con `$domain` en minúsculas. Si coincide, devuelve `true`.
2. **Nombre de empresa** (mayor cobertura): si la interacción tiene `company_name`, compara en minúsculas con `$companyName`. Si coincide, devuelve `true`.
3. Si ninguna estrategia coincide, devuelve `false`.

**Método privado:** `extractDomainFromEmail(string $email): ?string`
- Divide por `@`, valida que hay exactamente dos partes, devuelve la segunda en minúsculas
- Devuelve `null` si el email está malformado

---

### Spec 1.5 · Calculadora de puntuación

Crea `src/Services/ScoreCalculator.php`.

**Método:** `calculate(array $interactions, string $identifiedContact = ''): array`

Aplica estas 5 reglas en secuencia. Para cada regla que se activa, suma puntos al score Y añade un item al array `$breakdown`:

| Constante | Valor | Condición |
|---|---|---|
| `POINTS_RECENT_INTERACTION` | 20 | Al menos una interacción en los últimos 12 meses |
| `POINTS_MULTI_PERIOD` | 15 | Interacciones en más de un año natural distinto |
| `POINTS_REPEATED_CONTACT` | 10 | El mismo `contact_name` aparece en 2 o más interacciones |
| `POINTS_CONTACT_IDENTIFIED` | 5 | El parámetro `$identifiedContact` no está vacío |

Para la Regla 1 (puntos por interacción), inicialmente cada interacción suma un valor fijo — esto se refinará en la Fase 9.

Cada item del breakdown es un array con:
- `type`: `'interaction'` o `'bonus'`
- `points`: int
- `date`: string|null
- `label`: string descriptivo en español

**Método privado:** `label(int $score): string`
- `score >= 50` → `'high'`
- `score >= 25` → `'medium'`
- `score < 25` → `'low'`

**Retorno:**
```php
['score' => int, 'label' => string, 'breakdown' => array]
```

---

### Spec 1.6 · Extractor de contacto

Crea `src/Services/ContactExtractor.php`.

Recibe `WebContentRepository` por constructor (inyección de dependencia).

**Método:** `extract(array $interactions, string $domain): array`

Implementa resolución por prioridad descendente:

1. Contar frecuencias de `contact_name` en las interacciones (ignorar valores vacíos)
2. Si hay contactos en las interacciones Y hay contenido web para el dominio: cruzar los contactos más frecuentes con los nombres del web content. Si hay coincidencia (insensible a mayúsculas), devolver `['name' => ..., 'role' => ...]` con el cargo del web content
3. Si no hay cruce: devolver el contacto más frecuente de las interacciones sin cargo (`'role' => ''`)
4. Si no hay contactos en interacciones pero sí en web content: devolver el primer nombre del web content
5. Si no hay ninguna fuente: devolver `['name' => '', 'role' => '']`

---

### Spec 1.7 · Controlador principal

Crea `src/Controllers/CompanyInsightController.php`.

El controlador no contiene lógica de negocio. Solo coordina.

**Constructor:** instancia todas las dependencias:
- `CompanyRepository`, `InteractionRepository`, `UserRepository`
- `CompanyMatcherService`, `ScoreCalculator`
- `ContactExtractor` (con `new WebContentRepository()`)

**Método:** `handle(array $params): array`

Flujo en 4 pasos:

1. Resolver empresa por `$params['domain']` o `$params['id']`. Si no se encuentra: devolver `['error' => 'Company not found.']`
2. Cargar todas las interacciones y filtrar+agrupar por `user_id` usando `CompanyMatcherService::matches()`
3. Para cada `user_id` con interacciones:
   - Ordenar interacciones cronológicamente con `usort`
   - Llamar a `ContactExtractor::extract()`
   - Llamar a `ScoreCalculator::calculate()`
   - Construir el array del contacto de red
4. Ordenar `$networkContacts` por `score` descendente
5. Devolver:
```php
[
    'company'          => string,
    'domain'           => string,
    'company_meta'     => array,  // sector, city, country, employees, ebitda_m, etc.
    'network_contacts' => array,
]
```

---

## Fase 2 — Capa de presentación

### Spec 2.1 · Parciales HTML

Crea tres archivos en `src/Views/partials/`:

**`head.php`**:
- Genera `<!DOCTYPE html>` completo hasta `<body>`
- Título de página: usa variable `$pageTitle` si existe, si no `'ONEtoONE · Inteligencia de Red'`
- Carga `app.css` siempre, más `$pageCss` si está definida
- Si `$useBootstrap` es truthy, carga Bootstrap 5 CDN

**`header.php`**:
- Logo "ONEtoONE · Inteligencia de Red" enlazando a `/`
- Sección derecha: botón de toggle de formato de fecha (`DD-MM-YYYY`), selector de idioma (🌐 + select), y un tag con el nombre del autor

**`footer.php`**:
- Footer con copyright de ONEtoONE
- Si `$footerLinks` está definida, añadir el contenido tras un separador `·`
- Carga `/js/app.js`
- Si `$pageScripts` está definida, inyectar como `<script>...</script>` inline
- Cierra `</body></html>`

---

### Spec 2.2 · Vista HTML principal

Crea `public/view.php`.

**Autoloader:** manual PSR-4 apuntando a `src/`.

**Sanitización de input:**
- `?domain=`: `preg_replace('/[^a-zA-Z0-9.\-]/', '', ...)`, max 253 chars, lowercase
- `?id=`: `filter_var(..., FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]])`

**Variables de plantilla:** `$pageCss = 'view.css'`, `$activePage = 'companies'`

**Layout:**
- Sidebar izquierdo con listado de las primeras 10 empresas (de `DataProvider::companies()`), con buscador JS en tiempo real filtrando por atributo `data-name`
- Main: si hay empresa seleccionada, mostrar cabecera de empresa + meta pills + tarjetas de contacto por cada `network_contact`
- Cada tarjeta: avatar con iniciales, nombre del asesor, email/teléfono, contacto identificado, meta de fechas e interacciones, score con color por nivel, breakdown de puntuación

**Funciones helper en el archivo** (no en clase):
- `scoreClass(int $score): string` → `'high'|'medium'|'low'`
- `scoreLabel(int $score): string` → `'alto'|'medio'|'bajo'`
- `initials(string $name): string` → primeras letras de las dos primeras palabras
- `avatarIndex(string $name): int` → `crc32($name) % 5` para asignar color de avatar

---

### Spec 2.3 · API JSON

Crea `public/api.php`.

- `Content-Type: application/json; charset=utf-8`
- Misma sanitización de `domain` e `id` que `view.php`
- Si falta input: HTTP 400 con `{'error': ..., 'routes': {...}}`
- Si la empresa no existe: el controlador devuelve `{'error': ...}` — pasarlo tal cual
- Si hay error interno (excepción): HTTP 500 con `{'error': 'An internal error occurred.'}`
- Salida: `json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)`

**Ruta adicional `?stats=top_users[&limit=N]`:**
- Usa `NetworkStatsService::topUsersByInteractions(int $limit)`
- `$limit` validado entre 1 y 50, default 5
- Devuelve `{'stats': 'top_users', 'limit': N, 'data': [...]}`

---

### Spec 2.4 · Landing page

Crea `public/index.php`. Usa Bootstrap 5 (`$useBootstrap = true`).

Secciones:
1. **Hero** con la pregunta de negocio
2. **Explorador de datos**: listado de empresas con enlace a `view.php`, listado de usuarios, widget de top usuarios por interacciones (con barra de progreso proporcional al máximo)
3. **Dos interfaces**: tarjetas explicando la interfaz HTML y la API JSON
4. **Flujo del sistema**: 4 pasos con descripción, más preview del JSON de respuesta
5. **Tabla de scoring**: todas las reglas con justificación de negocio
6. **Stack tecnológico**: PHP 8.1+, sin dependencias, arquitectura 3 capas

---

## Fase 3 — Estilos

### Spec 3.1 · Design tokens y CSS compartido

Crea `public/css/app.css`.

Define las siguientes variables CSS en `:root`:
```
--bg, --surface, --border, --text, --muted, --accent
--high-bg, --high-text, --high-dot
--mid-bg, --mid-text, --mid-dot
--low-bg, --low-text, --low-dot
--av0..av4 (colores de avatar, uno por variable bg y text)
--radius (10px), --radius-sm (6px)
```

Incluye estilos para: reset, body, header, logo, `.header-nav` (pill-style), `.header-right`, `.header-ctrl-btn`, `.lang-wrapper`, `.header-tag`, footer, avatares `.av0`–`.av4`.

Incluye también todos los estilos compartidos entre `view.php` y `users.php`: `.layout`, `aside`, `.sidebar-*`, `main`, `.empty-state`, `.contacts-count`, `.no-results*`, `.cards`, `.card`, `.card-top`, `.user-row`, `.score-number`, `.score-pill`, `.meta*`, `.breakdown*`, `.error-box`, y los media queries para móvil.

**Regla de separación:** si un estilo solo aparece en una página, va en el CSS de esa página. Si aparece en dos o más, va en `app.css`.

---

### Spec 3.2 · CSS por página

**`public/css/view.css`** — solo estilos exclusivos de la vista de empresa:
Lista de empresas en sidebar, cabecera de empresa, logo, dominio, meta bar con pills, notas, y las tarjetas de contacto de la red (avatar, nombre del asesor, email, teléfono, contacto identificado).

**`public/css/users.css`** — solo estilos exclusivos de la vista de asesor:
Lista de usuarios en sidebar, cabecera del asesor (avatar grande, nombre, email, teléfono), y las tarjetas de empresa prospectada (icono, título, subtítulo con sector/ciudad, KPIs, contacto identificado).

**`public/css/landing.css`** — estilos de la landing (hero, secciones, tarjetas de entrada, tabla de scoring, steps de flujo, json preview, etc.)

---

## Fase 4 — Infraestructura

### Spec 4.1 · Docker y Makefile

Crea la configuración Docker para desarrollo local:

**`docker-compose.yml`**: servicio `app` con imagen PHP 8.2 + Apache, montando el proyecto en `/var/www/html`, exponiendo el puerto 8080.

**`docker/apache.conf`**: VirtualHost apuntando `DocumentRoot` a `/var/www/html/public`, con `AllowOverride All`.

**`Makefile`** con los siguientes targets:
- `start` — `docker compose up -d --build`
- `stop` — `docker compose stop`
- `down` — `docker compose down`
- `restart` — `down` + `start`
- `logs` — `docker compose logs -f`
- `bash` — `docker exec -it app bash`
- `ps` — `docker compose ps`

---

## Fase 5 — Vista de asesores

### Spec 5.1 · UserInsightController

Crea `src/Controllers/UserInsightController.php`.

Vista inversa a `CompanyInsightController`: dado un asesor, devuelve todas las empresas que ha prospectado.

**Método:** `handle(array $params): array` donde `$params = ['id' => int]`

Flujo:
1. Buscar usuario por ID. Si no existe: devolver `['error' => 'User not found.']`
2. Filtrar todas las interacciones por `user_id === $user['id']`
3. Para cada interacción, extraer la clave de empresa: primero intentar por dominio del email, luego por `company_name`. Ignorar interacciones sin ninguna de las dos.
4. Intentar resolver cada clave a una empresa registrada (`CompanyRepository::findByDomain` o `findByName`). Si se resuelve, usar su dominio como clave canónica; si no, usar la clave raw.
5. Agrupar interacciones por clave canónica.
6. Para cada grupo: ordenar por fecha, extraer contacto, calcular score, construir el item de empresa prospectada.
7. Ordenar por score descendente.
8. Devolver `['user' => array, 'prospected_companies' => array]`

**Método privado:** `extractRawKey(array $interaction): ?string`
- Prioridad: dominio del email > `company_name`
- Devuelve `null` si ambos están vacíos

**Método privado:** `buildMeta(array $company): array`
- Extrae los campos de metadata de la empresa (sector, country, city, phone, employees, ebitda_m, aum_m, web)

---

### Spec 5.2 · Página de usuarios

Crea `public/users.php`.

Estructura idéntica a `view.php` pero centrada en asesores:
- Sidebar: listado de todos los usuarios con avatar de iniciales, buscador JS
- Input sanitizado: `?id=` con `FILTER_VALIDATE_INT`
- Usa `UserInsightController` para obtener los datos
- Si hay asesor seleccionado: mostrar su cabecera (avatar grande, nombre, email, teléfono, contador de empresas prospectadas)
- Para cada empresa prospectada: tarjeta con logo, nombre, dominio, sector, ciudad, KPIs (EBITDA/AUM/empleados), score, fechas, interacciones, contacto identificado, breakdown

---

### Spec 5.3 · Helper de datos compartido

Crea `src/Helpers/DataProvider.php`.

Centraliza la carga de datos para plantillas. Cachea el resultado por petición con una propiedad estática `$cache`.

Métodos:
- `companies(): array`
- `users(): array`
- `topUsers(int $limit = 5): array` — delega en `NetworkStatsService`

---

## Fase 6 — Navegación

### Spec 6.1 · Navegación global

Actualiza `src/Views/partials/header.php`.

Añade entre el logo y `.header-right` un elemento `<nav class="header-nav">` con dos enlaces:
- `Compañías` → `view.php`
- `Usuarios` → `users.php`

El enlace activo se determina con la variable `$activePage` (valor `'companies'` o `'users'`). El activo recibe la clase CSS `active`.

Los estilos de `.header-nav a` en `app.css` deben ser pill-style coherentes con el resto del header:
- Fondo transparente en reposo, borde invisible
- Al hover: `background: var(--bg)`, `border: 1px solid var(--border)`
- Estado activo: `background: var(--bg)`, `border: 1px solid var(--text)`

---

## Fase 7 — Mejoras al desglose de puntuación

### Spec 7.1 · Dos secciones con ordenación

El breakdown de puntuación debe separarse en dos secciones distintas:

**Sección "Interacciones directas"** — items con `type === 'interaction'`:
- Orden por defecto: fecha descendente (más reciente primero)
- Botones de ordenación: `fecha ↓` (activo por defecto) y `fecha ↑`
- Cada item muestra: `+N pts`, tipo de interacción (badge), fecha formateada

**Sección "Bonificaciones"** — items con `type === 'bonus'`:
- Orden por defecto: puntos ascendente (menor primero)
- Botones de ordenación: `puntos ↑` (activo por defecto) y `puntos ↓`
- Cada item muestra: `+N pts`, label descriptivo

Los botones de ordenación deben funcionar mediante JavaScript, manipulando el DOM (reordenando los `.breakdown-item` hijos del contenedor). Usar atributos `data-date-sort` (YYYY-MM-DD para ordenación lexicográfica) y `data-points` (numérico) en cada item.

---

## Fase 8 — Refactorización DRY

### Spec 8.1 · Partial de breakdown

Extrae el bloque de breakdown a `src/Views/partials/breakdown.php`.

El partial espera la variable `$breakdownItems` definida en el scope que lo incluye. Contiene la lógica PHP de separación en `$bDirect` y `$bBonus`, la ordenación inicial, y todo el HTML de las dos secciones.

Ambas páginas (`view.php` y `users.php`) deben usar el partial con:
```php
$breakdownItems = $xxx['score_breakdown'];
require __DIR__ . '/../src/Views/partials/breakdown.php';
```

---

### Spec 8.2 · Consolidación CSS

Audita `view.css` y `users.css`. Todo selector que aparezca en ambos archivos debe moverse a `app.css`. Los archivos de página deben contener únicamente estilos exclusivos de esa vista.

---

### Spec 8.3 · Cache-busting de assets

Actualiza `src/Views/partials/head.php` para que los `<link>` de CSS incluyan un parámetro `?v=` generado con `filemtime()`:

```php
$v = '?v=' . filemtime(__DIR__ . '/../../../public/css/app.css');
```

Aplicar lo mismo para el CSS de página y para el `<script src="/js/app.js">` en `footer.php`.

Esto garantiza que el navegador descarga la versión actualizada automáticamente cada vez que el archivo cambia en disco.

---

## Fase 9 — Nuevas funcionalidades

### Spec 9.1 · Tipos de interacción con scoring ponderado

Actualiza `data/interactions.php`: añade el campo `'type'` a todas las interacciones con uno de estos valores: `'reunion'`, `'llamada'`, `'email'`, `'evento'`. Elegir el valor según el contexto de cada interacción.

Actualiza `ScoreCalculator.php`:
- Reemplaza la constante `POINTS_PER_INTERACTION` por un array `POINTS_BY_TYPE`:
  ```php
  'reunion' => 15,
  'llamada' => 10,
  'email'   => 7,
  'evento'  => 5,
  ```
- Añade `POINTS_FALLBACK = 10` para tipos desconocidos o ausentes
- Cada item de breakdown de tipo `'interaction'` debe incluir el campo `'itype'` con el valor del tipo

Actualiza `src/Views/partials/breakdown.php` para mostrar un badge con el tipo de interacción en cada item de interacción directa. El badge usa las clases CSS `itype itype-{tipo}` con colores distintos por tipo.

Añade a `app.css` los estilos de `.itype` y `.itype-reunion`, `.itype-llamada`, `.itype-email`, `.itype-evento`.

---

### Spec 9.2 · Estado de la relación por empresa

Añade el campo `'stage'` a todas las empresas en `data/companies.php`. Valores posibles:
- `'prospectando'` — empresa en radar, sin contacto formal
- `'en_conversacion'` — hay conversaciones activas
- `'mandato'` — operación activa con mandato firmado
- `'cerrado'` — operación finalizada
- `'descartado'` — descartada

Propaga el campo `stage` a través de:
1. `CompanyInsightController` → incluirlo en `$companyMeta`
2. `UserInsightController::buildMeta()` → incluirlo en el array devuelto
3. `public/view.php` → mostrar badge de stage junto al contador de contactos en la cabecera de empresa
4. `public/users.php` → mostrar badge de stage junto al nombre de empresa en cada tarjeta

---

### Spec 9.3 · StageHelper

Crea `src/Helpers/StageHelper.php`.

Clase estática con:
- Constante privada `LABELS`: array que mapea cada valor de stage a su etiqueta en español
- `label(string $stage): string` — devuelve la etiqueta o el propio valor si no está en el mapa
- `cssClass(string $stage): string` — devuelve `'stage-' . str_replace('_', '-', $stage)`

Ambas páginas deben usar `StageHelper` en lugar de duplicar la lógica de etiquetas.

Añade a `app.css` los estilos de `.stage-badge` y cada variante de stage con sus colores correspondientes al nivel de la relación.

---

## Fase 10 — Calidad técnica

### Spec 10.1 · Tests unitarios con PHPUnit

Configura PHPUnit 11.x:

**`composer.json`**:
```json
{
  "require": { "php": ">=8.1" },
  "require-dev": { "phpunit/phpunit": "^11.0" },
  "autoload": {
    "psr-4": {
      "Controllers\\": "src/Controllers/",
      "Helpers\\": "src/Helpers/",
      "Repositories\\": "src/Repositories/",
      "Services\\": "src/Services/"
    }
  },
  "autoload-dev": { "psr-4": { "Tests\\": "tests/" } }
}
```

**`phpunit.xml`**: bootstrap `vendor/autoload.php`, colors, `failOnWarning="true"`, test suite apuntando a `tests/`.

Crea `tests/Services/ScoreCalculatorTest.php` con los siguientes grupos de prueba:

1. **Sin interacciones**: score=0, label='low', breakdown vacío; con contacto identificado: score=5
2. **Regla 1 por tipo**: un test por cada tipo (`reunion`=15, `llamada`=10, `email`=7, `evento`=5), tipo desconocido=10, tipo ausente=10; varios tipos acumulan correctamente
3. **Regla 2 recencia**: interacción hace 6 meses activa el bono +20; interacción hace 13 meses no lo activa; el bono aparece una sola vez con múltiples interacciones recientes
4. **Regla 3 multi-periodo**: interacciones en dos años distintos activan +15; todas en el mismo año no; el label del bonus menciona cuántos años
5. **Regla 4 contacto repetido**: mismo nombre en 2 interacciones activa +10; cada nombre solo una vez no lo activa; nombres vacíos ignorados
6. **Regla 5 contacto identificado**: string no vacío activa +5; string vacío no activa nada
7. **Etiquetas**: score<25='low', 25-49='medium', >=50='high'
8. **Estructura del breakdown**: cada item de interaction tiene las claves `type`, `points`, `date`, `itype`, `label`; cada item de bonus tiene `type`, `points`, `date`, `label`; el número total de items coincide con las reglas aplicadas

Todos los métodos de test deben tener nombre en español. La salida de `--testdox` debe ser legible en español.

---

### Spec 10.2 · Documentación de la API

Crea `public/api-docs.php` usando el sistema de parciales existente (`head.php`, `header.php`, `footer.php`). Activa el enlace "API Docs" en el header añadiéndolo a `header.php` y usando `$activePage = 'api-docs'`.

La página debe documentar:
- **GET `?domain={domain}`**: descripción, tabla de parámetros (nombre, tipo, requerido, descripción), ejemplo `curl`, ejemplo de respuesta JSON completa con todos los campos incluyendo `score_breakdown` con items de tipo interaction y bonus
- **GET `?id={id}`**: igual que el anterior pero por ID
- **GET `?stats=top_users`**: parámetros (`stats` requerido, `limit` opcional), ejemplo, respuesta
- **Reglas de scoring**: tabla con tipos de interacción y sus puntos, tabla con bonificaciones
- **Etiquetas de score**: tabla `high/medium/low` con rangos
- **Modelo de datos**: enum de `itype`, tabla de valores de `stage` con badge visual
- **Errores**: tabla de códigos HTTP 400, 404, 500 con condición y ejemplo de respuesta

Cada endpoint debe tener un enlace "Probar →" que abre la URL real en otra pestaña.

Crea `public/css/api-docs.css` con los estilos específicos de la página de documentación.

---

## Fase 11 — Pulido final

### Spec 11.1 · Comentarios en español

Revisa todos los archivos PHP y JS del proyecto (excluir `vendor/`). Cualquier comentario en inglés debe reescribirse en español. Los comentarios deben:
- Explicar el "por qué", no solo el "qué"
- Contextualizar en el dominio M&A cuando sea relevante
- En los docblocks de métodos complejos: describir el propósito de negocio, no solo la firma
- En `ScoreCalculatorTest.php`: todos los nombres de método deben estar en español para que la salida de `--testdox` sea legible

---

### Spec 11.2 · Comandos de test simplificados

Crea el script `test` en la raíz del proyecto (archivo PHP ejecutable):
- Ejecuta `vendor/bin/phpunit --testdox` capturando la salida
- Traduce la línea de resumen final:
  - `OK (N tests, M assertions)` → `OK (N pruebas realizadas, M comprobaciones pasadas)`
  - `FAILURES!\nTests: N, Assertions: M, Failures: F.` → versión en español
  - `ERRORS!\nTests: N, Assertions: M, Errors: E.` → versión en español
- Permite pasar argumentos adicionales: `php test tests/Services/ScoreCalculatorTest.php`

Añade el target `test` al `Makefile`:
```makefile
.PHONY: test
test:
	php test
```

`.PHONY` es necesario porque existe un archivo llamado `test` en el directorio raíz y sin él `make` confundiría el target con el archivo.

---

## Fase 12 — Refactorización de entrada

### Spec 12.1 · bootstrap.php — autoloader centralizado

Extrae el bloque `spl_autoload_register` repetido en todos los puntos de entrada públicos a un único archivo `bootstrap.php` en la raíz del proyecto.

Comportamiento:
- Si existe `vendor/autoload.php` (Composer instalado): cargarlo y no registrar nada más
- Si no existe: registrar un autoloader PSR-4 manual que resuelve `Namespace\Class` → `src/Namespace/Class.php`

Todos los archivos en `public/` que antes registraban el autoloader deben sustituir ese bloque por:
```php
require __DIR__ . '/../bootstrap.php';
```

---

## Fase 13 — Página de scoring

### Spec 13.1 · public/scoring.php

Crea `public/scoring.php` como página de documentación del modelo de puntuación. Usa el mismo layout de documentación que `api-docs.php` (`docs-layout`, `docs-sidebar`, `docs-main`).

Variables de plantilla: `$pageCss = 'scoring.css'`, `$activePage = 'scoring'`.

Índice lateral con cinco anclas: `#interacciones`, `#bonificaciones`, `#etiquetas`, `#ejemplo`, `#configuracion`.

Contenido de cada sección:
- **Interacciones**: grid 2×2 de tarjetas, una por tipo (`reunion`, `llamada`, `email`, `evento`), mostrando puntos, descripción y un ejemplo concreto con datos del demo. Nota de fallback para tipos sin registrar.
- **Bonificaciones**: cuatro tarjetas (`bonus-card`) con puntos, condición, justificación de negocio, ejemplo con bonus y ejemplo sin bonus.
- **Etiquetas**: tabla con `alto ≥ 50`, `medio 25–49`, `bajo 0–24` e interpretación operativa.
- **Ejemplo completo**: tabla completa Carlos ↔ Acme Ltd con todas las interacciones y bonificaciones reales del demo, mostrando la suma total.
- **Configuración**: bloque de código con las constantes de `ScoreCalculator.php`, nota de que los umbrales también están en `label()`.

### Spec 13.2 · public/css/scoring.css

Crea `public/css/scoring.css`. Debe empezar con `@import url('./api-docs.css');` para heredar todos los estilos del layout de documentación.

Estilos específicos: `.scoring-notice`, `.itype-grid` (grid 2 columnas, 1 en móvil), `.itype-card`, `.itype-card-top`, `.itype-points`, `.itype-desc`, `.itype-example`, `.fallback-note`, `.example-label` / `.example-label--no`, `.bonus-card`, `.bonus-header`, `.bonus-points`, `.bonus-title`, `.bonus-condition`, `.bonus-why`, `.bonus-example`, `.bonus-example--counter`, `.bonus-row` / `.total-row` / `.pts-cell`, `.try-btn`, `.config-note`.

---

## Fase 14 — Guía de instalación

### Spec 14.1 · public/docs/instalacion.php

Crea `public/docs/instalacion.php`. Variables de plantilla: `$pageCss = 'instalacion.css'`, `$activePage = 'instalacion'`. Usa paths de require con `__DIR__ . '/../../src/Views/partials/...'`.

Documenta tres métodos de instalación:
1. **Docker** (`make install`): prerrequisitos, comando único, URL de acceso
2. **PHP servidor built-in**: prerrequisitos, comando `php -S localhost:8080 -t public/`, sin dependencias externas
3. **Apache en Debian**: prerrequisitos (php8.3 + apache2 + mod_rewrite), configuración del VirtualHost, comandos de activación

Layout: `docs-layout` (sidebar + main) con índice lateral de tres anclas.

### Spec 14.2 · public/css/instalacion.css

Crea `public/css/instalacion.css`. Debe empezar con `@import url('./api-docs.css');`.

Estilos específicos: `.method-header`, `.method-badge`, `.prereq-list`, `.steps-list`, `.result-url`, `.info-banner`, `.warning-banner`.

---

## Fase 15 — Colección Postman

### Spec 15.1 · onetoone.postman_collection.json

Crea `onetoone.postman_collection.json` en la raíz del proyecto, formato Postman Collection v2.1.

Variable de entorno: `{{base_url}}` con valor por defecto `http://localhost:8080`.

Organizar en tres carpetas:
- **Consulta por empresa**: `?domain=` para cada empresa del demo + `?id=1`
- **Estadísticas de red**: `?stats=top_users` con distintos valores de `limit`
- **Casos de error**: domain inexistente, id inválido, sin parámetros (400), id negativo

---

## Fase 16 — Datos ampliados

### Spec 16.1 · Expansión de interacciones y web_content

Amplía `data/interactions.php` de 28 a 45 interacciones introduciendo nuevos `contact_name` por empresa:
- acme.com: añadir Sergio Martínez, Laura Chen
- globex.com: añadir Sophie Williams, Mark Harrison
- initech.io: añadir Rachel Green, Peter Gibbons
- umbrella.vc: añadir Ada Wong, Claire Redfield
- dundercapital.com: añadir Jim Halpert, Pam Beesly

Añadir los mismos contactos a `data/web_content.php` con cargos realistas (COO, CFO, Head of Strategy…) para que `ContactExtractor` pueda cruzarlos.

---

## Fase 17 — contact_name en el desglose

### Spec 17.1 · Mostrar contacto por interacción

Actualiza `ScoreCalculator::calculate()` para incluir `'contact_name' => $interaction['contact_name'] ?? null` en cada item de breakdown de tipo `'interaction'`.

Actualiza `src/Views/partials/breakdown.php` para mostrar el nombre del contacto cuando esté presente:
```php
<?php if (!empty($item['contact_name'])): ?>
  · <span class="breakdown-contact"><?= htmlspecialchars($item['contact_name']) ?></span>
<?php endif; ?>
```

Añade `.breakdown-contact { color: var(--text); font-weight: 500; }` a `app.css`.

### Spec 17.2 · Regla 5 — todos los contactos únicos

Reescribe la Regla 5 de `ScoreCalculator` para que en lugar de recibir un `$identifiedContact` externo, extraiga directamente todos los `contact_name` únicos y no vacíos del array `$interactions`. Suma +5 por cada contacto único encontrado:

```php
$identifiedNames = array_values(array_unique(array_filter(
    array_column($interactions, 'contact_name')
)));
foreach ($identifiedNames as $contactName) {
    $score += self::POINTS_CONTACT_IDENTIFIED;
    $breakdown[] = ['type' => 'bonus', 'points' => self::POINTS_CONTACT_IDENTIFIED, 'date' => null,
        'label' => sprintf('Contacto identificado: %s', $contactName)];
}
```

Eliminar el parámetro `$identifiedContact` de la firma del método. Actualizar llamadas en ambos controladores.

---

## Fase 18 — Internacionalización (i18n)

### Spec 18.1 · Catálogo de traducciones .po

Crea la estructura de directorios `locale/es_ES.UTF-8/LC_MESSAGES/` en la raíz del proyecto (fuera de `public/` por seguridad).

Crea `locale/es_ES.UTF-8/LC_MESSAGES/messages.po` con:
- Cabecera gettext estándar (Project-Id-Version, Language, charset UTF-8, Plural-Forms)
- Todas las cadenas visibles de los archivos PHP organizadas en secciones comentadas
- Enfoque passthrough: `msgid` y `msgstr` idénticos en español (el sistema está preparado para añadir otros idiomas sin refactorizar el código)

Instrucciones de compilación en el propio fichero:
```
msgfmt messages.po -o messages.mo
```

### Spec 18.2 · bootstrap.php — inicialización gettext

Amplía `bootstrap.php` para inicializar gettext tras el autoloader:

- Mapa `SUPPORTED_LOCALES`: `es → es_ES.UTF-8`, `en → en_GB.UTF-8`, `fr → fr_FR.UTF-8`, `de → de_DE.UTF-8`
- Leer idioma de `$_COOKIE['lang']`; fallback a `es` si no existe o no es válido
- `putenv("LANG=...")`, `setlocale(LC_ALL, ...)`, `bindtextdomain('messages', __DIR__ . '/locale')`, `textdomain('messages')`
- Definir constante `CURRENT_LANG` para las vistas

### Spec 18.3 · header.php — selector de idioma dinámico

Actualiza el `<select id="lang-select">` en `header.php` para generar las opciones con PHP, marcando como `selected` la opción que coincide con `CURRENT_LANG`.

### Spec 18.4 · app.js — cambio de idioma con cookie

Actualiza `initLangSelector()` en `app.js`: al cambiar el selector, establecer la cookie `lang=<código>` con `path=/; SameSite=Lax` y recargar la página para que PHP aplique el nuevo locale.

### Spec 18.5 · make pomo

Añade target `pomo` al `Makefile` que itera todos los `.po` en `locale/` y ejecuta `msgfmt` para generar los `.mo` correspondientes.
