Design doc (docs/superpowers/specs) and task-by-task implementation plan (docs/superpowers/plans) for the auto-assign + booking redesign.
14 KiB
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
- 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.
- 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).
- En "Elige fecha y hora", reemplazar el
<input type="date">nativo por un calendario de mes siempre visible. - 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.
- 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.
- 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_depositqueda 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 esnull, 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 |
0–100. Lo captura el dueño. |
Backfill (en la migración)
auto_assign_specialist = 0para todos.businesses.working_hours={"1":{"start":"09:00","end":"20:00"},...,"5":{...},"6":null,"7":null}(replica el09:00–20:00Lun–Vie actualmente hardcodeado enbooking.ts:57-58).employees.specialties = '[]',employees.working_hours = NULL,employees.efficiency_score = 50.
Tipos (shared/types.ts)
Businessañadeauto_assign_specialist: boolean,working_hours: WorkingHours | null.Employeeañadespecialties: 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:
- El empleado está
active = 1. - Ofrece el servicio: existe fila en
employee_services(employee_id, service_id). [t0, t1)cae dentro de su horario laboral ese día (getWorkingHours). Si el día es cerrado → descartado.- No traslapa ninguna cita existente con
status IN ('scheduled','completed'): para toda citac, es falso quet0 < c.end_at && t1 > c.start_at(isAvailable).
→ Garantiza que un especialista jamás atiende a dos personas a la vez.
5.2 Score (0–100) de cada candidato válido
score = 50 * specialtyMatch + 30 * efficiency + 20 * (1 - loadBalance)
- specialtyMatch (0..1):
1.0si una etiqueta deemployee.specialtiescoincide (normalizado: lowercase, sin acentos, match por token/subcadena) conservice.categoryo un token deservice.name;0.5si ofrece el servicio (paso 2) pero sin coincidencia de etiqueta. - efficiency (0..1):
employee.efficiency_score / 100. - loadBalance (0..1):
min(1, ocupadoHoy / laborableHoy), dondeocupadoHoy = computeOccupiedMinutes(suma de duraciones de citas del día) ylaborableHoy = minutos del turno ese día. A menor carga → mayor(1 - loadBalance)→ premia reparto justo.
5.3 Desempate y resultado
- Mayor
scoregana. Empate → menorid(determinista). autoAssigndevuelve{ 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 denode:sqlite). - Antes del
INSERT, llamar aisAvailable(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,
autoAssigncorre 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" usaautoAssign(no másLIMIT 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 hardcodeado09:00–20: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
hasMultiplepara mostrar "varios especialistas disponibles" (informativo).
7.3 Confirmación
- Muestra el especialista asignado: avatar/color, nombre, rol, y 1–2
reasonslegibles (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 Lun–Dom, header con mes/año + flechas ‹ › (limite ±3 meses).
- Días pasados o fuera de horario: atenuados (
slate-100, textoslate-300), no seleccionables. - Fin de semana: color tenue (no bloqueado salvo que
working_hoursseanull). - 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,
mainpasa demax-w-2xlamax-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/brandsuave). La grilla de bloques actual no se modifica (grid-cols-3 sm:grid-cols-4, estilos seleccionado/disponible idénticos). - En
<mdse conserva el stack vertical actual (aprobado por el usuario). TopBar(max-w-3xl) yActionBar(max-w-2xl) se ensanchan amax-w-5xlpara 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
accentcomo señal secundaria. - Seleccionado:
bg-brand-500 text-white shadow-soft+ badgeCheck(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
ringmá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 12–28px). El texto arranca en 12px → solape. Afecta a 9 inputs de la app.
9.2 Fix
- Envolver
.input,.select,.textarea(y demás reglas de componente custom) en@layer components { … }ensrc/index.css. Así las utilidades del layerutilities(incluidopl-*) siempre ganan, independientemente del orden de fuente. Resuelve los 9 sitios a la vez. - En los 3 fields de "Tus datos" subir
pl-9→pl-10(iconoleft-3+ 16px → texto a 40px, ~12px de holgura, mejor para 50+). Opcionalmente replicarpl-10en 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 enbusinesses.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 0–100 con etiqueta descriptiva).
- Endpoints existentes
PATCH /api/employees/:idse 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 typecheckynpm run lintlimpios. - 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).