Files
AgendaPro/docs/superpowers/plans/2026-07-26-auto-assign-specialist-booking-redesign.md

91 KiB
Raw Permalink Blame History

Auto-asignación de especialista + rediseño de reservas — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Añadir auto-asignación matemática de especialistas (sin doble agenda) a la página pública de reservas, con un check en Configuración que omite el paso del especialista, calendario de mes siempre visible, layout de dos columnas en escritorio, colores de alta legibilidad y fix de iconos solapados.

Architecture: Nuevo módulo server/lib/scheduling.ts (funciones puras + funciones con DB) que centraliza disponibilidad + scoring. Restricción dura de no-traslape + score 50·specialtyMatch + 30·efficiency + 20·(1loadBalance). Guard transaccional anti-race en la creación de citas. Frontend: nuevo MonthCalendar, DateTimePicker a 2 columnas en desktop, wizard de 3/4 pasos según el check, y captura de especialidades/horario/eficiencia en ajustes y ficha de empleado.

Tech Stack: React 18, TypeScript, Vite, Express, node:sqlite (DatabaseSync), react-query, Tailwind 3.4, lucide-react. Tests: node:test (built-in) vía tsx para unit; scripts .mjs HTTP para integración.

Global Constraints

  • Idioma/copy: todo el texto面向 usuario final en español (MX). Etiquetas exactas según el spec.
  • Sin nuevas dependencias npm: usar node:test + tsx (ya instalado) para unit tests. No agregar jest/vitest.
  • Stack DB: node:sqlite síncrono. Transacciones vía db.exec("BEGIN IMMEDIATE") / COMMIT / ROLLBACK.
  • Estilo: usar la paleta existente: brand (azul #3b66ff) primario, accent (naranja #f17616) secundario. Tailwind config ya define ambas escalas. Border radius .input = 0.65rem.
  • Formato horario (JSON): WorkingHoursMap = Record<1..7, {start:"HH:mm", end:"HH:mm"} | null> donde 1=Lun…7=Dom; null = cerrado.
  • Grid de slots: 30 min (inalterado). Límite 40 slots/día (inalterado).
  • No romper mobile: el stack vertical actual del paso Horario en <md se conserva.
  • No commitear sin pedirlo: la política del repo es no hacer commit salvo petición explícita. Cada tarea termina con un paso "Commit" opcional — solo ejecutarlo si el usuario lo pide; si no, dejar cambios sin commitear.

File Structure

Nuevos:

  • server/lib/scheduling.ts — funciones puras (parseWorkingHours, getWorkingHoursForDate, specialtyMatch, overlaps, hasConflict, scoreCandidate, isoDayOfWeek, isoDateStr, normalizeText, tokens) + funciones con DB (getCandidates, getExistingBusy, isAvailable, pickBestSlotEmployee, autoAssign, runInTransaction).
  • server/lib/scheduling.test.ts — unit tests de las funciones puras (node:test).
  • src/components/MonthCalendar.tsx — grilla de mes siempre visible.
  • server/scripts/booking-e2e.mjs — tests de integración HTTP de reservas (auto-asignación + doble-booking).

Modificados:

  • server/db.tsmigrateV3ToV4 (2 columnas en businesses, 3 en employees).
  • shared/types.tsBusiness.auto_assign_specialist, Business.working_hours, Employee.specialties/working_hours/efficiency_score, tipo WorkingHoursMap.
  • server/routes/booking.ts — slots lee working_hours, usa pickBestSlotEmployee; POST /book usa autoAssign + guard transaccional; expone auto_assign_specialist.
  • server/routes/appointments.ts — guard isAvailable en POST; autoAssign cuando no hay empleado.
  • server/routes/settings.ts — whitelist + selects con los campos nuevos.
  • server/routes/employees.ts — CRUD con specialties/working_hours/efficiency_score.
  • src/lib/publicApi.ts — tipos (auto_assign_specialist, employee_id opcional, reasons en BookResponse).
  • src/lib/api.ts — sin cambios de firma (usa any), solo documentado.
  • src/index.css — envolver .input/.select/.textarea en @layer components.
  • src/pages/public/BookingPage.tsx — wizard 3/4 pasos, DateTimePicker 2-col + MonthCalendar, agrupación Mañana/Tarde, DetailsForm pl-10, Confirmation con reasons.
  • src/pages/SettingsPage.tsx — check auto-assign + editor de horario del negocio.
  • src/pages/EmployeesPage.tsxEmployeeModal con especialidades, horario, eficiencia.
  • package.json — script test:unit.

Task 1: Migración V3→V4 + tipos compartidos

Files:

  • Modify: server/db.ts (añadir migrateV3ToV4 y llamarla en runMigrations)
  • Modify: shared/types.ts (Business, Employee, WorkingHoursMap)
  • Test: arranque del servidor + npm run typecheck

Interfaces:

  • Produces: columnas DB businesses.auto_assign_specialist, businesses.working_hours, employees.specialties, employees.working_hours, employees.efficiency_score; tipos TS WorkingHoursMap, y los campos en Business/Employee.

  • Step 1: Añadir migrateV3ToV4 en server/db.ts

Insertar antes de export function runMigrations() (línea ~225) la nueva función, y registrarla en runMigrations:

/** v4: auto-assign specialist, business & employee working hours, employee specialties/efficiency. */
function migrateV3ToV4() {
  if (getMeta("schema_version") >= "4") return;

  const bizCols: [string, string][] = [
    ["auto_assign_specialist", "INTEGER NOT NULL DEFAULT 0"],
    ["working_hours", "TEXT"],
  ];
  for (const [col, def] of bizCols) {
    if (!columnExists("businesses", col)) db.exec(`ALTER TABLE businesses ADD COLUMN ${col} ${def};`);
  }

  const empCols: [string, string][] = [
    ["specialties", "TEXT"],
    ["working_hours", "TEXT"],
    ["efficiency_score", "REAL NOT NULL DEFAULT 50"],
  ];
  for (const [col, def] of empCols) {
    if (!columnExists("employees", col)) db.exec(`ALTER TABLE employees ADD COLUMN ${col} ${def};`);
  }

  // Backfill business working_hours: Lun-Vie 09:00-20:00 (replica del hardcodeado anterior)
  const DEFAULT_WH = JSON.stringify({
    1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
    3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
    5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
  });
  const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL`).all() as { id: number }[];
  const upd = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`);
  for (const b of noWh) upd.run(DEFAULT_WH, b.id);

  setMeta("schema_version", "4");
}

Y en runMigrations() añadir la llamada (después de migrateV2ToV3();):

export function runMigrations() {
  db.exec(SCHEMA);
  migrateV1ToV2();
  migrateV2ToV3();
  migrateV3ToV4();
}
  • Step 2: Actualizar shared/types.ts

Añadir el tipo WorkingHoursMap y los campos. Reemplazar la interfaz Business (líneas 4-18) añadiendo antes de created_at?:

export interface Business {
  id: number;
  name: string;
  industry: string;
  currency: string;
  currency_symbol: string;
  phone: string | null;
  address: string | null;
  slug?: string | null;
  plan?: string;
  status?: string;
  template?: string | null;
  trial_ends_at?: string | null;
  booking_enabled?: number | boolean;
  cancel_window_hours?: number;
  cancel_penalty_pct?: number;
  require_deposit?: number | boolean;
  deposit_pct?: number;
  timezone?: string;
  auto_assign_specialist?: number | boolean;
  working_hours?: WorkingHoursMap | string | null;
  created_at?: string;
}

Añadir tras Business:

export type WorkingDay = { start: string; end: string };
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom

Y en Employee (líneas 30-43) añadir tras service_ids?:

  specialties?: string[];
  working_hours?: WorkingHoursMap | string | null;
  efficiency_score?: number;
  • Step 3: Verificar que el servidor arranca y migra

Run: npm run dev:server (iniciar y detener tras ver "migrations ok" o que el server escucha). Esperado: sin errores de SQL. La BD adquiere las columnas nuevas.

Comprobación rápida (PowerShell):

npm run typecheck

Esperado: 0 errores.

  • Step 4: Commit (opcional, solo si el usuario lo pide)
git add server/db.ts shared/types.ts
git commit -m "feat(db): v4 migration — auto-assign, working hours, specialties, efficiency"

Task 2: scheduling.ts — funciones puras + unit tests

Files:

  • Create: server/lib/scheduling.ts (solo la parte pura por ahora; las funciones con DB se añaden en Task 3)
  • Create: server/lib/scheduling.test.ts
  • Modify: package.json (añadir script test:unit)

Interfaces:

  • Produces: WorkingDay, WorkingHoursMap, BusyWindow, ScoreInput, parseWorkingHours, isoDayOfWeek, getWorkingHoursForDate, normalizeText, tokens, specialtyMatch, overlaps, hasConflict, scoreCandidate, isoDateStr, clamp01.

  • Step 1: Crear server/lib/scheduling.ts con las funciones puras

// server/lib/scheduling.ts
// Disponibilidad + scoring de especialistas. Las funciones puras de aquí
// se unit-testean en scheduling.test.ts; las que tocan la DB se añaden más abajo.

export interface WorkingDay { start: string; end: string; } // "HH:mm"
export type WorkingHoursMap = Record<number, WorkingDay | null>; // 1=Lun … 7=Dom

export interface BusyWindow { startMs: number; endMs: number; }
export interface ScoreInput {
  specialtyMatch: number; // 0..1
  efficiency: number;     // 0..1
  loadBalance: number;    // 0..1 (fracción de jornada ocupada)
}

export function clamp01(n: number): number {
  return Math.max(0, Math.min(1, n));
}

export function parseWorkingHours(json: string | null | undefined): WorkingHoursMap | null {
  if (!json) return null;
  try {
    const obj = JSON.parse(json);
    if (typeof obj !== "object" || obj === null) return null;
    const out: WorkingHoursMap = {};
    for (let d = 1; d <= 7; d++) {
      const v = obj[String(d)];
      if (v && typeof v.start === "string" && typeof v.end === "string") out[d] = { start: v.start, end: v.end };
      else out[d] = null;
    }
    return out;
  } catch {
    return null;
  }
}

/** JS getDay(): 0=Dom..6=Sáb → ISO 1=Lun..7=Dom. */
export function isoDayOfWeek(date: Date): number {
  const j = date.getDay();
  return j === 0 ? 7 : j;
}

export function isoDateStr(d: Date): string {
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}

/** Resuelve el horario (empleado tiene prioridad; si no, negocio) para esa fecha. Devuelve ms o null si es día cerrado. */
export function getWorkingHoursForDate(
  empWh: WorkingHoursMap | null,
  bizWh: WorkingHoursMap | null,
  date: Date
): { startMs: number; endMs: number } | null {
  const dow = isoDayOfWeek(date);
  const day = empWh?.[dow] ?? bizWh?.[dow] ?? null;
  if (!day) return null;
  const [sh, sm] = day.start.split(":").map(Number);
  const [eh, em] = day.end.split(":").map(Number);
  const startMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm).getTime();
  const endMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em).getTime();
  if (!isFinite(startMs) || !isFinite(endMs) || endMs <= startMs) return null;
  return { startMs, endMs };
}

