Files
AgendaPro/server/lib/scheduling.ts
AgendaPro DevandClaude Opus 5 f48a9ac3bf fix: anclar la agenda a la zona horaria del negocio, no a la del proceso
La reserva pública no ofrecía horarios en producción. La ventana laboral se
construía con `new Date(y, m, d, hh, mm)`, que resuelve el reloj de pared en la
tz del proceso. El Dockerfile no fijaba TZ y node:22-slim arranca en UTC,
mientras que la máquina de desarrollo está en America/Mexico_City: por eso solo
fallaba desplegado. Un negocio de 09:00-20:00 se publicaba como 09:00-20:00 UTC
(03:00-14:00 de México), y como el generador descarta lo anterior a ahora+30min,
a partir de la 1 PM la lista quedaba vacía.

Toda la API de scheduling.ts lleva ahora `tz` explícita y resuelve el reloj de
pared con wallToUtcDate/bizDateISO de time.ts, que ya existían para esto.

Arrastraba cinco defectos más en la misma ruta:

- getExistingBusy acotaba el día concatenando `${fecha}T00:00:00`. Como start_at
  se guarda en UTC, una cita de las 19:00 de México vive en el día UTC siguiente
  y quedaba fuera del rango: el guard anti doble-reserva no veía la tarde entera.
  Ahora usa bizDayBoundsIsoFor.
- Un negocio recién sembrado nacía con working_hours y slug en NULL, o sea con
  cero franjas agendables y /b/:slug en 404: el backfill vivía solo dentro de las
  migraciones, que corren antes de que exista la fila. Los defaults se fijan en el
  INSERT (server/lib/businessDefaults.ts) en los tres sitios que crean negocios, y
  migrateV4ToV5 repara los ya rotos. El demo usa slug fijo `mi-negocio-demo`
  porque es la URL ya publicada y el volumen se recrea en cada despliegue.
- El chip mostraba la hora formateada por el servidor y el resumen la del
  navegador: dos horas distintas para el mismo slot. Ambas salen ahora del
  instante resuelto en la tz del negocio.
- La separación mañana/tarde usaba /PM/i sobre un texto ya localizado, y es-MX
  rinde "05:00 p.m." con puntos: nunca casaba, así que el grupo "Tarde"
  desaparecía y toda la tarde se agrupaba bajo "Mañana".
- MonthCalendar comparaba canPrev contra el día 1 del mes visible en vez de
  contra minDate, de modo que la flecha de mes anterior nunca se podía pulsar.

Las guardas de migración comparaban la versión como texto ("10" >= "2" es false),
lo que habría reejecutado migrateV1ToV2 y su DROP TABLE users al llegar a dos
dígitos; ahora comparan números.

Verificación: scheduling.test.ts fija TZ=UTC y usa negocios en America/Mexico_City
para que la tz del proceso y la del negocio nunca coincidan; el Dockerfile fija
ENV TZ=UTC por lo mismo. 43 unitarias + 33 e2e + 12 booking + 17 admin en verde
con el servidor en UTC y base recién sembrada; typecheck limpio. booking-e2e.mjs
busca el próximo día abierto en vez de asumir "mañana", que lo hacía fallar cada
viernes y sábado por calendario.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-28 10:47:59 -06:00

