Files
AgendaPro/docs/superpowers/specs/2026-07-26-auto-assign-specialist-booking-redesign-design.md
AgendaPro Dev d796538fd9 feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed
- Rebrand all user-facing text to AgendaMax
- Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0)
- Fix calendar toolbar hover: active buttons keep readable contrast
- Sticky calendar toolbar (month/week/day always visible on scroll)
- Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
2026-07-27 10:11:16 -06:00

191 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Auto-asignación de especialista + rediseño de la página de reservas
**Fecha:** 2026-07-26
**Estado:** Borrador para revisión del usuario
**Alcance:** Página pública de reservas (`/b/:slug`) + sección de Configuración + modelo de datos
## 1. Objetivo
1. Añadir en Configuración un check **"Asignar especialista automáticamente"** que, al activarse, **omite el paso de elección de especialista** para el cliente en la URL pública de reservas.
2. La asignación se resuelve con un **algoritmo matemático** que considera especialidad, eficiencia y —sobre todo— **disponibilidad**, garantizando que **un especialista nunca atienda a dos clientes a la vez** (sin traslape con citas ya agendadas).
3. En "Elige fecha y hora", reemplazar el `<input type="date">` nativo por un **calendario de mes siempre visible**.
4. En **escritorio**, dividir "Elige fecha y hora" en **dos columnas** (calendario + horarios) para aprovechar el ancho de pantalla; en móvil se mantiene el stack actual.
5. **Colores indicativos llamativos** (alto contraste, mayor tamaño) para ayudar a usuarios mayores de 50, **manteniendo** el estilo moderno/fresco de AgendaMax y **sin alterar** los bloques de horario actuales.
6. **Fix:** iconos de "Tus datos" (nombre/teléfono/correo) que se solapan con el placeholder gris.
## 2. No-objetivos (YAGNI)
- Pasarela de pago / depósitos (`require_deposit` queda sin aplicar).
- Política de cancelación automatizada (fuera de alcance aquí).
- Recurrencia de citas, descansos/almuerzos puntuales.
- Vista de disponibilidad tipo FullCalendar en el flujo público (se usa una grilla de mes propia, más simple y enfocada).
## 3. Arquitectura
Nuevo módulo **`server/lib/scheduling.ts`** con funciones puras y testeables que centralizan toda la "data science":
- `getWorkingHours(business, employee|null, date) → { start, end } | null` (resuelve horario: empleado → si no, negocio; si el día es `null`, fuera de servicio).
- `isAvailable(db, employeeId, startIso, endIso) → boolean` (verifica traslape contra citas `(scheduled, completed)`).
- `computeOccupiedMinutes(db, employeeId, date) → number` (para load balance).
- `specialtyMatch(employee, service) → 0..1`.
- `scoreCandidate(ctx) → { score, reasons }`.
- `autoAssign(db, { business, service, startIso, endIso }) → { employeeId, score, reasons } | null` (filtra por hard constraints, luego scorea).
Se descarta una versión SQL-pura (window functions): el scoring ponderado es más legible y mantenible en TypeScript y el número de especialistas por negocio es pequeño (cabe en memoria). El módulo es importado por `server/routes/booking.ts` y `server/routes/appointments.ts`.
## 4. Modelo de datos (migración V3 → V4)
Función `migrateV3ToV4` en `server/db.ts` (sigue el patrón de `migrateV2ToV3`).
### Tabla `businesses` — 2 columnas
| Columna | Tipo | Default | Notas |
|---|---|---|---|
| `auto_assign_specialist` | `INTEGER NOT NULL` | `0` | El check que omite el paso de especialista. |
| `working_hours` | `TEXT` (JSON) | ver backfill | Horario default del negocio por día. |
Formato JSON: `{"1":{"start":"09:00","end":"20:00"}, "2":{...}, ..., "6":null, "7":null}` (1=Lun … 7=Dom; `null` = cerrado).
### Tabla `employees` — 3 columnas
| Columna | Tipo | Default | Notas |
|---|---|---|---|
| `specialties` | `TEXT` (JSON array) | `'[]'` | Etiquetas: `["Corte","Barba"]`. Se comparan con `service.category` y tokens de `service.name`. |
| `working_hours` | `TEXT` (JSON) | `NULL` | Horario individual; `NULL` = hereda el del negocio. |
| `efficiency_score` | `REAL NOT NULL` | `50` | 0100. Lo captura el dueño. |
### Backfill (en la migración)
- `auto_assign_specialist = 0` para todos.
- `businesses.working_hours` = `{"1":{"start":"09:00","end":"20:00"},...,"5":{...},"6":null,"7":null}` (replica el `09:0020:00` LunVie actualmente hardcodeado en `booking.ts:57-58`).
- `employees.specialties = '[]'`, `employees.working_hours = NULL`, `employees.efficiency_score = 50`.
### Tipos (`shared/types.ts`)
- `Business` añade `auto_assign_specialist: boolean`, `working_hours: WorkingHours | null`.
- `Employee` añade `specialties: string[]`, `working_hours: WorkingHours | null`, `efficiency_score: number`.
- `WorkingHours` = `Record<1..7, { start: string; end: string } | null>`.
### Settings API (`server/routes/settings.ts`)
- La lista blanca (`settings.ts:8-20`) añade: `auto_assign_specialist`, `working_hours`.
## 5. Algoritmo de auto-asignación (la "data science")
Restricción dura + score ponderado, para una cita candidata (servicio `S`, intervalo `[t0, t1)`):
### 5.1 Hard constraints — candidato válido si:
1. El empleado está `active = 1`.
2. Ofrece el servicio: existe fila en `employee_services(employee_id, service_id)`.
3. `[t0, t1)` cae **dentro** de su horario laboral ese día (`getWorkingHours`). Si el día es cerrado → descartado.
4. **No traslapa** ninguna cita existente con `status IN ('scheduled','completed')`: para toda cita `c`, es falso que `t0 < c.end_at && t1 > c.start_at` (`isAvailable`).
→ Garantiza que **un especialista jamás atiende a dos personas a la vez**.
### 5.2 Score (0100) de cada candidato válido
```
score = 50 * specialtyMatch + 30 * efficiency + 20 * (1 - loadBalance)
```
- **specialtyMatch (0..1):** `1.0` si una etiqueta de `employee.specialties` coincide (normalizado: lowercase, sin acentos, match por token/subcadena) con `service.category` o un token de `service.name`; `0.5` si ofrece el servicio (paso 2) pero sin coincidencia de etiqueta.
- **efficiency (0..1):** `employee.efficiency_score / 100`.
- **loadBalance (0..1):** `min(1, ocupadoHoy / laborableHoy)`, donde `ocupadoHoy = computeOccupiedMinutes` (suma de duraciones de citas del día) y `laborableHoy = minutos del turno ese día`. A menor carga → mayor `(1 - loadBalance)` → premia reparto justo.
### 5.3 Desempate y resultado
- Mayor `score` gana. Empate → menor `id` (determinista).
- `autoAssign` devuelve `{ employeeId, score, reasons: string[] }` para auditoría/UI.
### 5.4 Pesos
`specialty 50 / efficiency 30 / load 20`. Disponibilidad es **restricción dura** (peso infinito): nunca se viola.
## 6. Guard anti double-booking (transaccional)
Hoy `POST /api/public/:slug/book` (`booking.ts:100-153`) y `POST /api/appointments` (`appointments.ts:75-167`) insertan **sin re-verificar** conflictos (race condition). Fix:
- Envolver la creación en una **transacción** de SQLite (`db.exec('BEGIN IMMEDIATE')` o el equivalente de `node:sqlite`).
- Antes del `INSERT`, llamar a `isAvailable(employeeId, t0, t1)`.
- Si ya no está libre → **409 Conflict** con `{ error: "ESE_HORARIO_OCUPADO" }`. El cliente público ve "Esa hora acaba de ocuparse, elige otra" y vuelve al paso de horario.
- En auto-asignación, `autoAssign` corre **dentro** de la misma transacción, sobre el estado ya bloqueado.
## 7. Flujo del wizard público (`src/pages/public/BookingPage.tsx`)
### 7.1 Pasos
- `auto_assign_specialist = ON`**3 pasos**: Servicio → **Horario** → Datos (sin paso Especialista).
- `OFF` → 4 pasos actuales, pero la card "Cualquiera" usa `autoAssign` (no más `LIMIT 1`).
### 7.2 Slots (`GET /api/public/:slug/slots`)
- Se mantiene la firma y la grilla de **30 min**, pero el rango ahora se lee de `getWorkingHours` (negocio/empleado) en vez del hardcodeado `09:0020:00`.
- Cuando se omite el especialista, los candidatos son **todos** los empleados que ofrecen el servicio; un slot se muestra si **al menos uno** está libre (unión de disponibilidades). La asignación concreta se resuelve al confirmar.
- El payload del slot puede incluir un flag opcional `hasMultiple` para mostrar "varios especialistas disponibles" (informativo).
### 7.3 Confirmación
- Muestra el especialista asignado: avatar/color, nombre, rol, y 12 `reasons` legibles (p. ej. "Especialista en Corte · alta eficiencia · disponible"). El cliente nunca queda sin saber a quién le tocó.
## 8. UI — `DateTimePicker` (Elige fecha y hora)
### 8.1 Calendario de mes siempre visible (`MonthCalendar`)
- Componente nuevo (sin librería externa). Grilla **LunDom**, header con mes/año + flechas (limite ±3 meses).
- Días pasados o fuera de horario: atenuados (`slate-100`, texto `slate-300`), no seleccionables.
- Fin de semana: color tenue (no bloqueado salvo que `working_hours` sea `null`).
- Día con disponibilidad (≥1 slot): **punto `accent`** (naranja AgendaMax) bajo el número.
- Día seleccionado: relleno `brand-500`, texto blanco, `ring-2 ring-brand-500/30`, `shadow-soft`.
- Tipografía base >=16px; celda táctil >=44px (accesibilidad 50+).
### 8.2 Dos columnas en escritorio
- En el paso Horario, `main` pasa de `max-w-2xl` a **`max-w-5xl`** (solo en este paso).
- `DateTimePicker`: `md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:gap-8`**calendario izquierda**, **tarjeta de horarios derecha**.
- Tarjeta de horarios: encabezado con la fecha elegida en grande + agrupación visual de slots por **Mañana / Tarde** (etiquetas con color `accent`/`brand` suave). **La grilla de bloques actual no se modifica** (`grid-cols-3 sm:grid-cols-4`, estilos seleccionado/disponible idénticos).
- En `<md` se conserva el stack vertical actual (aprobado por el usuario).
- `TopBar` (`max-w-3xl`) y `ActionBar` (`max-w-2xl`) se ensanchan a `max-w-5xl` para acompañar.
### 8.3 Colores indicativos (alta legibilidad, sin romper la marca)
- No se introducen colores nuevos estridentes; se **refuerza contraste y tamaño** y se usa `accent` como señal secundaria.
- Seleccionado: `bg-brand-500 text-white shadow-soft` + badge `Check` (más grande).
- Disponible: `border-slate-200 bg-white`, hover → `border-brand-400 bg-brand-50`.
- Indisponible: `bg-slate-100 text-slate-300` + ícono de candado sutil.
- Texto de etiquetas y campos >=16px; estado enfocado con `ring` más visible.
## 9. UI — Fix de iconos en "Tus datos" (y app-wide)
### 9.1 Causa raíz
`src/index.css:272-284`: `.input { padding: 0.55rem 0.75rem }` (shorthand) tiene la misma especificidad (0,1,0) que `.pl-9` de Tailwind. Como `@tailwind utilities;` se emite **antes** en el archivo, la regla `.input` (posterior) **gana** y fija `padding-left: 0.75rem` (12px), justo donde inicia el icono (`left-3` = 12px, ancho 16px → ocupa 1228px). El texto arranca en 12px → **solape**. Afecta a **9 inputs** de la app.
### 9.2 Fix
1. Envolver `.input`, `.select`, `.textarea` (y demás reglas de componente custom) en **`@layer components { … }`** en `src/index.css`. Así las utilidades del layer `utilities` (incluido `pl-*`) **siempre** ganan, independientemente del orden de fuente. Resuelve los 9 sitios a la vez.
2. En los 3 fields de "Tus datos" subir `pl-9`**`pl-10`** (icono `left-3` + 16px → texto a 40px, ~12px de holgura, mejor para 50+). Opcionalmente replicar `pl-10` en los otros 6 inputs con icono para consistencia.
## 10. UI — Configuración (`src/pages/SettingsPage.tsx`)
- Card **"Reservas online"** (`:64-101`): añade
- Check **"Asignar especialista automáticamente"** con helper "El cliente no elige especialista; lo asigna el sistema según especialidad, eficiencia y disponibilidad".
- Editor de **horario del negocio**: grilla 7 días, cada uno con switch activo + `start`/`end` (type=time). Persiste en `businesses.working_hours`.
- El listado de campos del mutation PATCH (`SettingsPage.tsx:28-49`) incluye los nuevos.
## 11. UI — Ficha de Especialista (edición de empleado)
- Vista de edición de empleado añade:
- **Especialidades** (input de chips/tags → array).
- **Horario laboral** (default: "Heredar del negocio"; si se edita, grilla 7 días como en el negocio).
- **Eficiencia** (slider 0100 con etiqueta descriptiva).
- Endpoints existentes `PATCH /api/employees/:id` se extienden con los 3 campos en la lista blanca.
## 12. Plan de pruebas
- **Unit (scheduling):** `isAvailable` (casos de traslape en bordes), `specialtyMatch` (acentos/case), `scoreCandidate` (monotocidad de cada factor), `autoAssign` (elige óptimo, desempate determinista, respeta horario, ninguno disponible → null).
- **Integración (rutas):** reservar la misma hora dos veces → la 2ª da 409; reservar con auto-assign activo asigna y no traspasa.
- **Build/estático:** `npm run typecheck` y `npm run lint` limpios.
- **E2E si aplica:** flujo público completo con check activo (3 pasos) y sin check (4 pasos).
## 13. Riesgos y mitigaciones
| Riesgo | Mitigación |
|---|---|
| Datos vacíos al migrar (sin especialidades/eficiencia) | Defaults neutros: `specialties=[]`, `efficiency=50`. El algoritmo degrada a disponibilidad + load balance. |
| Race condition de doble reserva | Transacción `BEGIN IMMEDIATE` + `isAvailable` dentro de la misma. |
| Performance con muchos especialistas | N por negocio es chico; peor caso O(N·slots·citas). Cache de `computeOccupiedMinutes` por (employee, date). |
| Cambio de `working_hours` rompe slots pasados | Solo afecta a fechas futuras; citas pasadas no se revalidan. |
| `@layer components` cambia cascada | Re-auditar visualmente inputs/selects existentes; los utilities ya pensados para ganar. |
## 14. Entregables (resumen de archivos)
- **Nuevos:** `server/lib/scheduling.ts`.
- **Migración:** `server/db.ts` (`migrateV3ToV4`).
- **Backend rutas:** `server/routes/booking.ts`, `server/routes/appointments.ts`, `server/routes/settings.ts`, `server/routes/employees.ts`.
- **Tipos:** `shared/types.ts`.
- **Frontend:** `src/pages/public/BookingPage.tsx` (MonthCalendar, layout 2 col, flujo 3/4 pasos, confirmación), `src/pages/SettingsPage.tsx`, ficha de empleado, `src/lib/publicApi.ts`, `src/lib/api.ts`.
- **Estilos:** `src/index.css` (`@layer components` + fix de padding).