export function normalizeText(s: string): string {
  return (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").trim();
}

export function tokens(s: string): string[] {
  return normalizeText(s).split(/[^a-z0-9]+/).filter(Boolean);
}

/**
 * 1.0 si una especialidad coincide con la categoría o un token del nombre del servicio.
 * 0.5 si no hay etiquetas o no coincide (el caller ya garantiza que ofrece el servicio).
 */
export function specialtyMatch(specialties: string[], service: { name: string; category: string }): number {
  if (!specialties || specialties.length === 0) return 0.5;
  const catN = normalizeText(service.category);
  const nameTokens = tokens(service.name);
  const hay = new Set<string>([catN, ...nameTokens]);
  for (const sp of specialties) {
    const spn = normalizeText(sp);
    if (!spn) continue;
    if (hay.has(spn)) return 1;
    if (catN && (catN.includes(spn) || spn.includes(catN))) return 1;
    for (const t of nameTokens) {
      if (t.length >= 4 && (t.includes(spn) || spn.includes(t))) return 1;
    }
  }
  return 0.5;
}

export function overlaps(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
  return aStart < bEnd && aEnd > bStart;
}

export function hasConflict(existing: BusyWindow[], startMs: number, endMs: number): boolean {
  return existing.some((b) => overlaps(startMs, endMs, b.startMs, b.endMs));
}

/** score = 50·specialtyMatch + 30·efficiency + 20·(1loadBalance), todo en [0,1]. Rango 0..100. */
export function scoreCandidate(s: ScoreInput): number {
  const sm = clamp01(s.specialtyMatch);
  const eff = clamp01(s.efficiency);
  const inv = clamp01(1 - clamp01(s.loadBalance));
  return 50 * sm + 30 * eff + 20 * inv;
}
  • Step 2: Crear server/lib/scheduling.test.ts
// server/lib/scheduling.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import {
  parseWorkingHours, isoDayOfWeek, getWorkingHoursForDate,
  normalizeText, tokens, specialtyMatch, overlaps, hasConflict, scoreCandidate,
} from "./scheduling.ts";

test("parseWorkingHours: json válido → mapa 1..7", () => {
  const m = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" }, 6: null, 7: null }));
  assert.equal(m?.[1]?.start, "09:00");
  assert.equal(m?.[6], null);
  assert.equal(m?.[7], null);
  assert.equal(m?.[2], null); // ausente → null
});

test("parseWorkingHours: null/invalid → null", () => {
  assert.equal(parseWorkingHours(null), null);
  assert.equal(parseWorkingHours("no-json"), null);
});

test("isoDayOfWeek: lunes=1, domingo=7", () => {
  assert.equal(isoDayOfWeek(new Date("2026-07-27T12:00:00")), 1); // lunes
  assert.equal(isoDayOfWeek(new Date("2026-07-26T12:00:00")), 7); // domingo
});

test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio", () => {
  const emp = parseWorkingHours(JSON.stringify({ 1: { start: "10:00", end: "14:00" } }));
  const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } }));
  const r = getWorkingHoursForDate(emp, biz, new Date("2026-07-27T12:00:00")); // lunes
  assert.equal(r?.startMs, new Date(2026, 6, 27, 10, 0).getTime());
});

test("getWorkingHoursForDate: día cerrado → null", () => {
  const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null })); // fin de semana cerrado
  assert.equal(getWorkingHoursForDate(null, biz, new Date("2026-07-25T12:00:00")), null); // sábado
});

test("normalizeText/tokens: quita acentos y lowercase", () => {
  assert.equal(normalizeText("Coloración"), "coloracion");
  assert.deepEqual(tokens("Corte de cabello"), ["corte", "de", "cabello"]);
});

test("specialtyMatch: coincidencia exacta de etiqueta → 1", () => {
  assert.equal(specialtyMatch(["corte"], { name: "Corte de cabello", category: "Cabello" }), 1);
});

test("specialtyMatch: etiqueta con acento/case → 1", () => {
  assert.equal(specialtyMatch(["Coloración"], { name: "Tinte", category: "Coloración" }), 1);
});

test("specialtyMatch: sin etiquetas → 0.5", () => {
  assert.equal(specialtyMatch([], { name: "Corte", category: "Cabello" }), 0.5);
});

test("specialtyMatch: etiqueta no relacionada → 0.5", () => {
  assert.equal(specialtyMatch(["barba"], { name: "Manicura", category: "Uñas" }), 0.5);
});

test("overlaps: bordes inclusivos de no-traslape", () => {
  // una cita termina exactamente cuando empieza otra → NO traslape
  assert.equal(overlaps(100, 200, 200, 300), false);
  // traslape real
  assert.equal(overlaps(100, 250, 200, 300), true);
  assert.equal(overlaps(200, 300, 100, 250), true);
});

test("hasConflict: lista vacía → false", () => {
  assert.equal(hasConflict([], 100, 200), false);
});

test("scoreCandidate: pesos 50/30/20", () => {
  // todo al máximo → 100
  assert.equal(scoreCandidate({ specialtyMatch: 1, efficiency: 1, loadBalance: 0 }), 100);
  // todo al mínimo → 0
  assert.equal(scoreCandidate({ specialtyMatch: 0, efficiency: 0, loadBalance: 1 }), 0);
  // sólo specialty (0.5) → 25
  assert.equal(scoreCandidate({ specialtyMatch: 0.5, efficiency: 0, loadBalance: 1 }), 25);
});

test("scoreCandidate: mayor efficiency → mayor score", () => {
  const low = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.2, loadBalance: 0.5 });
  const high = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.9, loadBalance: 0.5 });
  assert.ok(high > low);
});

test("scoreCandidate: menor load (más disponible) → mayor score", () => {
  const busy = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.9 });
  const free = scoreCandidate({ specialtyMatch: 0.5, efficiency: 0.5, loadBalance: 0.1 });
  assert.ok(free > busy);
});
  • Step 3: Añadir script test:unit en package.json

En la sección "scripts" añadir:

    "test:unit": "node --import tsx --test server/lib/scheduling.test.ts",
  • Step 4: Correr los tests (deben pasar)

Run: npm run test:unit Esperado: todos los tests PASS (≈14 tests).

  • Step 5: Commit (opcional)
git add server/lib/scheduling.ts server/lib/scheduling.test.ts package.json
git commit -m "feat(scheduling): pure availability + scoring helpers with unit tests"

Task 3: scheduling.ts — funciones con DB (isAvailable, autoAssign, pickBestSlotEmployee)

Files:

  • Modify: server/lib/scheduling.ts (añadir al final las funciones con DB)
  • Test: server/lib/scheduling.test.ts sigue verde; verificación de tipos.

Interfaces:

  • Consumes: tipos de Task 1/2.

  • Produces: CandidateInfo, getCandidates(db, businessId, serviceId), getExistingBusy(db, employeeIds, dateIso), isAvailable(db, employeeId, startMs, endMs), pickBestSlotEmployee(...), autoAssign(db, ctx), runInTransaction(db, fn).

  • Step 1: Añadir las funciones con DB al final de server/lib/scheduling.ts

import type { DatabaseSync } from "node:sqlite";

export interface CandidateInfo {
  id: number;
  name: string;
  color: string;
  role: string;
  specialties: string[];
  efficiency_score: number;
  empWh: WorkingHoursMap | null;
}

function safeArr(json: unknown): string[] {
  if (typeof json !== "string" || !json) return [];
  try {
    const a = JSON.parse(json);
    return Array.isArray(a) ? a.map(String) : [];
  } catch {
    return [];
  }
}

export function getCandidates(db: DatabaseSync, businessId: number, serviceId: number): CandidateInfo[] {
  const rows = db
    .prepare(
      `SELECT e.id, e.name, e.color, e.role, e.specialties, e.working_hours wh, e.efficiency_score
       FROM employees e
       JOIN employee_services es ON es.employee_id = e.id
       WHERE e.business_id = ? AND es.service_id = ? AND e.active = 1`
    )
    .all(businessId, serviceId) as any[];
  return rows.map((r) => ({
    id: r.id,
    name: r.name,
    color: r.color,
    role: r.role,
    specialties: safeArr(r.specialties),
    efficiency_score: typeof r.efficiency_score === "number" ? r.efficiency_score : 50,
    empWh: parseWorkingHours(r.wh),
  }));
}

export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
  const map = new Map<number, BusyWindow[]>();
  if (employeeIds.length === 0) return map;
  const startOfDay = `${dateIso}T00:00:00`;
  const endOfDay = `${dateIso}T23:59:59`;
  const rows = db
    .prepare(
      `SELECT employee_id, start_at, end_at FROM appointments
       WHERE employee_id IN (${employeeIds.map(() => "?").join(",")})
         AND status IN ('scheduled','completed') AND start_at >= ? AND start_at <= ?`
    )
    .all(...employeeIds, startOfDay, endOfDay) as any[];
  for (const r of rows) {
    const arr = map.get(r.employee_id) ?? [];
    arr.push({ startMs: new Date(r.start_at).getTime(), endMs: new Date(r.end_at).getTime() });
    map.set(r.employee_id, arr);
  }
  return map;
}

export function isAvailable(db: DatabaseSync, employeeId: number, startMs: number, endMs: number): boolean {
  const dateIso = isoDateStr(new Date(startMs));
  const busy = getExistingBusy(db, [employeeId], dateIso).get(employeeId) ?? [];
  return !hasConflict(busy, startMs, endMs);
}

interface RankedCandidate { info: CandidateInfo; score: number; reasons: string[] }

function rankOne(
  c: CandidateInfo,
  busy: BusyWindow[],
  wh: { startMs: number; endMs: number },
  service: { name: string; category: string }
): RankedCandidate | null {
  const workingMs = Math.max(1, wh.endMs - wh.startMs);
  let occupiedMs = 0;
  for (const b of busy) {
    occupiedMs += Math.min(b.endMs, wh.endMs) - Math.max(b.startMs, wh.startMs);
    if (occupiedMs < 0) occupiedMs = 0;
  }
  const loadBalance = clamp01(occupiedMs / workingMs);
  const sm = specialtyMatch(c.specialties, service);
  const score = scoreCandidate({ specialtyMatch: sm, efficiency: c.efficiency_score / 100, loadBalance });
  const reasons: string[] = [];
  if (sm >= 1) reasons.push("Especialidad coincidente");
  if (c.efficiency_score >= 75) reasons.push("Alta eficiencia");
  if (loadBalance <= 0.25) reasons.push("Buena disponibilidad");
  return { info: c, score, reasons };
}