362 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
import { bizDateISO, bizDayBoundsIsoFor, isoDowFromDateStr, wallToUtcDate, DEFAULT_TZ } from "./time.ts";
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;
}
/** @deprecated Fecha natural en la tz del PROCESO. Para lógica de negocio usa
* `bizDateISO(instant, tz)` de time.ts — esta desplaza el día en un contenedor UTC. */
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 una fecha
* natural del negocio, y lo devuelve como instantes absolutos (ms UTC).
*
* `dateIso` es "YYYY-MM-DD" **en la tz del negocio** y `tz` su zona IANA. Ambos
* son obligatorios a propósito: la versión anterior recibía un `Date` y
* construía la ventana con `new Date(y, m, d, hh, mm)`, o sea en la tz del
* *proceso*. En dev (Windows, America/Mexico_City) coincidía; en el contenedor
* de producción (node:22-slim = UTC) desplazaba toda la jornada 6 horas, de modo
* que un negocio de 09:00–20:00 se publicaba como 03:00–14:00 hora de México.
*/
export function getWorkingHoursForDate(
empWh: WorkingHoursMap | null,
bizWh: WorkingHoursMap | null,
dateIso: string,
tz: string
): { startMs: number; endMs: number } | null {
const zone = tz || DEFAULT_TZ;
const dow = isoDowFromDateStr(dateIso);
const day = empWh?.[dow] ?? bizWh?.[dow] ?? null;
if (!day) return null;
const [y, mo, d] = dateIso.split("-").map(Number);
const [sh, sm] = day.start.split(":").map(Number);
const [eh, em] = day.end.split(":").map(Number);
const startMs = wallToUtcDate(zone, y, mo, d, sh, sm, 0).getTime();
const endMs = wallToUtcDate(zone, y, mo, d, eh, em, 0).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 = 40·specialtyMatch + 20·efficiency + 40·(1−loadBalance), todo en [0,1]. Rango 0..100.
* Pesos 40/20/40: specialty sigue mandando en matches reales, pero loadBalance (40) ahora
* puede superar la ventaja estática de efficiency (20) → el especialista libre/menos cargado
* gana con más frecuencia (equidad, anti-burnout). */
export function scoreCandidate(s: ScoreInput): number {
const sm = clamp01(s.specialtyMatch);
const eff = clamp01(s.efficiency);
const inv = clamp01(1 - clamp01(s.loadBalance));
return 40 * sm + 20 * eff + 40 * inv;
}
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),
}));
}
/** Estados que ocupan la ventana de trabajo del empleado para load/availability.
* NOTA: `no_show` NO está — la silla quedó vacía, así que no cuenta contra la carga
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
/**
* Citas que ocupan a cada empleado durante el día natural `dateIso` **del negocio**.
*
* Los límites se calculan con `bizDayBoundsIsoFor`, no concatenando
* `${dateIso}T00:00:00`: `start_at` se almacena en UTC, así que una cita de las
* 19:00 de México vive en el día UTC *siguiente* (01:00Z) y la versión anterior
* la dejaba fuera del rango — el guard anti doble-reserva no veía ninguna cita
* de la tarde.
*/
export function getExistingBusy(
db: DatabaseSync,
employeeIds: number[],
dateIso: string,
tz: string
): Map<number, BusyWindow[]> {
const map = new Map<number, BusyWindow[]>();
if (employeeIds.length === 0) return map;
const { start: startOfDay, end: endOfDay } = bizDayBoundsIsoFor(tz || DEFAULT_TZ, dateIso);
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
const rows = db
.prepare(
`SELECT employee_id, start_at, end_at FROM appointments
WHERE employee_id IN (${employeeIds.map(() => "?").join(",")})
AND status IN (${statusPh}) AND start_at >= ? AND start_at <= ?`
)
.all(...employeeIds, ...BUSY_STATUSES, 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,
tz: string
): boolean {
const zone = tz || DEFAULT_TZ;
const dateIso = bizDateISO(new Date(startMs), zone);
const busy = getExistingBusy(db, [employeeId], dateIso, zone).get(employeeId) ?? [];
return !hasConflict(busy, startMs, endMs);
}
interface RankedCandidate { info: CandidateInfo; score: number; loadBalance: 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, loadBalance, reasons };
}
/**
* Hash determinista (xmur3) → uint32. Sin deps. Misma entrada ⇒ misma salida en cualquier
* plataforma, para que el desempate por hash sea reproducible por input.
* Úselo para repartir empates sin sesgo por id/seniority. */
export function hashStr(str: string): number {
let h = 1779033703 ^ str.length;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
h = Math.imul(h ^ (h >>> 16), 2246822507);
h = Math.imul(h ^ (h >>> 13), 3266489909);
return (h ^ (h >>> 16)) >>> 0;
}
/** Epsilon para considerar dos loadBalance iguales (ratio de ms → posible ruido float). */
const LOAD_EPSILON = 1e-9;
/**
* ¿`a` desplaza al actual `best`? Orden de desempate (anti-burnout, sin sesgo por id):
* 1) mayor score
* 2) (cerca de empate) menor loadBalance — gana el menos cargado
* 3) hash determinista de `${dateIso}|${startMs}|${empId}` — mismo slot ⇒ misma decisión,
* pero slots distintos reparten entre empleados (no siempre cae en la misma persona).
*/
function beats(a: RankedCandidate, best: RankedCandidate, dateIso: string, startMs: number): boolean {
if (a.score !== best.score) return a.score > best.score;
if (Math.abs(a.loadBalance - best.loadBalance) > LOAD_EPSILON) return a.loadBalance < best.loadBalance;
return hashStr(`${dateIso}|${startMs}|${a.info.id}`) < hashStr(`${dateIso}|${startMs}|${best.info.id}`);
}
/** 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,
tz: string
): { id: number; name: string } | null {
const zone = tz || DEFAULT_TZ;
const dateIso = bizDateISO(new Date(startMs), zone);
let best: RankedCandidate | null = null;
for (const c of candidates) {
const wh = getWorkingHoursForDate(c.empWh, bizWh, dateIso, zone);
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 || beats(ranked, best, dateIso, startMs)) {
best = ranked;
}
}
return best ? { id: best.info.id, name: best.info.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;
/** Zona IANA del negocio. Sin ella la ventana laboral se ancla a la tz del proceso. */
tz: string;
}
/** 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 zone = ctx.tz || DEFAULT_TZ;
const dateIso = bizDateISO(new Date(ctx.startMs), zone);
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso, zone);
let best: RankedCandidate | null = null;
for (const c of candidates) {
const wh = getWorkingHoursForDate(c.empWh, ctx.bizWh, dateIso, zone);
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 || beats(ranked, best, dateIso, ctx.startMs)) {
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;
}
}