Files
AgendaPro/docs/superpowers/specs/2026-07-26-auto-assign-specialist-booking-redesign-design.md
T
AgendaPro Dev 4227db0c8b docs: spec + implementation plan for specialist auto-assignment
Design doc (docs/superpowers/specs) and task-by-task implementation plan (docs/superpowers/plans) for the auto-assign + booking redesign.
2026-07-26 16:00:04 -06:00

14 KiB
Raw Blame History

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 AgendaPro 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 = ON3 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 AgendaPro) 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-8calendario 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-9pl-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).