/** Usado por el endpoint de slots: devuelve el mejor candidato libre para un slot concreto. */
export function pickBestSlotEmployee(
  candidates: CandidateInfo[],
  busyMap: Map<number, BusyWindow[]>,
  bizWh: WorkingHoursMap | null,
  service: { name: string; category: string },
  startMs: number,
  endMs: number
): { id: number; name: string } | null {
  const date = new Date(startMs);
  let best: { id: number; name: string; score: number } | null = null;
  for (const c of candidates) {
    const wh = getWorkingHoursForDate(c.empWh, bizWh, date);
    if (!wh) continue;
    if (startMs < wh.startMs || endMs > wh.endMs) continue;
    if (hasConflict(busyMap.get(c.id) ?? [], startMs, endMs)) continue;
    const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, service);
    if (!ranked) continue;
    if (!best || ranked.score > best.score || (ranked.score === best.score && c.id < best.id)) {
      best = { id: c.id, name: c.name, score: ranked.score };
    }
  }
  return best ? { id: best.id, name: best.name } : null;
}

export interface AutoAssignResult {
  employeeId: number;
  score: number;
  reasons: string[];
}

export interface AutoAssignCtx {
  businessId: number;
  serviceId: number;
  serviceName: string;
  serviceCategory: string;
  startMs: number;
  endMs: number;
  bizWh: WorkingHoursMap | null;
}

/** Asigna el mejor especialista disponible (hard constraints + score). Llamar dentro de transacción. */
export function autoAssign(db: DatabaseSync, ctx: AutoAssignCtx): AutoAssignResult | null {
  const candidates = getCandidates(db, ctx.businessId, ctx.serviceId);
  if (candidates.length === 0) return null;
  const dateIso = isoDateStr(new Date(ctx.startMs));
  const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso);
  let best: RankedCandidate | null = null;
  for (const c of candidates) {
    const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, new Date(ctx.startMs));
    if (!wh) continue;
    if (ctx.startMs < wh.startMs || ctx.endMs > wh.endMs) continue;
    if (hasConflict(busyMap.get(c.id) ?? [], ctx.startMs, ctx.endMs)) continue;
    const ranked = rankOne(c, busyMap.get(c.id) ?? [], wh, { name: ctx.serviceName, category: ctx.serviceCategory });
    if (!ranked) continue;
    if (!best || ranked.score > best.score || (ranked.score === best.score && c.info.id < best.info.id)) {
      best = ranked;
    }
  }
  if (!best) return null;
  return {
    employeeId: best.info.id,
    score: best.score,
    reasons: best.reasons.length ? best.reasons : ["Disponible"],
  };
}

/** Ejecuta fn dentro de una transacción BEGIN IMMEDIATE…COMMIT; ROLLBACK en error. */
export function runInTransaction<T>(db: DatabaseSync, fn: () => T): T {
  db.exec("BEGIN IMMEDIATE");
  try {
    const r = fn();
    db.exec("COMMIT");
    return r;
  } catch (err) {
    try { db.exec("ROLLBACK"); } catch { /* ignore */ }
    throw err;
  }
}
  • Step 2: Verificar tipos y tests

Run: npm run typecheck → 0 errores. Run: npm run test:unit → sigue verde.

  • Step 3: Commit (opcional)
git add server/lib/scheduling.ts
git commit -m "feat(scheduling): db-backed candidate ranking + autoAssign + tx helper"

Task 4: backend booking.ts — slots con working_hours + pickBestSlotEmployee

Files:

  • Modify: server/routes/booking.ts (endpoint GET /:slug/slots y publicBusiness)
  • Test: arranque + integración (Task 7 cubre el script HTTP).

Interfaces:

  • Consumes: getWorkingHoursForDate, pickBestSlotEmployee, getCandidates, getExistingBusy, parseWorkingHours, isoDateStr de server/lib/scheduling.ts.

  • Produces: publicBusiness() devuelve auto_assign_specialist y working_hours; slots respetan horario real.

  • Step 1: Importar scheduling en server/routes/booking.ts

Al inicio del archivo (tras import { db }):

import {
  parseWorkingHours, getWorkingHoursForDate, getCandidates, getExistingBusy,
  pickBestSlotEmployee, isoDateStr, type WorkingHoursMap,
} from "../lib/scheduling.ts";
  • Step 2: Exponer campos nuevos en publicBusiness

Reemplazar la función publicBusiness (líneas 6-14) por:

function publicBusiness(slug: string) {
  return db
    .prepare(
      `SELECT id, name, industry, currency, currency_symbol, phone, address, timezone,
              booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct,
              auto_assign_specialist, working_hours
       FROM businesses WHERE slug = ? AND status = 'active'`
    )
    .get(slug) as any;
}
  • Step 3: Reescribir el bloque de cálculo de slots (líneas 41-96)

Reemplazar desde // candidate employees (línea 41) hasta el final del endpoint (línea 96) por:

  // candidate employees
  let candidates: number[];
  if (employeeId) {
    const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(serviceId, employeeId);
    if (!ok) return res.status(400).json({ error: "El especialista no ofrece este servicio" });
    candidates = [employeeId];
  } else {
    candidates = db.prepare(`SELECT employee_id id FROM employee_services WHERE service_id = ?`).all(serviceId).map((r: any) => r.id);
    if (candidates.length === 0) {
      const any = db.prepare(`SELECT id FROM employees WHERE business_id = ? AND active = 1 LIMIT 1`).get(biz.id) as any;
      candidates = any ? [any.id] : [];
    }
  }
  if (candidates.length === 0) return res.json({ slots: [] });

  const bizWh = parseWorkingHours(biz.working_hours) as WorkingHoursMap | null;

  // Si el cliente no eligió especialista, usar el ranking para mostrar el mejor disponible por slot.
  const useRanking = !employeeId;
  const candInfos = useRanking ? getCandidates(db, biz.id, serviceId) : [];
  const candBusyMap = useRanking ? getExistingBusy(db, candInfos.map((c) => c.id), date) : new Map();

  // Para cuando sí se eligió especialista: ventana = horario de ese empleado (o negocio).
  const dateObj = new Date(`${date}T12:00:00`);
  const singleEmpRow = employeeId
    ? (db.prepare(`SELECT working_hours wh FROM employees WHERE id = ?`).get(employeeId) as any)
    : null;
  const singleEmpWh = singleEmpRow ? parseWorkingHours(singleEmpRow.wh) : null;
  const window = employeeId
    ? (getWorkingHoursForDate(singleEmpWh as WorkingHoursMap | null, bizWh, dateObj) ?? getWorkingHoursForDate(null, bizWh, dateObj))
    : getWorkingHoursForDate(null, bizWh, dateObj);

  const now = new Date();
  const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = [];
  const dur = service.duration_min;

  if (!window) {
    return res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
  }
  const dayStart = window.startMs;
  const dayEnd = window.endMs;

  // citas existentes del día para el caso de especialista fijo
  const existing = employeeId
    ? (getExistingBusy(db, [employeeId], date).get(employeeId) ?? [])
    : [];

  for (let t = dayStart; t + dur * 60000 <= dayEnd; t += 30 * 60000) {
    if (t < now.getTime() + 30 * 60000) continue; // pasado / demasiado pronto
    const slotStart = t;
    const slotEnd = t + dur * 60000;
    let chosen: { id: number; name: string } | null = null;
    if (useRanking) {
      chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd);
    } else {
      const busy = existing.some((b) => overlapsRange(slotStart, slotEnd, b.startMs, b.endMs));
      if (!busy) {
        const emp = db.prepare(`SELECT name FROM employees WHERE id = ?`).get(employeeId) as any;
        chosen = { id: employeeId, name: emp?.name ?? "" };
      }
    }
    if (chosen) {
      slots.push({
        time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }),
        iso: new Date(t).toISOString(),
        employee_id: chosen.id,
        employee_name: chosen.name,
      });
    }
    if (slots.length >= 40) break;
  }
  res.json({ slots, service: { id: service.id, name: service.name, price: service.price, duration_min: service.duration_min } });
}

function overlapsRange(aStart: number, aEnd: number, bStart: number, bEnd: number): boolean {
  return aStart < bEnd && aEnd > bStart;
}

Nota: overlapsRange es local para no cambiar imports; equivale a overlaps de scheduling. (Se puede reemplazar por el import si se prefiere.)

  • Step 4: Verificar tipos

Run: npm run typecheck → 0 errores.

  • Step 5: Commit (opcional)
git add server/routes/booking.ts
git commit -m "feat(booking): slots honor real working hours + rank best specialist per slot"

Task 5: backend booking.tsPOST /book con autoAssign + guard transaccional

Files:

  • Modify: server/routes/booking.ts (endpoint POST /:slug/book, líneas 100-153)

Interfaces:

  • Consumes: autoAssign, isAvailable, runInTransaction, parseWorkingHours, isoDateStr de scheduling.

  • Produces: POST /book responde 409 en conflicto; asigna vía algoritmo cuando employee_id ausente o cuando auto_assign_specialist=1; incluye reasons en la respuesta.

  • Step 1: Ampliar imports de scheduling

Añadir a la lista de imports de Task 4:

import { autoAssign, isAvailable, runInTransaction } from "../lib/scheduling.ts";
  • Step 2: Reescribir POST /:slug/book

Reemplazar todo el handler (líneas 100-153) por:

