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]>
This commit is contained in:
AgendaPro Dev
2026-08-28 10:47:59 -06:00
co-authored by Claude Opus 5
parent 0069d23744
commit f48a9ac3bf
17 changed files with 420 additions and 92 deletions
+59 -18
View File
@@ -2,6 +2,8 @@
// 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
@@ -39,24 +41,39 @@ export function isoDayOfWeek(date: Date): number {
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 esa fecha. Devuelve ms o null si es día cerrado. */
/**
* 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,
date: Date
dateIso: string,
tz: string
): { startMs: number; endMs: number } | null {
const dow = isoDayOfWeek(date);
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 = new Date(date.getFullYear(), date.getMonth(), date.getDate(), sh, sm).getTime();
const endMs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), eh, em).getTime();
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 };
}
@@ -156,11 +173,24 @@ export function getCandidates(db: DatabaseSync, businessId: number, serviceId: n
* del empleado (además ayuda a los sobre-agendados a recuperarse). */
export const BUSY_STATUSES = ["scheduled", "completed"] as const;
export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso: string): Map<number, BusyWindow[]> {
/**
* 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 startOfDay = `${dateIso}T00:00:00`;
const endOfDay = `${dateIso}T23:59:59`;
const { start: startOfDay, end: endOfDay } = bizDayBoundsIsoFor(tz || DEFAULT_TZ, dateIso);
const statusPh = BUSY_STATUSES.map(() => "?").join(",");
const rows = db
.prepare(
@@ -177,9 +207,16 @@ export function getExistingBusy(db: DatabaseSync, employeeIds: number[], dateIso
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) ?? [];
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);
}
@@ -245,13 +282,14 @@ export function pickBestSlotEmployee(
bizWh: WorkingHoursMap | null,
service: { name: string; category: string },
startMs: number,
endMs: number
endMs: number,
tz: string
): { id: number; name: string } | null {
const date = new Date(startMs);
const dateIso = isoDateStr(date);
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, date);
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;
@@ -278,17 +316,20 @@ export interface AutoAssignCtx {
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 dateIso = isoDateStr(new Date(ctx.startMs));
const busyMap = getExistingBusy(db, candidates.map((c) => c.id), dateIso);
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, new Date(ctx.startMs));
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;