// Create a booking from the public site (no auth): creates/finds client + appointment
bookingRouter.post("/:slug/book", (req, res) => {
  const biz = publicBusiness(req.params.slug);
  if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" });
  const { service_id, employee_id, start_at, client } = req.body ?? {};
  if (!service_id || !start_at || !client?.name) return res.status(400).json({ error: "Faltan datos (servicio, hora o nombre)" });
  const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(Number(service_id), biz.id) as any;
  if (!service) return res.status(400).json({ error: "Servicio no válido" });
  const start = new Date(start_at);
  if (isNaN(start.getTime()) || start.getTime() < Date.now()) return res.status(400).json({ error: "Fecha inválida" });

  const startMs = start.getTime();
  const endMs = startMs + service.duration_min * 60000;

  // ¿El cliente forzó especialista? Si auto_assign_specialist=1, se ignora al cliente y se auto-asigna.
  const autoAssignOn = !!biz.auto_assign_specialist;
  const clientChose = !autoAssignOn && employee_id ? Number(employee_id) : null;

  try {
    const result = runInTransaction(db, () => {
      // 1) Resolver especialista
      let empId: number | null = clientChose;
      let reasons: string[] = [];
      if (!empId) {
        const bizWh = parseWorkingHours(biz.working_hours) as any;
        const aa = autoAssign(db, {
          businessId: biz.id, serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
          startMs, endMs, bizWh,
        });
        if (!aa) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
        empId = aa.employeeId;
        reasons = aa.reasons;
      } else {
        // validar que ofrece el servicio
        const ok = db.prepare(`SELECT 1 FROM employee_services WHERE service_id = ? AND employee_id = ?`).get(service.id, empId);
        if (!ok) throw { status: 400, error: "El especialista no ofrece este servicio" };
        // guard anti doble reserva
        if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "ESE_HORARIO_OCUPADO" };
        reasons = ["Tu especialista elegido"];
      }

      // 2) Resolver cliente (por phone/email o crear)
      let clientId: number | null = null;
      const match = client.phone
        ? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND phone = ?`).get(biz.id, client.phone) as any)
        : client.email
        ? (db.prepare(`SELECT id FROM clients WHERE business_id = ? AND email = ?`).get(biz.id, client.email) as any)
        : null;
      if (match) clientId = match.id;
      else {
        const created = db
          .prepare(`INSERT INTO clients (business_id, name, email, phone, tags) VALUES (?, ?, ?, ?, 'Online') RETURNING id`)
          .get(biz.id, client.name, client.email || null, client.phone || null) as any;
        clientId = created.id;
      }

      const endIso = new Date(endMs).toISOString().replace(/\.\d{3}Z$/, "Z");
      const startIso = new Date(startMs).toISOString().replace(/\.\d{3}Z$/, "Z");
      const appt = db
        .prepare(
          `INSERT INTO appointments (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes)
           VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?, ?) RETURNING *`
        )
        .get(biz.id, Number(service_id), empId, clientId, startIso, endIso, service.price, client.notes || "Reserva online") as any;

      const empName = (db.prepare(`SELECT name FROM employees WHERE id = ?`).get(empId) as any)?.name;
      return { appt, empId: empId as number, empName, reasons, clientCreated: !match };
    });

    res.status(201).json({
      appointment: {
        id: result.appt.id,
        start_at: result.appt.start_at,
        end_at: result.appt.end_at,
        price: result.appt.price,
        service_name: service.name,
        employee_id: result.empId,
        employee_name: result.empName,
        reasons: result.reasons,
      },
      business: { name: biz.name, currency_symbol: biz.currency_symbol },
      client_created: result.clientCreated,
    });
  } catch (e: any) {
    if (e && typeof e === "object" && e.status) {
      return res.status(e.status).json({ error: e.error === "ESE_HORARIO_OCUPADO" ? "Esa hora acaba de ocuparse, elige otra." : e.error });
    }
    throw e;
  }
});
  • Step 3: Verificar tipos

Run: npm run typecheck → 0 errores.

  • Step 4: Commit (opcional)
git add server/routes/booking.ts
git commit -m "feat(booking): auto-assign specialist + transactional double-booking guard (409)"

Task 6: backend appointments.ts — guard anti doble reserva

Files:

  • Modify: server/routes/appointments.ts (handler POST /, líneas 75-167)

Interfaces:

  • Consumes: isAvailable, autoAssign, runInTransaction, parseWorkingHours de scheduling.

  • Step 1: Importar scheduling al inicio de appointments.ts

import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
  • Step 2: Añadir guard dentro del POST /

Localizar el bloque que calcula endIso/startIso (líneas 129-134) e insertar, justo antes del INSERT (línea 139), el guard envolviendo en transacción. Reemplazar las líneas 129-167 (desde const start = new Date(start_at); hasta el final del handler) por:

  const start = new Date(start_at);
  if (isNaN(start.getTime())) return err(res, 400, "Fecha de inicio inválida");
  const dur = Number(duration_min) || service.duration_min;
  const end = new Date(start.getTime() + dur * 60000);
  const endIso = end.toISOString().replace(/\.\d{3}Z$/, "Z");
  const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
  const startMs = start.getTime();
  const endMs = end.getTime();

  const finalStatus = status || "scheduled";
  const price = price_override !== undefined ? Number(price_override) : service.price;

  try {
    const r = runInTransaction(db, () => {
      // Resolver empleado (igual que antes, pero usando autoAssign si ninguno)
      let empId = employee_id ? Number(employee_id) : null;
      if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
        empId = req.user!.employee_id;
      }
      if (!empId) {
        const bizRow = db.prepare(`SELECT working_hours wh FROM businesses WHERE id = ?`).get(req.user!.business_id) as any;
        const aa = autoAssign(db, {
          businessId: req.user!.business_id, serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
          startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any,
        });
        empId = aa?.employeeId ?? null;
      }
      if (!empId) throw { status: 400, error: "No hay empleado asignado a este servicio" };

      // Guard anti doble reserva
      if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };

      const inserted = db
        .prepare(
          `INSERT INTO appointments
            (business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
        )
        .get(req.user!.business_id, Number(service_id), empId, clientId, startIso, endIso, finalStatus, price, notes || null, req.user!.id) as any;

      if (finalStatus === "completed") {
        db.prepare(
          `INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method)
           VALUES (?, ?, ?, ?, ?, ?, 0, 'card')`
        ).run(req.user!.business_id, inserted.id, clientId, empId, Number(service_id), price);
      }
      joinAppointment(inserted);
      return inserted;
    });
    res.status(201).json({ appointment: r });
  } catch (e: any) {
    if (e && typeof e === "object" && e.status) return err(res, e.status, e.error);
    throw e;
  }
});
  • Step 3: Verificar tipos

Run: npm run typecheck → 0 errores.

  • Step 4: Commit (opcional)
git add server/routes/appointments.ts
git commit -m "feat(appointments): transactional conflict guard + auto-assign fallback"

Task 7: backend settings + employees (campos nuevos) + script de integración

Files:

  • Modify: server/routes/settings.ts (FIELDS + SELECTs)

  • Modify: server/routes/employees.ts (POST/PATCH + attachServices)

  • Create: server/scripts/booking-e2e.mjs

  • Modify: package.json (script test:booking)

  • Step 1: settings.ts — ampliar FIELDS y SELECTs

En FIELDS (líneas 8-20) añadir "auto_assign_specialist" y "working_hours":

const FIELDS = [
  "name", "industry", "phone", "address", "slug",
  "booking_enabled", "cancel_window_hours", "cancel_penalty_pct",
  "require_deposit", "deposit_pct", "timezone",
  "auto_assign_specialist", "working_hours",
] as const;

Añadir los campos a las dos SELECT del archivo. En GET / (líneas 24-28) y en la SELECT final del PATCH / (líneas 53-54), añadir , auto_assign_specialist, working_hours a la lista de columnas.

GET /:

  const b = db
    .prepare(
      `SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled,
              cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone,
              auto_assign_specialist, working_hours
       FROM businesses WHERE id = ?`
    )
    .get(req.user!.business_id) as any;

PATCH (SELECT final):

  const updated = db
    .prepare(`SELECT id, name, industry, currency, currency_symbol, phone, address, slug, booking_enabled, cancel_window_hours, cancel_penalty_pct, require_deposit, deposit_pct, timezone, auto_assign_specialist, working_hours FROM businesses WHERE id = ?`)
    .get(req.user!.business_id);

Además, en PATCH, serializar working_hours a JSON si viene como objeto. Tras el bucle for (const f of FIELDS) (antes del check de slug), añadir:

  if (merged.working_hours && typeof merged.working_hours !== "string") {
    merged.working_hours = JSON.stringify(merged.working_hours);
  }
  if (merged.auto_assign_specialist !== undefined) {
    merged.auto_assign_specialist = merged.auto_assign_specialist ? 1 : 0;
  }
  • Step 2: employees.ts — añadir campos en POST y PATCH

En POST / (línea 76) ampliar destructuring y el INSERT:

employeesRouter.post("/", ownerOnly, (req: AuthedRequest, res) => {
  const { name, role, email, phone, color, service_ids, specialties, working_hours, efficiency_score } = req.body ?? {};
  if (!name) return err(res, 400, "El nombre es obligatorio");
  const r = db
    .prepare(
      `INSERT INTO employees (business_id, name, role, email, phone, color, specialties, working_hours, efficiency_score)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
    )
    .get(
      req.user!.business_id,
      name,
      role || "Especialista",
      email || null,
      phone || null,
      color || "#3b66ff",
      JSON.stringify(Array.isArray(specialties) ? specialties : []),
      working_hours ? (typeof working_hours === "string" ? working_hours : JSON.stringify(working_hours)) : null,
      typeof efficiency_score === "number" ? efficiency_score : 50
    ) as any;
  if (Array.isArray(service_ids)) {
    const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
    for (const sid of service_ids) ins.run(r.id, Number(sid));
  }
  attachServices(r);
  r.stats = computeStats(r.id, req.user!.business_id as number);
  res.status(201).json({ employee: r });
});

En PATCH /:id (líneas 99-126), ampliar destructuring y el UPDATE. Reemplazar el cuerpo por:

employeesRouter.patch("/:id", ownerOnly, (req: AuthedRequest, res) => {
  const id = Number(req.params.id);
  const existing = db.prepare(`SELECT * FROM employees WHERE id = ? AND business_id = ?`).get(id, req.user!.business_id as number) as any;
  if (!existing) return err(res, 404, "Empleado no encontrado");
  const { name, role, email, phone, color, active, service_ids, specialties, working_hours, efficiency_score } = req.body ?? {};
  const merged = {
    name: name ?? existing.name,
    role: role ?? existing.role,
    email: email ?? existing.email,
    phone: phone ?? existing.phone,
    color: color ?? existing.color,
    active: active === undefined ? existing.active : active ? 1 : 0,
    specialties: specialties !== undefined
      ? (typeof specialties === "string" ? specialties : JSON.stringify(Array.isArray(specialties) ? specialties : []))
      : existing.specialties,
    working_hours: working_hours !== undefined
      ? (working_hours === null ? null : typeof working_hours === "string" ? working_hours : JSON.stringify(working_hours))
      : existing.working_hours,
    efficiency_score: efficiency_score !== undefined ? Number(efficiency_score) : (existing.efficiency_score ?? 50),
  };
  db.prepare(
    `UPDATE employees SET name = ?, role = ?, email = ?, phone = ?, color = ?, active = ?, specialties = ?, working_hours = ?, efficiency_score = ? WHERE id = ?`
  ).run(merged.name, merged.role, merged.email, merged.phone, merged.color, merged.active, merged.specialties, merged.working_hours, merged.efficiency_score, id);
  if (Array.isArray(service_ids)) {
    db.prepare(`DELETE FROM employee_services WHERE employee_id = ?`).run(id);
    const ins = db.prepare(`INSERT OR IGNORE INTO employee_services (employee_id, service_id) VALUES (?, ?)`);
    for (const sid of service_ids) ins.run(id, Number(sid));
  }
  const updated = db.prepare(`SELECT * FROM employees WHERE id = ?`).get(id) as any;
  attachServices(updated);
  updated.stats = computeStats(updated.id, req.user!.business_id as number);
  res.json({ employee: updated });
});
  • Step 3: Crear server/scripts/booking-e2e.mjs
// server/scripts/booking-e2e.mjs — requiere el server dev corriendo (npm run dev)
const BASE = "http://localhost:5173/api";
let pass = 0, fail = 0;
async function req(method, path, body, token) {
  const headers = { "Content-Type": "application/json" };
  if (token) headers.authorization = `Bearer ${token}`;
  const res = await fetch(`${BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
  const text = await res.text();
  let json; try { json = JSON.parse(text); } catch { json = text; }
  return { status: res.status, json };
}
function check(name, cond, extra = "") {
  if (cond) { pass++; console.log(`  \u2713 ${name}`); }
  else { fail++; console.log(`  \u2717 ${name} ${extra}`); }
}

const { json: login } = await req("POST", "/auth/login", { email: "[email protected]", password: "demo1234" });
const t = login.token;
check("login owner", !!t);

// 1) Slug del negocio
const { json: settings } = await req("GET", "/settings", null, t);
const slug = settings.settings?.slug;
check("tiene slug", !!slug, String(slug));

// 2) Datos públicos + servicios
const { json: pub } = await req("GET", `/public/${slug}`);
check("public business", !!pub.business && Array.isArray(pub.services) && pub.services.length > 0);
const svc = pub.services[0];
const tomorrow = new Date(Date.now() + 86400000);
const date = tomorrow.toISOString().slice(0, 10);

// 3) Slots disponibles
const { json: slotsRes } = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${date}`);
check("slots devuelve lista", Array.isArray(slotsRes.slots));
check("hay slots disponibles hoy/mañana", slotsRes.slots.length > 0, `got ${slotsRes.slots.length}`);

// 4) Reservar (auto-asignación, sin employee_id)
const slot = slotsRes.slots[0];
const { status: bookStatus, json: booked } = await req("POST", `/public/${slug}/book`, {
  service_id: svc.id,
  start_at: slot.iso,
  client: { name: "Cliente Prueba Auto", phone: "+525500000001" },
});
check("reserva creada (auto-assign)", bookStatus === 201 && !!booked.appointment?.employee_id, `status=${bookStatus}`);
check("response trae reasons", Array.isArray(booked.appointment?.reasons) && booked.appointment.reasons.length > 0);

// 5) Mismo slot otra vez → 409 (doble reserva del mismo especialista)
const { status: conflict } = await req("POST", `/public/${slug}/book`, {
  service_id: svc.id,
  employee_id: booked.appointment.employee_id,
  start_at: slot.iso,
  client: { name: "Cliente Conflictivo", phone: "+525500000002" },
});
check("doble reserva → 409", conflict === 409, `status=${conflict}`);

// 6) Activar auto_assign_specialist y verificar que la respuesta pública lo expone
await req("PATCH", "/settings", { auto_assign_specialist: 1 }, t);
const { json: pub2 } = await req("GET", `/public/${slug}`);
check("auto_assign_specialist expuesto en público", pub2.business?.auto_assign_specialist === 1);
await req("PATCH", "/settings", { auto_assign_specialist: 0 }, t); // cleanup

console.log(`\n${pass}/${pass + fail} passed${fail ? `, ${fail} FAILED` : ""}`);
process.exit(fail ? 1 : 0);
  • Step 4: Añadir script en package.json
    "test:booking": "node server/scripts/booking-e2e.mjs",
  • Step 5: Correr la integración (con el server levantado)

Run (en otra terminal): npm run dev Run: npm run test:booking Esperado: todos PASS. También correr npm run test:e2e para confirmar que no se rompió nada existente (puede haber un falil si la cita del seed choca — ver nota).

Nota de seed: los tests e2e existentes crean citas en 2026-09-15T11:00:00Z etc. El nuevo guard NO debe afectarlos salvo duplicidad exacta. Si test:e2e falla por la cita ya existente de una corrida anterior, ejecutar con RESET_DB=1 npm run seed antes.

  • Step 6: Commit (opcional)
git add server/routes/settings.ts server/routes/employees.ts server/scripts/booking-e2e.mjs package.json
git commit -m "feat(settings,employees): expose auto-assign + working hours + specialties; add booking e2e"

Task 8: Fix de iconos solapados (CSS @layer components + pl-10)

Files:

  • Modify: src/index.css (líneas 272-290)
  • Modify: src/pages/public/BookingPage.tsx (DetailsForm, líneas 664, 678, 692: pl-9pl-10)

Interfaces: ninguno (sólo estilos).

  • Step 1: Envolver .input/.select/.textarea en @layer components

En src/index.css, reemplazar el bloque de líneas 272-290:

.input,
.select,
.textarea {
  width: 100%;
  background: #ffffff;
  border: 1px solid #e5e7eb;
  border-radius: 0.65rem;
  padding: 0.55rem 0.75rem;
  font-size: 0.875rem;
  color: #0f172a;
  transition: border-color 0.15s, box-shadow 0.15s;
  outline: none;
}
.input:focus,
.select:focus,
.textarea:focus {
  border-color: #3b66ff;
  box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
}

por:

@layer components {
  .input,
  .select,
  .textarea {
    width: 100%;
    background: #ffffff;
    border: 1px solid #e5e7eb;
    border-radius: 0.65rem;
    padding: 0.55rem 0.75rem;
    font-size: 0.875rem;
    color: #0f172a;
    transition: border-color 0.15s, box-shadow 0.15s;
    outline: none;
  }
  .input:focus,
  .select:focus,
  .textarea:focus {
    border-color: #3b66ff;
    box-shadow: 0 0 0 3px rgba(59, 102, 255, 0.15);
  }
}

Así las utilidades pl-*/pr-* del layer utilities siempre ganan sobre el padding del componente.

  • Step 2: Bump pl-9pl-10 en DetailsForm

En src/pages/public/BookingPage.tsx, en la función DetailsForm (líneas 664, 678, 692), cambiar las tres ocurrencias de className="input pl-9" por className="input pl-10". (Tres inputs: Nombre, Teléfono, Correo.)

  • Step 3: Verificar visualmente

Run: npm run dev → abrir /b/<slug> → avanzar a "Tus datos". El icono ya no se solapa con el placeholder (texto arranca a 40px, icono ocupa 1228px). Confirmar en al menos otro input con icono (p. ej. LoginPage) que el padding lateral también se respeta.

  • Step 4: Commit (opcional)
git add src/index.css src/pages/public/BookingPage.tsx
git commit -m "fix(ui): wrap inputs in @layer components so pl-* wins; bump icon padding (pl-10)"

Task 9: publicApi.ts — tipos actualizados

Files:

  • Modify: src/lib/publicApi.ts

Interfaces:

  • Produces: PublicBusiness.auto_assign_specialist, BookPayload.employee_id opcional, BookResponse.appointment.employee_id/reasons.

  • Step 1: Ampliar interfaces

En PublicBusiness (líneas 3-15) añadir:

  auto_assign_specialist: number | boolean;

En BookPayload (líneas 59-64) hacer employee_id opcional:

export interface BookPayload {
  service_id: number;
  employee_id?: number;
  start_at: string;
  client: BookClient;
}

En BookResponse.appointment (líneas 66-76) añadir employee_id y reasons:

export interface BookResponse {
  appointment: {
    id: number;
    start_at: string;
    price: number;
    service_name: string;
    employee_id: number;
    employee_name: string;
    reasons: string[];
  };
  business: { name: string; currency_symbol: string };
  client_created: boolean;
}
  • Step 2: Verificar tipos

Run: npm run typecheck → 0 errores. (Puede marcar errores en BookingPage.tsx por el employee_id ahora requerido en el slot — se resuelve en Task 12. Si aparece, continuar; Task 12 lo corrige.)

  • Step 3: Commit (opcional)
git add src/lib/publicApi.ts
git commit -m "feat(types): public booking types for auto-assign + reasons"

Task 10: Componente MonthCalendar

Files:

  • Create: src/components/MonthCalendar.tsx

Interfaces:

  • Produces: MonthCalendar({ value, min, max, availableDates, onSelect }).

  • Step 1: Crear el componente

// src/components/MonthCalendar.tsx
import { useMemo, useState } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "../lib/format";

const WD = ["Lun", "Mar", "Mié", "Jue", "Vie", "Sáb", "Dom"];
const MONTHS = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];

function toIso(d: Date): string {
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function isoToDate(iso: string): Date {
  const [y, m, d] = iso.split("-").map(Number);
  return new Date(y, (m || 1) - 1, d || 1);
}
function atMidnight(d: Date): Date {
  return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}

export function MonthCalendar({
  value,
  min,
  max,
  availableDates,
  onSelect,
}: {
  value: string;
  min?: string;
  max?: string;
  availableDates?: Set<string>;
  onSelect: (iso: string) => void;
}) {
  const initial = value ? isoToDate(value) : new Date();
  const [cursor, setCursor] = useState(new Date(initial.getFullYear(), initial.getMonth(), 1));
  const todayIso = toIso(new Date());
  const minDate = min ? atMidnight(isoToDate(min)) : null;
  const maxDate = max ? atMidnight(isoToDate(max)) : null;

  const cells = useMemo(() => {
    const first = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
    const lead = (first.getDay() + 6) % 7; // Lun primero
    const start = new Date(first);
    start.setDate(first.getDate() - lead);
    const out: Date[] = [];
    for (let i = 0; i < 42; i++) {
      const d = new Date(start);
      d.setDate(start.getDate() + i);
      out.push(d);
    }
    return out;
  }, [cursor]);

  const inMonth = (d: Date) => d.getMonth() === cursor.getMonth();
  const selectable = (d: Date) => {
    const dm = atMidnight(d);
    if (minDate && dm < minDate) return false;
    if (maxDate && dm > maxDate) return false;
    return true;
  };
  const move = (delta: number) => setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + delta, 1));
  const firstOfMonth = new Date(cursor.getFullYear(), cursor.getMonth(), 1);
  const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= firstOfMonth;
  const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0);

  return (
    <div className="select-none">
      <div className="mb-3 flex items-center justify-between">
        <button type="button" onClick={() => move(-1)} disabled={!canPrev}
          className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
          aria-label="Mes anterior">
          <ChevronLeft className="h-4 w-4" />
        </button>
        <div className="text-sm font-extrabold text-slate-800">{MONTHS[cursor.getMonth()]} {cursor.getFullYear()}</div>
        <button type="button" onClick={() => move(1)} disabled={!canNext}
          className="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 text-slate-600 transition-colors hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
          aria-label="Mes siguiente">
          <ChevronRight className="h-4 w-4" />
        </button>
      </div>
      <div className="mb-1 grid grid-cols-7 gap-1">
        {WD.map((w) => (
          <div key={w} className="text-center text-[11px] font-bold uppercase tracking-wide text-slate-400">{w}</div>
        ))}
      </div>
      <div className="grid grid-cols-7 gap-1">
        {cells.map((d, i) => {
          const dIso = toIso(d);
          const sel = dIso === value;
          const on = selectable(d);
          const inM = inMonth(d);
          const isToday = dIso === todayIso;
          const hasAvail = availableDates?.has(dIso);
          const weekend = d.getDay() === 0 || d.getDay() === 6;
          return (
            <button key={i} type="button" disabled={!on} onClick={() => onSelect(dIso)}
              className={cn(
                "relative flex aspect-square items-center justify-center rounded-xl text-sm font-bold transition-all",
                !inM && "text-slate-300",
                inM && !sel && on && "text-slate-700 hover:bg-brand-50 hover:text-brand-700",
                inM && !on && "cursor-not-allowed text-slate-300",
                sel && "bg-brand-500 text-white shadow-soft ring-2 ring-brand-500/30",
                weekend && inM && !sel && on && "text-slate-500"
              )}>
              {d.getDate()}
              {hasAvail && !sel && (
                <span className="absolute bottom-1 h-1.5 w-1.5 rounded-full bg-accent-500" aria-hidden />
              )}
              {isToday && !sel && (
                <span className="absolute top-1 h-1.5 w-1.5 rounded-full bg-brand-400" aria-hidden />
              )}
            </button>
          );
        })}
      </div>
    </div>
  );
}
  • Step 2: Verificar tipos

Run: npm run typecheck → 0 errores en el nuevo archivo.

  • Step 3: Commit (opcional)
git add src/components/MonthCalendar.tsx
git commit -m "feat(ui): MonthCalendar — always-visible month grid with availability dots"

Task 11: DateTimePicker — calendario + 2 columnas + agrupación Mañana/Tarde

Files:

  • Modify: src/pages/public/BookingPage.tsx (componente DateTimePicker, líneas 555-642)

Interfaces:

  • Consumes: MonthCalendar (Task 10).

  • Produces: DateTimePicker con nueva firma idéntica (mismos props) para no romper el caller; muestra calendario a la izquierda y slots a la derecha en md+.

  • Step 1: Añadir import de MonthCalendar

En los imports de BookingPage.tsx (junto al de ui), añadir:

import { MonthCalendar } from "../../components/MonthCalendar";
  • Step 2: Reescribir DateTimePicker

Reemplazar toda la función DateTimePicker (líneas 555-642) por:

function DateTimePicker({
  date,
  onDate,
  min,
  max,
  slots,
  loading,
  isError,
  selectedSlot,
  onPick,
}: {
  date: string;
  onDate: (d: string) => void;
  min: string;
  max?: string;
  slots: PublicSlot[];
  loading: boolean;
  isError: boolean;
  selectedSlot: PublicSlot | null;
  onPick: (s: PublicSlot) => void;
}) {
  // agrupar slots en Mañana (< 12:00) / Tarde (>= 12:00) usando la hora del texto "HH:MM AM/PM"
  const { morning, afternoon } = useMemo(() => {
    const morning: PublicSlot[] = [];
    const afternoon: PublicSlot[] = [];
    for (const s of slots) {
      const m = /(\d{1,2}):(\d{2})/.exec(s.time);
      const h = m ? Number(m[1]) : 0;
      const pm = /PM/i.test(s.time);
      const hour24 = pm && h !== 12 ? h + 12 : !pm && h === 12 ? 0 : h;
      (hour24 < 12 ? morning : afternoon).push(s);
    }
    return { morning, afternoon };
  }, [slots]);

  return (
    <div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
      {/* Columna izquierda: calendario */}
      <div className="card p-4">
        <label className="label flex items-center gap-1.5">
          <CalendarDays className="h-3.5 w-3.5 text-slate-400" /> Fecha
        </label>
        <MonthCalendar value={date} min={min} max={max} onSelect={(d) => onDate(d)} />
        <div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-slate-500">
          <span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-accent-500" /> Con cupo</span>
          <span className="inline-flex items-center gap-1"><span className="h-2 w-2 rounded-full bg-brand-400" /> Hoy</span>
        </div>
      </div>

      {/* Columna derecha: horarios */}
      <div className="mt-4 md:mt-0">
        <label className="label flex items-center gap-1.5">
          <Clock className="h-3.5 w-3.5 text-slate-400" /> Horario disponible
        </label>
        <div className="card p-4">
          {loading && (
            <div className="flex items-center justify-center gap-2 py-8 text-slate-400">
              <Spinner className="text-brand-500" />
              <span className="text-sm font-medium">Buscando horarios</span>
            </div>
          )}
          {!loading && isError && (
            <div className="flex flex-col items-center gap-2 py-8 text-center">
              <AlertCircle className="h-6 w-6 text-rose-400" />
              <p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
            </div>
          )}
          {!loading && !isError && slots.length === 0 && (
            <div className="flex flex-col items-center gap-2 py-8 text-center">
              <CalendarDays className="h-6 w-6 text-slate-300" />
              <p className="text-sm font-medium text-slate-600">Sin horarios disponibles</p>
              <p className="text-xs text-slate-400">Prueba con otra fecha o especialista.</p>
            </div>
          )}
          {!loading && !isError && slots.length > 0 && (
            <div className="space-y-4">
              <SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} />
              <SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} />
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function SlotGroup({
  title,
  slots,
  selectedSlot,
  onPick,
}: {
  title: string;
  slots: PublicSlot[];
  selectedSlot: PublicSlot | null;
  onPick: (s: PublicSlot) => void;
}) {
  if (slots.length === 0) return null;
  return (
    <div>
      <div className="mb-2 flex items-center gap-2">
        <span className="rounded-full bg-accent-50 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wide text-accent-700">{title}</span>
        <span className="text-[11px] font-medium text-slate-400">{slots.length} opciones</span>
      </div>
      <div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
        {slots.map((slot) => {
          const active = selectedSlot?.iso === slot.iso && selectedSlot?.employee_id === slot.employee_id;
          return (
            <button
              key={`${slot.iso}-${slot.employee_id}`}
              type="button"
              onClick={() => onPick(slot)}
              className={cn(
                "rounded-xl border px-2 py-2.5 text-sm font-bold transition-all",
                active
                  ? "border-brand-500 bg-brand-500 text-white shadow-soft"
                  : "border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50"
              )}
            >
              {slot.time}
            </button>
          );
        })}
      </div>
    </div>
  );
}
  • Step 3: Verificar tipos

Run: npm run typecheck → puede quedar algún error por el max prop nuevo en el caller; se resuelve en Task 12. Si aparecen errores sólo por el caller, continuar.

  • Step 4: Commit (opcional)
git add src/pages/public/BookingPage.tsx
git commit -m "feat(ui): DateTimePicker 2-col + month calendar + morning/afternoon grouping"

Task 12: Wizard 3/4 pasos + Confirmation con reasons + anclar main ancho

Files:

  • Modify: src/pages/public/BookingPage.tsx (STEPS, TopBar, BookingPage main/orquestación, paso Especialista condicional, ActionBar, Confirmation, pase de max y employeeId al book).

Interfaces:

  • Consumes: business.auto_assign_specialist (de publicApi).

  • Produce: wizard que omite el paso Especialista cuando auto_assign_specialist; confirmación muestra reasons.

  • Step 1: Hacer dinámico el array STEPS y el total

Reemplazar las constantes STEPS (líneas 36-41) por una función y un helper:

const FULL_STEPS = [
  { n: 1, label: "Servicio" },
  { n: 2, label: "Especialista" },
  { n: 3, label: "Horario" },
  { n: 4, label: "Datos" },
] as const;

function stepsFor(autoAssign: boolean) {
  return autoAssign
    ? FULL_STEPS.filter((s) => s.label !== "Especialista").map((s, i) => ({ n: i + 1, label: s.label, original: s.n }))
    : FULL_STEPS.map((s, i) => ({ n: i + 1, label: s.label, original: s.n }));
}
  • Step 2: En BookingPage, calcular autoAssign y steps, y mapear pasos internos

Dentro del componente BookingPage, tras obtener business, añadir (antes de const selectedService):

  const autoAssign = !!business?.auto_assign_specialist;
  const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]);
  const stepCount = steps.length;
  // "original" = número de sección lógica: 1 Servicio, 3 Horario, 4 Datos (2 Especialista sólo si no auto)
  const currentOriginal = steps.find((s) => s.n === step)?.original;

Reemplazar goNext/goBack (líneas 154-155) por versiones acotadas al rango dinámico:

  const goNext = () => setStep((s) => Math.min(stepCount, s + 1));
  const goBack = () => setStep((s) => Math.max(1, s - 1));
  • Step 3: Reemplazar el return principal (líneas 200-301)

Reemplazar todo el bloque return (...) del componente por:

  return (
    <div className="flex min-h-screen flex-col bg-gradient-to-b from-brand-50/50 via-[#f6f7fb] to-[#f6f7fb]">
      <TopBar name={business.name} industry={business.industry} step={step} stepCount={stepCount} steps={steps} />

      <main className={cn(
        "mx-auto w-full flex-1 px-4 pb-32 pt-5 sm:px-6 md:pb-28",
        currentOriginal === 3 ? "max-w-5xl" : "max-w-2xl"
      )}>
        {currentOriginal === 1 && <Hero business={business} />}

        <div className="mt-4">
          {currentOriginal === 1 && (
            <StepShell icon={<Sparkles className="h-4 w-4" />} title="Elige un servicio" subtitle="Selecciona el servicio que deseas reservar.">
              <ServiceGrid services={services} currency={currency} selectedId={serviceId} onPick={pickService} />
            </StepShell>
          )}

          {currentOriginal === 2 && (
            <StepShell icon={<User className="h-4 w-4" />} title="Elige un especialista" subtitle="¿Tienes una preferencia? O déjalo en cualquiera.">
              <EmployeeList employees={employees} selectedId={employeeId} onPick={pickEmployee} />
            </StepShell>
          )}

          {currentOriginal === 3 && selectedService && (
            <StepShell icon={<CalendarDays className="h-4 w-4" />} title="Elige fecha y hora" subtitle={`${selectedService.name} · ${selectedService.duration_min} min · ${formatCurrency(selectedService.price, currency)}`}>
              <DateTimePicker
                date={selectedDate}
                onDate={(d) => { setSelectedDate(d); setSelectedSlot(null); }}
                min={todayStr()}
                max={maxBookableDate()}
                slots={slots}
                loading={slotsQuery.isLoading}
                isError={slotsQuery.isError}
                selectedSlot={selectedSlot}
                onPick={pickSlot}
              />
            </StepShell>
          )}

          {currentOriginal === 4 && selectedService && selectedSlot && (
            <StepShell icon={<CalendarCheck className="h-4 w-4" />} title="Tus datos" subtitle="Necesitamos algunos datos para confirmar tu cita.">
              <DetailsForm
                client={client}
                setClient={setClient}
                summary={
                  <>
                    <SummaryRow label="Servicio" value={selectedService.name} />
                    <SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
                    <SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
                    {!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
                  </>
                }
              />
            </StepShell>
          )}
        </div>
      </main>

      <ActionBar
        hidden={!!result}
        step={step}
        stepCount={stepCount}
        goBack={goBack}
        onPrimary={onPrimary}
        primaryLabel={primaryLabel}
        canContinue={canContinue}
        busy={bookMut.isPending}
        priceLabel={selectedService ? formatCurrency(selectedService.price, currency) : undefined}
        serviceName={selectedService?.name}
        slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short" })} · ${selectedSlot.time}` : undefined}
      />

      <FooterNote name={business.name} />
    </div>
  );

Añadir helper maxBookableDate (junto a todayStr):

function maxBookableDate() {
  const d = new Date();
  d.setMonth(d.getMonth() + 3);
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
  • Step 4: Actualizar canContinue, onPrimary/primaryLabel y handleBook

Reemplazar pickSlot (líneas 169-172), handleBook (174-188), canContinue (190-194) y primaryLabel/onPrimary (197-198) por versiones que usen currentOriginal y stepCount:

  const pickSlot = (slot: PublicSlot) => {
    setSelectedSlot(slot);
    // avanzar al siguiente paso (Datos)
    setStep((s) => Math.min(stepCount, s + 1));
  };

  const handleBook = () => {
    if (!selectedService || !selectedSlot) return;
    if (!client.name.trim()) return;
    bookMut.mutate({
      service_id: selectedService.id,
      employee_id: autoAssign ? undefined : selectedSlot.employee_id,
      start_at: selectedSlot.iso,
      client: {
        name: client.name.trim(),
        email: client.email.trim() || undefined,
        phone: client.phone.trim() || undefined,
        notes: client.notes.trim() || undefined,
      },
    });
  };

  const canContinue =
    (currentOriginal === 1 && !!serviceId) ||
    currentOriginal === 2 ||
    (currentOriginal === 3 && !!selectedSlot) ||
    (currentOriginal === 4 && client.name.trim().length > 0);

  const currency = business.currency_symbol || "$";
  const primaryLabel = currentOriginal === 4 ? "Confirmar reserva" : "Continuar";
  const onPrimary = currentOriginal === 4 ? handleBook : goNext;

Y en reset() (líneas 91-100) cambiar el orden de pasos no afecta; dejar igual.

  • Step 5: Actualizar slotsQuery enabled

En slotsQuery (línea 74) cambiar la condición para usar currentOriginal:

    enabled: currentOriginal === 3 && !!serviceId && !!selectedDate && !result,

Nota: como currentOriginal se calcula más abajo en el código actual, mover su cálculo (Step 2) a antes de slotsQuery para que la closure lo vea.

  • Step 6: Actualizar TopBar y ActionBar para stepCount dinámico

Reemplazar firma y render de TopBar (líneas 304-354) — resumido: aceptar stepCount: number y steps: {n,label}[], y reemplazar "Paso {step} de 4" por "Paso {step} de {stepCount}", iterar steps (no STEPS) en el indicador de escritorio, y en móvil mostrar / {stepCount}.

function TopBar({ name, industry, step, stepCount, steps }: { name: string; industry: string; step: number; stepCount: number; steps: { n: number; label: string }[]; }) {
  const current = steps.find((s) => s.n === step);
  return (
    <header className="sticky top-0 z-30 border-b border-slate-200/70 bg-white/85 backdrop-blur-md">
      <div className="mx-auto flex w-full max-w-5xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
        <div className="flex min-w-0 items-center gap-2.5">
          <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-brand-500 to-brand-700 text-white shadow-soft">
            <Sparkles className="h-4 w-4" />
          </div>
          <div className="min-w-0">
            <div className="truncate text-sm font-extrabold text-slate-900">{name}</div>
            <div className="truncate text-[11px] text-slate-500">{industry}</div>
          </div>
        </div>
        {step > 0 && (
          <div className="flex items-center gap-1.5">
            <span className="hidden text-[11px] font-semibold uppercase tracking-wide text-slate-400 sm:inline">Paso {step} de {stepCount}</span>
            <div className="hidden items-center gap-1 md:flex">
              {steps.map((s) => (
                <div key={s.n} className={cn(
                  "flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold transition-colors",
                  s.n === step ? "bg-brand-500 text-white" : s.n < step ? "bg-brand-50 text-brand-700" : "bg-slate-100 text-slate-400"
                )}>
                  {s.n < step ? <Check className="h-3 w-3" /> : <span>{s.n}</span>}
                  <span>{s.label}</span>
                </div>
              ))}
            </div>
            <div className="flex items-center gap-1.5 rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700 md:hidden">
              <span>{step}</span><span className="font-semibold text-brand-400">/</span><span className="text-brand-400">{stepCount}</span>
              <span className="text-brand-300">·</span><span>{current?.label}</span>
            </div>
          </div>
        )}
      </div>
    </header>
  );
}

En ActionBar (líneas 725-782): aceptar stepCount, cambiar max-w-2xlmax-w-5xl en el div interno (línea 751), y mostrar "Paso {step}/{stepCount}" si se desea (opcional). Firma actualizada:

function ActionBar({ hidden, step, stepCount, goBack, onPrimary, primaryLabel, canContinue, busy, priceLabel, serviceName, slotLabel }: {
  hidden: boolean; step: number; stepCount: number; goBack: () => void; onPrimary: () => void; primaryLabel: string; canContinue: boolean; busy: boolean; priceLabel?: string; serviceName?: string; slotLabel?: string;
}) {
  if (hidden) return null;
  return (
    <div className="sticky bottom-0 z-30 border-t border-slate-200/70 bg-white/90 backdrop-blur-md">
      <div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-4 py-3 sm:px-6">
        {step > 1 && (<button onClick={goBack} className="btn btn-ghost shrink-0" type="button"><ChevronLeft className="h-4 w-4" /> <span className="hidden sm:inline">Atrás</span></button>)}
        <div className="min-w-0 flex-1">
          {serviceName ? (
            <>
              <div className="truncate text-sm font-bold text-slate-900">{serviceName}</div>
              <div className="truncate text-xs text-slate-500">{slotLabel ? slotLabel : priceLabel}</div>
            </>
          ) : (<div className="text-sm font-medium text-slate-400">Selecciona para continuar</div>)}
        </div>
        <button onClick={onPrimary} disabled={!canContinue || busy} className="btn btn-primary shrink-0" type="button">
          {busy ? <Spinner /> : <>{primaryLabel} <ChevronRight className="h-4 w-4" /></>}
        </button>
      </div>
    </div>
  );
}
  • Step 7: Confirmation con reasons

Reemplazar el bloque del especialista en Confirmation (líneas 820-828) para mostrar reasons cuando existan. Tras <SummaryRow label="Especialista" value={appointment.employee_name} /> añadir:

              {appointment.reasons && appointment.reasons.length > 0 && (
                <div className="px-3 pb-2 pt-1">
                  <div className="flex flex-wrap gap-1.5">
                    {appointment.reasons.map((r) => (
                      <span key={r} className="inline-flex items-center gap-1 rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-bold text-brand-700">
                        <Sparkles className="h-3 w-3" /> {r}
                      </span>
                    ))}
                  </div>
                </div>
              )}
  • Step 8: Verificar tipos y runtime

Run: npm run typecheck → 0 errores. Run: npm run dev → probar flujo completo con auto_assign_specialist=1 (3 pasos) y =0 (4 pasos), y revisar la confirmación con reasons.

  • Step 9: Commit (opcional)
git add src/pages/public/BookingPage.tsx
git commit -m "feat(booking): dynamic 3/4-step wizard + wider layout + confirmation reasons"

Task 13: SettingsPage — check auto-assign + editor de horario del negocio

Files:

  • Modify: src/pages/SettingsPage.tsx

Interfaces:

  • Consumes: api.settings.update con auto_assign_specialist y working_hours.

  • Step 1: Añadir un editor de horario reutilizable y normalizar working_hours

Al inicio del archivo, tras los imports, añadir un helper y un subcomponente:

import { Wand2, Clock } from "lucide-react";

const DEFAULT_WH = {
  1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
  3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
  5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
} as Record<number, { start: string; end: string } | null>;

function parseWh(v: any): Record<number, { start: string; end: string } | null> {
  let obj = v;
  if (typeof v === "string") { try { obj = JSON.parse(v); } catch { obj = null; } }
  if (!obj || typeof obj !== "object") return { ...DEFAULT_WH };
  const out: Record<number, any> = {};
  for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null;
  return out;
}

const DAYS = [
  { n: 1, label: "Lunes" }, { n: 2, label: "Martes" }, { n: 3, label: "Miércoles" },
  { n: 4, label: "Jueves" }, { n: 5, label: "Viernes" }, { n: 6, label: "Sábado" }, { n: 7, label: "Domingo" },
];

function WorkingHoursEditor({ value, onChange }: { value: Record<number, { start: string; end: string } | null>; onChange: (v: Record<number, { start: string; end: string } | null>) => void }) {
  const toggle = (n: number) => onChange({ ...value, [n]: value[n] ? null : { start: "09:00", end: "18:00" } });
  const set = (n: number, field: "start" | "end", v: string) => {
    const cur = value[n] ?? { start: "09:00", end: "18:00" };
    onChange({ ...value, [n]: { ...cur, [field]: v } });
  };
  return (
    <div className="space-y-1.5">
      {DAYS.map(({ n, label }) => {
        const on = !!value[n];
        return (
          <div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
            <label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
              <input type="checkbox" className="h-4 w-4 accent-brand-500" checked={on} onChange={() => toggle(n)} />
              {label}
            </label>
            {on ? (
              <div className="flex items-center gap-1.5">
                <input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.start} onChange={(e) => set(n, "start", e.target.value)} />
                <span className="text-xs text-slate-400">a</span>
                <input type="time" className="input !w-auto !py-1 text-xs" value={value[n]!.end} onChange={(e) => set(n, "end", e.target.value)} />
              </div>
            ) : (
              <span className="text-xs font-medium text-slate-400">Cerrado</span>
            )}
          </div>
        );
      })}
    </div>
  );
}
  • Step 2: Estado local de working hours + incluir en el mutation

En el componente SettingsPage, tras const [saved, setSaved] = useState(false); añadir:

  const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });

  useEffect(() => {
    if (data?.settings) { setF(data.settings); setWh(parseWh(data.settings.working_hours)); }
  }, [data]);

(Reemplazar el useEffect existente que sólo hace setF.)

En el mut (mutationFn), añadir al payload:

        auto_assign_specialist: f.auto_assign_specialist ? 1 : 0,
        working_hours: wh,
  • Step 3: UI — check auto-assign y editor de horario

Dentro de la card "Reservas online" (tras el bloque de la URL pública, antes de cerrar el </div> de la card — línea ~100), añadir:

            <label className="flex cursor-pointer items-start gap-3 rounded-xl border border-slate-200 p-3">
              <input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-500" checked={!!f.auto_assign_specialist} onChange={(e) => set("auto_assign_specialist", e.target.checked)} />
              <div>
                <div className="flex items-center gap-1.5 text-sm font-bold text-slate-800"><Wand2 className="h-4 w-4 text-brand-500" /> Asignar especialista automáticamente</div>
                <p className="text-xs text-slate-500">El cliente no elige especialista: el sistema lo asigna según especialidad, eficiencia y disponibilidad. Evita dobles reservas.</p>
              </div>
            </label>

            <div>
              <label className="label flex items-center gap-1.5"><Clock className="h-3.5 w-3.5 text-slate-400" /> Horario del negocio</label>
              <WorkingHoursEditor value={wh} onChange={setWh} />
              <p className="mt-1 text-[11px] text-slate-500">Define qué días y horas se ofrecen citas. Los especialistas pueden tener su propio horario.</p>
            </div>
  • Step 4: Verificar tipos y runtime

Run: npm run typecheck → 0 errores. Run: npm run dev → abrir /settings, cambiar el check y el horario, guardar, recargar y confirmar persistencia.

  • Step 5: Commit (opcional)
git add src/pages/SettingsPage.tsx
git commit -m "feat(settings): auto-assign checkbox + business working-hours editor"

Task 14: EmployeeModal — especialidades, horario, eficiencia

Files:

  • Modify: src/pages/EmployeesPage.tsx (EmployeeModal, líneas 176-313)

Interfaces:

  • Consumes: api.employees.update/create con specialties, working_hours, efficiency_score.

  • Step 1: Estado local + sincronización

En EmployeeModal, tras const [svcIds, setSvcIds] = useState<number[]>([]); (línea 195) añadir:

  const [specialties, setSpecialties] = useState<string[]>([]);
  const [specialtyInput, setSpecialtyInput] = useState("");
  const [efficiency, setEfficiency] = useState(50);
  const [inheritHours, setInheritHours] = useState(true);
  const [wh, setWh] = useState<Record<number, { start: string; end: string } | null>>({ ...DEFAULT_WH });

Importar DEFAULT_WH, DAYS, parseWh desde donde se definieron en SettingsPage, o duplicarlos como ayudantes locales en EmployeesPage.tsx (recomendado: moverlos a src/lib/workingHours.ts y compartir). Para mantener el cambio contenido, crear:

// src/lib/workingHours.ts
export const DEFAULT_WH: Record<number, { start: string; end: string } | null> = {
  1: { start: "09:00", end: "20:00" }, 2: { start: "09:00", end: "20:00" },
  3: { start: "09:00", end: "20:00" }, 4: { start: "09:00", end: "20:00" },
  5: { start: "09:00", end: "20:00" }, 6: null, 7: null,
};
export const DAYS = [
  { n: 1, label: "Lunes" }, { n: 2, label: "Martes" }, { n: 3, label: "Miércoles" },
  { n: 4, label: "Jueves" }, { n: 5, label: "Viernes" }, { n: 6, label: "Sábado" }, { n: 7, label: "Domingo" },
];
export function parseWh(v: any): Record<number, { start: string; end: string } | null> {
  let obj = v;
  if (typeof v === "string") { try { obj = JSON.parse(v); } catch { obj = null; } }
  if (!obj || typeof obj !== "object") return { ...DEFAULT_WH };
  const out: Record<number, any> = {};
  for (let d = 1; d <= 7; d++) out[d] = obj[String(d)] ?? null;
  return out;
}

Y refactorizar SettingsPage.tsx (Task 13) para importar de src/lib/workingHours.ts en lugar de duplicar (ajustar el import).

En EmployeeModal, sincronizar en el useEffect (líneas 197-216). Para el caso employee, añadir:

      setSpecialties(Array.isArray(employee.specialties) ? employee.specialties : []);
      setEfficiency(typeof employee.efficiency_score === "number" ? employee.efficiency_score : 50);
      const parsed = parseWh(employee.working_hours);
      const hasOwn = !!employee.working_hours && Object.values(parsed).some((v) => v !== null);
      setInheritHours(!hasOwn);
      setWh(hasOwn ? parsed : { ...DEFAULT_WH });

Y en el else (nuevo):

      setSpecialties([]); setEfficiency(50); setInheritHours(true); setWh({ ...DEFAULT_WH });
  • Step 2: Payload del mutation

En el mut.mutationFn (líneas 219-223) ampliar el payload:

      const payload = {
        name, role, email, phone, color, active, service_ids: svcIds,
        specialties,
        efficiency_score: efficiency,
        working_hours: inheritHours ? null : wh,
      };
  • Step 3: UI — especialidades, horario, eficiencia

Tras el bloque "Servicios que ofrece" (cerrar antes del cierre del Modal, ~línea 308), añadir:

        <div>
          <label className="label">Especialidades</label>
          <div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-100 p-2">
            {specialties.map((sp) => (
              <span key={sp} className="chip bg-brand-50 text-brand-700">
                {sp}
                <button type="button" onClick={() => setSpecialties((a) => a.filter((x) => x !== sp))} className="ml-0.5 text-brand-400 hover:text-brand-700">×</button>
              </span>
            ))}
            <input
              className="min-w-[120px] flex-1 bg-transparent text-sm outline-none"
              placeholder="Ej. Coloración y pulsa Enter"
              value={specialtyInput}
              onChange={(e) => setSpecialtyInput(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter" && specialtyInput.trim()) { e.preventDefault(); setSpecialties((a) => [...new Set([...a, specialtyInput.trim()])]); setSpecialtyInput(""); }
              }}
            />
          </div>
          <p className="mt-1 text-[11px] text-slate-500">Etiquetas que el algoritmo compara con la categoría y el nombre del servicio.</p>
        </div>

        <div>
          <label className="label">Eficiencia <span className="ml-1 text-brand-700">{efficiency}</span></label>
          <input type="range" min={0} max={100} value={efficiency} onChange={(e) => setEfficiency(Number(e.target.value))} className="w-full accent-brand-500" />
          <p className="mt-1 text-[11px] text-slate-500">Qué tan rápido/hábil es. El algoritmo premia valores altos cuando hay empate por disponibilidad.</p>
        </div>

        <div>
          <label className="flex cursor-pointer items-center gap-2">
            <input type="checkbox" className="h-4 w-4 accent-brand-500" checked={inheritHours} onChange={(e) => setInheritHours(e.target.checked)} />
            <span className="text-sm font-bold text-slate-800">Heredar horario del negocio</span>
          </label>
          {!inheritHours && (
            <div className="mt-2 space-y-1.5">
              {DAYS.map(({ n, label }) => {
                const on = !!wh[n];
                return (
                  <div key={n} className="flex items-center gap-2 rounded-lg border border-slate-100 px-2 py-1.5">
                    <label className="flex w-24 cursor-pointer items-center gap-2 text-xs font-semibold text-slate-700">
                      <input type="checkbox" className="h-4 w-4 accent-brand-500" checked={on} onChange={() => setWh({ ...wh, [n]: wh[n] ? null : { start: "09:00", end: "18:00" } })} />
                      {label}
                    </label>
                    {on ? (
                      <div className="flex items-center gap-1.5">
                        <input type="time" className="input !w-auto !py-1 text-xs" value={wh[n]!.start} onChange={(e) => setWh({ ...wh, [n]: { ...(wh[n] as any), start: e.target.value } })} />
                        <span className="text-xs text-slate-400">a</span>
                        <input type="time" className="input !w-auto !py-1 text-xs" value={wh[n]!.end} onChange={(e) => setWh({ ...wh, [n]: { ...(wh[n] as any), end: e.target.value } })} />
                      </div>
                    ) : <span className="text-xs font-medium text-slate-400">Cerrado</span>}
                  </div>
                );
              })}
            </div>
          )}
        </div>
  • Step 4: Verificar tipos y runtime

Run: npm run typecheck → 0 errores. Run: npm run dev → editar un empleado, añadir especialidades, ajustar eficiencia, definir horario propio, guardar y recargar.

  • Step 5: Commit (opcional)
git add src/pages/EmployeesPage.tsx src/lib/workingHours.ts src/pages/SettingsPage.tsx
git commit -m "feat(employees): specialties, efficiency score, per-employee working hours"

Task 15: Verificación final (tipos, lint, unit, e2e, build)

Files: ninguno (sólo validación).

  • Step 1: Typecheck

Run: npm run typecheck Esperado: 0 errores.

  • Step 2: Lint

Run: npm run lint Esperado: 0 errores (avisos aceptables). Si aparecen react-hooks/exhaustive-deps por las nuevas dependencias en useEffect/useMemo, añadirlas al array de dependencias.

  • Step 3: Unit tests

Run: npm run test:unit Esperado: todos PASS.

  • Step 4: Build de producción

Run: npm run build Esperado: compila sin errores.

  • Step 5: Integración de reservas (con server levantado)

Run: npm run dev (otra terminal) Run: npm run test:booking Run: npm run test:e2e Esperado: todos PASS.

  • Step 6: Recorrido visual manual

  • /b/<slug> con auto_assign_specialist=1: 3 pasos, calendario de mes visible, layout 2 columnas en desktop, sin solape de iconos en "Tus datos", confirmación con badges de reasons.

  • /b/<slug> con auto_assign_specialist=0: 4 pasos, card "Cualquiera" usa el ranking.

  • Mobile (<md): stack vertical en Horario (sin romper).

  • Intentar reservar el mismo horario dos veces → 409 visible para el cliente.

  • Step 7: Commit final (opcional)

git add -A
git commit -m "test: full verification — typecheck, lint, unit, booking e2e, build"

Self-Review (post-escritura)

Spec coverage:

  • Check "omitir especialista" → Task 1 (col), Task 5 (server), Task 12 (wizard). ✓
  • Algoritmo matemático (especialidad/eficiencia/disponibilidad/tiempo) → Task 2-3. ✓
  • Sin doble agenda (hard constraint + guard tx) → Task 3 (hasConflict), Task 5-6 (409). ✓
  • Calendario de mes siempre visible → Task 10, integrado en Task 11. ✓
  • Layout 2 columnas escritorio → Task 11 (md:grid), max-w-5xl en Task 12. ✓
  • Colores llamativos + accesibilidad → accent dots, badges reasons, celdas >=aspect-square, estilos reforzados en Tasks 10-12. ✓
  • Mantener bloques de horario → Task 11 conserva grid-cols-3 sm:grid-cols-4 y los mismos estilos seleccionado/disponible. ✓
  • Mobile intacto → Task 11 mantiene stack <md. ✓
  • Fix iconos solapados → Task 8. ✓
  • Captura de datos del algoritmo (especialidades/horario/eficiencia) → Tasks 7 (backend), 13-14 (UI). ✓

Placeholder scan: sin TBD/TODO; todos los pasos tienen código completo o comandos exactos.

Type consistency: WorkingHoursMap/CandidateInfo/AutoAssignResult definidos en Task 2-3 y consumidos con los mismos nombres en Tasks 4-6. stepsFor/currentOriginal en Task 12 coinciden entre TopBar/ActionBar/BookingPage. BookPayload.employee_id opcional (Task 9) consumido en Task 12.

Notas de riesgo: runInTransaction usa BEGIN IMMEDIATE; con node:sqlite síncrono y un solo proceso, el bloqueo es efectivo. Si dos requests concurrentes compiten por la misma cita, el segundo recibe SQLITE_BUSY al BEGIN IMMEDIATE — manejar con un reintento simple si se observa (no requerido para el scope actual, single-process).