From f48a9ac3bf5ac2fbb00bc397030470d4751384e5 Mon Sep 17 00:00:00 2001 From: AgendaPro Dev Date: Fri, 28 Aug 2026 10:47:59 -0600 Subject: [PATCH] fix: anclar la agenda a la zona horaria del negocio, no a la del proceso MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 42 ++++++++++++-- Dockerfile | 5 ++ server/db.ts | 50 ++++++++++++++--- server/index.ts | 21 ++++++- server/lib/businessDefaults.ts | 49 ++++++++++++++++ server/lib/scheduling.test.ts | 95 ++++++++++++++++++++++++++------ server/lib/scheduling.ts | 77 ++++++++++++++++++++------ server/lib/time.ts | 31 +++++++++++ server/routes/admin.ts | 13 ++++- server/routes/appointments.ts | 12 +++- server/routes/booking.ts | 25 +++++---- server/scripts/booking-e2e.mjs | 20 +++++-- server/scripts/seed.ts | 12 +++- src/components/MonthCalendar.tsx | 8 ++- src/lib/format.ts | 17 +++++- src/lib/publicApi.ts | 2 + src/pages/public/BookingPage.tsx | 33 ++++++----- 17 files changed, 420 insertions(+), 92 deletions(-) create mode 100644 server/lib/businessDefaults.ts diff --git a/CLAUDE.md b/CLAUDE.md index a43123a..66cf21f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,19 +112,32 @@ No lo endurezcas a medias: si se toca, hay que cambiar login, `authRequired`, `a [server/db.ts](server/db.ts) abre `data/agendapro.db` (WAL + `foreign_keys = ON`) y exporta: - `SCHEMA` — el esquema de instalación nueva (`CREATE TABLE IF NOT EXISTS …`). -- `runMigrations()` — llama `migrateV1ToV2()` → `migrateV2ToV3()` → `migrateV3ToV4()` **en ese orden**, - cada una idempotente y guardada por `getMeta("schema_version")`. Ojo: en el archivo `migrateV3ToV4` +- `runMigrations()` — llama `migrateV1ToV2()` → `migrateV2ToV3()` → `migrateV3ToV4()` → `migrateV4ToV5()` **en ese orden**, + cada una idempotente y guardada por `schemaVersion()`. Ojo: en el archivo `migrateV3ToV4` está definida *antes* de `migrateV2ToV3`; el orden de ejecución es el de `runMigrations`, no el del - archivo. Versión actual: **4**. + archivo. Versión actual: **5**. Las guardas comparan con `schemaVersion()` (número): la comparación + de texto que había hacía que `"10" >= "2"` fuera false y reejecutara `migrateV1ToV2`, que contiene + un `DROP TABLE users`. -Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV4ToV5()` nueva que -haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "5")`. +Para cambiar el esquema: añade las columnas/tablas a `SCHEMA` **y** una `migrateV5ToV6()` nueva que +haga el backfill, llámala desde `runMigrations()` y termina con `setMeta("schema_version", "6")`. Usa los helpers `tableExists()` / `columnExists()` para que la migración sea re-ejecutable. ### Siembra y plantillas `server/index.ts` llama `ensureSeed()` al arrancar: crea el admin de plataforma (`ensurePlatformAdmin()`) y, si no hay ningún negocio, siembra el demo "Lumière". + +**Todo negocio nuevo debe nacer con `slug`, `working_hours` y `timezone` en el propio `INSERT`.** +Los valores viven en [server/lib/businessDefaults.ts](server/lib/businessDefaults.ts) +(`DEFAULT_WORKING_HOURS`, `uniqueSlug`) y hay **tres** sitios que crean negocios — +`server/index.ts` (`ensureSeed`), `server/scripts/seed.ts` (`npm run seed`) y +`server/routes/admin.ts` (alta desde la consola): si añades otro, usa el mismo módulo. El backfill +de esos campos vivía solo dentro de `migrateV2ToV3`/`migrateV3ToV4`, y las migraciones corren al +importar `db.ts`, o sea **antes** de que exista la fila: un negocio sembrado después quedaba con +`working_hours = NULL` (cero franjas agendables en cualquier fecha) y `slug = NULL` +(`/b/:slug` → 404). `migrateV4ToV5()` repara los que ya quedaron rotos. + `seedBusiness({ businessId, template, ownerEmail, ownerName })` de [server/scripts/seed.ts](server/scripts/seed.ts) es reutilizable: lo usa tanto el arranque como el alta/reset de negocios desde `/api/admin`. Las plantillas (`estetica-spa`, `barberia`, `clinica`, @@ -147,6 +160,25 @@ de SQLite (UTC) atribuye mal las citas de la tarde/noche. Regla: - La tz sale de `businesses.timezone`, con default `America/Mexico_City` (ver `bizTz()` en [server/routes/me.ts](server/routes/me.ts)). Todos los helpers son puros y aceptan el instante explícito, por eso son testeables (`server/lib/time.test.ts`). +- **`new Date(y, m, d, hh, mm)` está prohibido en lógica de negocio**, igual que `date('now')`. + Ese constructor resuelve el reloj de pared en la tz del **proceso**, no en la del negocio. En dev + (Windows en `America/Mexico_City`) coincide y todo pasa; en el contenedor de producción + (`node:22-slim`, sin `TZ`) el proceso corre en UTC y la jornada entera se desplaza 6 h — un + negocio de 09:00–20:00 se publicaba como 03:00–14:00 hora de México, de modo que a partir de la + 1 PM el endpoint de slots devolvía la lista vacía y no se podía reservar. Usa + `wallToUtcDate(tz, …)` y `bizDateISO(instant, tz)` de [server/lib/time.ts](server/lib/time.ts). + `isoDateStr`/`isoDayOfWeek` de `scheduling.ts` quedan marcados `@deprecated` por lo mismo. +- Por eso **toda la API de `scheduling.ts` lleva `tz` explícita**: `getWorkingHoursForDate(empWh, + bizWh, dateIso, tz)`, `getExistingBusy(db, ids, dateIso, tz)`, `isAvailable(db, emp, s, e, tz)`, + `pickBestSlotEmployee(…, tz)` y `AutoAssignCtx.tz`. No añadas sobrecargas sin `tz`. +- `getExistingBusy` acota el día con `bizDayBoundsIsoFor(tz, dateIso)`, no concatenando + `` `${dateIso}T00:00:00` ``: `start_at` se guarda en UTC, así que una cita de las 19:00 de México + vive en el día UTC siguiente y la versión anterior no la veía — el guard anti doble-reserva era + ciego a toda la tarde. +- `server/lib/scheduling.test.ts` fija `process.env.TZ = "UTC"` en su primera línea y usa negocios + en `America/Mexico_City`: la tz del proceso y la del negocio **nunca** coinciden, así que una + recaída falla en el test y no en producción. `Dockerfile` fija `ENV TZ=UTC` por lo mismo. No las + alinees "para que sea más simple". ### Agendado, auto-asignación y guard anti doble-reserva diff --git a/Dockerfile b/Dockerfile index a633e9b..7245875 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,11 @@ WORKDIR /app ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=3000 +# La zona del contenedor se fija explícitamente a UTC, que es como se almacenan los +# instantes en la base. La lógica de agenda ya no depende de ella (usa +# `businesses.timezone` vía server/lib/time.ts), y dejarla en UTC hace que cualquier +# recaída a la tz del proceso se note de inmediato en vez de esconderse en dev. +ENV TZ=UTC COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force diff --git a/server/db.ts b/server/db.ts index 478105c..5b78250 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,4 +1,5 @@ import { DatabaseSync } from "node:sqlite"; +import { DEFAULT_WORKING_HOURS, uniqueSlug } from "./lib/businessDefaults.ts"; import { fileURLToPath } from "node:url"; import path from "node:path"; import fs from "node:fs"; @@ -167,7 +168,7 @@ function columnExists(table: string, column: string): boolean { /** Migrate an existing v1 DB (single-tenant) to the multi-tenant v2 schema, preserving all data. */ function migrateV1ToV2() { - if (getMeta("schema_version") >= "2") return; + if (schemaVersion() >= 2) return; if (!tableExists("users")) { setMeta("schema_version", "2"); return; @@ -224,7 +225,7 @@ function migrateV1ToV2() { /** v4: auto-assign specialist, business & employee working hours, employee specialties/efficiency. */ function migrateV3ToV4() { - if (getMeta("schema_version") >= "4") return; + if (schemaVersion() >= 4) return; const bizCols: [string, string][] = [ ["auto_assign_specialist", "INTEGER NOT NULL DEFAULT 0"], @@ -243,12 +244,9 @@ function migrateV3ToV4() { 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, - }); + // Backfill business working_hours (misma definición que usan el seed y el alta: + // server/lib/businessDefaults.ts — no la dupliques aquí). + const DEFAULT_WH = DEFAULT_WORKING_HOURS; 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); @@ -262,11 +260,45 @@ export function runMigrations() { migrateV1ToV2(); migrateV2ToV3(); migrateV3ToV4(); + migrateV4ToV5(); +} + +/** Versión de esquema como NÚMERO. Compararla como texto hacía que `"10" >= "2"` + * fuera false y reejecutara las migraciones antiguas al llegar a dos dígitos. */ +function schemaVersion(): number { + return Number(getMeta("schema_version") || "0") || 0; +} + +/** + * v5: repara los negocios que nacieron sin `slug` ni `working_hours`. + * + * Los backfills de v3 (slug) y v4 (working_hours) solo alcanzaban a las filas que ya + * existían cuando corrió la migración. Cualquier negocio creado después —el demo de + * una instalación nueva, o un alta desde /api/admin— quedaba con ambos campos en NULL: + * sin página pública de reservas (404 en /b/:slug) y sin ninguna franja agendable. + * El default ya se aplica en el INSERT (server/lib/businessDefaults.ts); esta + * migración arregla los que quedaron rotos. Idempotente: solo toca filas NULL/vacías. + */ +function migrateV4ToV5() { + if (schemaVersion() >= 5) return; + + const noWh = db.prepare(`SELECT id FROM businesses WHERE working_hours IS NULL OR working_hours = ''`).all() as { id: number }[]; + const updWh = db.prepare(`UPDATE businesses SET working_hours = ? WHERE id = ?`); + for (const b of noWh) updWh.run(DEFAULT_WORKING_HOURS, b.id); + + const noSlug = db.prepare(`SELECT id, name FROM businesses WHERE slug IS NULL OR slug = ''`).all() as { id: number; name: string }[]; + const updSlug = db.prepare(`UPDATE businesses SET slug = ? WHERE id = ?`); + for (const b of noSlug) updSlug.run(uniqueSlug(db, b.name, b.id), b.id); + + if (noWh.length || noSlug.length) { + console.log(`[migrate v5] reparados ${noWh.length} horarios y ${noSlug.length} slugs de negocio.`); + } + setMeta("schema_version", "5"); } /** v3: cancellation policy, commissions, cash register, notifications/reminders, booking slug. */ function migrateV2ToV3() { - if (getMeta("schema_version") >= "3") return; + if (schemaVersion() >= 3) return; // Business-level settings (cancellation policy + deposit + booking enabled) const bizCols: [string, string][] = [ diff --git a/server/index.ts b/server/index.ts index b9cdb26..ed6b797 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,4 +1,6 @@ import express from "express"; +import { DEFAULT_WORKING_HOURS } from "./lib/businessDefaults.ts"; +import { DEFAULT_TZ } from "./lib/time.ts"; import cors from "cors"; import path from "node:path"; import fs from "node:fs"; @@ -29,12 +31,25 @@ function ensureSeed() { const row = db.prepare(`SELECT COUNT(*) c FROM businesses`).get() as { c: number }; if (row.c === 0) { console.log("[seed] Base de datos vacía — creando negocio demo y admin…"); + // slug, working_hours y timezone se fijan AQUÍ, en el INSERT. Los backfills de + // migración corren al importar db.ts, o sea antes de que exista esta fila: un + // negocio sembrado sin ellos queda sin página pública (/b/:slug → 404) y sin + // ninguna franja agendable. Ver server/lib/businessDefaults.ts. + const NAME = "Lumière Estética & Spa"; + // Slug FIJO, no derivado del nombre: es la URL pública ya publicada + // (/b/mi-negocio-demo) y el volumen de producción se recrea en cada + // redespliegue, así que un slug derivado la rompería en cada deploy. + // Cámbialo desde Ajustes si el negocio deja de ser el demo. + const DEMO_SLUG = "mi-negocio-demo"; const biz = db .prepare( - `INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status) - VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active') RETURNING id` + `INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status, slug, working_hours, timezone) + VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active', ?, ?, ?) RETURNING id` ) - .get("Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX") as { id: number }; + .get( + NAME, "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX", + DEMO_SLUG, DEFAULT_WORKING_HOURS, DEFAULT_TZ + ) as { id: number }; seedBusiness({ businessId: biz.id, template: getTemplate(DEFAULT_TEMPLATE_KEY)!, diff --git a/server/lib/businessDefaults.ts b/server/lib/businessDefaults.ts new file mode 100644 index 0000000..7396797 --- /dev/null +++ b/server/lib/businessDefaults.ts @@ -0,0 +1,49 @@ +// server/lib/businessDefaults.ts +// +// Valores por defecto de un negocio recién creado. Existen porque el backfill de +// `slug` y `working_hours` vivía SOLO dentro de las migraciones (migrateV2ToV3 y +// migrateV3ToV4), y las migraciones corren al importar db.ts, es decir **antes** de +// que `ensureSeed()` inserte ningún negocio. Resultado en una instalación nueva: +// +// • `working_hours = NULL` → `getWorkingHoursForDate` devuelve null para todos los +// días → el endpoint de slots responde `[]` en cualquier fecha y no se puede +// reservar nada. +// • `slug = NULL` → `publicBusiness()` nunca encuentra el negocio → /b/:slug da 404. +// +// Un backfill de migración no puede cubrir filas que aún no existen: el default tiene +// que aplicarse en el INSERT. Este módulo es la única definición, compartida por el +// seed, el alta desde /api/admin y las propias migraciones. + +import type { DatabaseSync } from "node:sqlite"; + +/** Lun-Vie 09:00-20:00, fin de semana cerrado. */ +export const DEFAULT_WORKING_HOURS = 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, +}); + +/** "Lumière Estética & Spa" → "lumiere-estetica-spa". Puro. */ +export function slugify(s: string): string { + return (s || "negocio") + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60) || "negocio"; +} + +/** Slug único para `name`, añadiendo sufijo numérico si ya está tomado. */ +export function uniqueSlug(db: DatabaseSync, name: string, excludeId: number | null = null): string { + const base = slugify(name); + let slug = base; + let n = 2; + const taken = db.prepare(`SELECT id FROM businesses WHERE slug = ? AND id IS NOT ?`); + while (taken.get(slug, excludeId)) slug = `${base}-${n++}`; + return slug; +} diff --git a/server/lib/scheduling.test.ts b/server/lib/scheduling.test.ts index 25910ce..7498cea 100644 --- a/server/lib/scheduling.test.ts +++ b/server/lib/scheduling.test.ts @@ -1,4 +1,12 @@ // server/lib/scheduling.test.ts +// +// La suite corre con TZ=UTC A PROPÓSITO, y los casos usan negocios en +// America/Mexico_City: la tz del proceso y la del negocio nunca coinciden, así que +// cualquier cálculo que se ancle a la del proceso falla aquí y no en producción. +// (Se fija en runtime en vez de en el script de npm porque `TZ=x cmd` no es +// portable a cmd.exe en Windows.) +process.env.TZ = "UTC"; + import { test } from "node:test"; import assert from "node:assert/strict"; import { @@ -8,6 +16,13 @@ import { } from "./scheduling.ts"; import type { CandidateInfo, BusyWindow } from "./scheduling.ts"; +const MX = "America/Mexico_City"; +/** Instante UTC de un reloj de pared mexicano. Los tests no deben depender de la tz del proceso. */ +function mx(dateIso: string, hh: number, mm = 0): number { + const [y, mo, d] = dateIso.split("-").map(Number); + return Date.UTC(y, mo - 1, d, hh + 6, mm); // México = UTC-6 todo el año desde 2022 +} + 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"); @@ -26,16 +41,9 @@ test("isoDayOfWeek: lunes=1, domingo=7", () => { 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 + assert.equal(getWorkingHoursForDate(null, biz, "2026-07-25", MX), null); // sábado }); test("normalizeText/tokens: quita acentos y lowercase", () => { @@ -106,8 +114,8 @@ test("scoreCandidate: fairness — libre+ineficiente vence a ocupado+eficiente ( test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no por id)", () => { const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } })); - const startMs = new Date(2026, 6, 27, 13, 0).getTime(); // lunes, dentro de horario - const endMs = new Date(2026, 6, 27, 14, 0).getTime(); + const startMs = mx("2026-07-27", 13); // lunes, dentro de horario + const endMs = mx("2026-07-27", 14); // A (id menor, senior): especialidad coincide (sm=1), ocupado media jornada (load=0.5) // → 40·1 + 20·0.5 + 40·0.5 = 70 // B (id mayor, junior): sin especialidad (sm=0.5), libre (load=0) @@ -115,23 +123,23 @@ test("pickBestSlotEmployee: empate en score → gana el de menor loadBalance (no const A: CandidateInfo = { id: 1, name: "Senior", color: "#fff", role: "stylist", specialties: ["corte"], efficiency_score: 50, empWh: null }; const B: CandidateInfo = { id: 2, name: "Junior", color: "#fff", role: "stylist", specialties: [], efficiency_score: 50, empWh: null }; const busy = new Map([ - [1, [{ startMs: new Date(2026, 6, 27, 9, 0).getTime(), endMs: new Date(2026, 6, 27, 13, 0).getTime() }]], + [1, [{ startMs: mx("2026-07-27", 9), endMs: mx("2026-07-27", 13) }]], [2, []], ]); - const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs); + const best = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX); assert.equal(best?.id, 2, "el junior libre gana el empate por menor loadBalance, NO por id menor"); }); test("pickBestSlotEmployee: empate total (score+load) → decisión determinista y reproducible", () => { const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "17:00" } })); - const startMs = new Date(2026, 6, 27, 9, 0).getTime(); - const endMs = new Date(2026, 6, 27, 10, 0).getTime(); + const startMs = mx("2026-07-27", 9); + const endMs = mx("2026-07-27", 10); // Dos candidatos idénticos salvo el id → mismo score y mismo loadBalance → hash decide. const A: CandidateInfo = { id: 10, name: "Diez", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null }; const B: CandidateInfo = { id: 20, name: "Veinte", color: "#fff", role: "s", specialties: ["corte"], efficiency_score: 50, empWh: null }; const busy = new Map([[10, []], [20, []]]); - const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs); - const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs); + const r1 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX); + const r2 = pickBestSlotEmployee([A, B], busy, bizWh, { name: "Corte", category: "Cabello" }, startMs, endMs, MX); assert.ok(r1 && (r1.id === 10 || r1.id === 20), "debe elegir uno de los dos candidatos"); assert.equal(r1?.id, r2?.id, "misma entrada → misma decisión (hash determinista, reproducible)"); }); @@ -143,3 +151,58 @@ test("BUSY_STATUSES: scheduled+completed sí cuentan; no_show NO (silla vacía)" assert.equal(s.includes("no_show"), false); assert.equal(s.length, 2); }); + +// ── Regresión: la ventana laboral debe anclarse a la tz del NEGOCIO ──────────── +// Estos tests fallaban en producción (contenedor node:22-slim = UTC) y pasaban en +// dev (Windows = America/Mexico_City), porque getWorkingHoursForDate construía la +// ventana con `new Date(y, m, d, hh, mm)`, es decir en la tz del *proceso*. +// Corren bajo `TZ=UTC` en test:unit precisamente para que el desfase no se esconda. + +test("getWorkingHoursForDate: ancla la ventana a la tz del negocio, no a la del proceso", () => { + const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } })); + const r = getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City"); // lunes + assert.ok(r, "el lunes debe estar abierto"); + // 09:00 CST = 15:00Z ; 20:00 CST = 02:00Z del día siguiente + assert.equal(new Date(r!.startMs).toISOString(), "2026-08-31T15:00:00.000Z"); + assert.equal(new Date(r!.endMs).toISOString(), "2026-09-01T02:00:00.000Z"); +}); + +test("getWorkingHoursForDate: la misma fecha en otra tz da otra ventana absoluta", () => { + const biz = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } })); + const mx = getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City"); + const utc = getWorkingHoursForDate(null, biz, "2026-08-31", "UTC"); + assert.notEqual(mx!.startMs, utc!.startMs); + assert.equal(new Date(utc!.startMs).toISOString(), "2026-08-31T09:00:00.000Z"); +}); + +test("getWorkingHoursForDate: el día de la semana sale de la fecha natural, no de un Date local", () => { + const biz = parseWorkingHours(JSON.stringify({ 6: null, 7: null, 1: { start: "09:00", end: "20:00" } })); + assert.equal(getWorkingHoursForDate(null, biz, "2026-08-29", "America/Mexico_City"), null); // sábado + assert.equal(getWorkingHoursForDate(null, biz, "2026-08-30", "America/Mexico_City"), null); // domingo + assert.ok(getWorkingHoursForDate(null, biz, "2026-08-31", "America/Mexico_City")); // lunes +}); + +test("getWorkingHoursForDate: empleado tiene prioridad sobre negocio (tz-explícito)", () => { + 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, "2026-08-31", "America/Mexico_City"); + assert.equal(new Date(r!.startMs).toISOString(), "2026-08-31T16:00:00.000Z"); // 10:00 CST +}); + +test("pickBestSlotEmployee: un slot de la tarde del negocio sigue dentro de la ventana", () => { + // 19:00 México = 01:00Z del día siguiente. Con la ventana mal anclada este slot + // quedaba fuera de rango y el endpoint devolvía la tarde vacía. + const bizWh = parseWorkingHours(JSON.stringify({ 1: { start: "09:00", end: "20:00" } })); + const cand: CandidateInfo = { + id: 1, name: "Ana", color: "#000", role: "Estilista", + specialties: ["Cabello"], efficiency_score: 80, empWh: null, + }; + const startMs = Date.parse("2026-09-01T01:00:00Z"); // lunes 31/08 19:00 México + const endMs = startMs + 60 * 60000; + const busy = new Map(); + const best = pickBestSlotEmployee( + [cand], busy, bizWh, { name: "Corte", category: "Cabello" }, + startMs, endMs, "America/Mexico_City" + ); + assert.equal(best?.id, 1); +}); diff --git a/server/lib/scheduling.ts b/server/lib/scheduling.ts index 325a5a8..a4111bc 100644 --- a/server/lib/scheduling.ts +++ b/server/lib/scheduling.ts @@ -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; // 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 { +/** + * 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 { const map = new Map(); 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; diff --git a/server/lib/time.ts b/server/lib/time.ts index 3aa1544..bf644f7 100644 --- a/server/lib/time.ts +++ b/server/lib/time.ts @@ -115,3 +115,34 @@ export function wallToUtcISO( ): string { return wallToUtcDate(tz, y, mo, d, hh, mm, ss).toISOString(); } + +/** Zona horaria por defecto de los negocios (México). Única definición. */ +export const DEFAULT_TZ = "America/Mexico_City"; + +/** + * Día ISO de la semana (1=Lun … 7=Dom) de una fecha natural "YYYY-MM-DD". + * Deriva del string, nunca de un `Date` local, así que es independiente de + * la tz del proceso (en un contenedor UTC `new Date("…").getDay()` puede + * caer en el día anterior). + */ +export function isoDowFromDateStr(dateIso: string): number { + const [y, m, d] = dateIso.split("-").map(Number); + const j = new Date(Date.UTC(y, m - 1, d)).getUTCDay(); // 0=Dom..6=Sáb + return j === 0 ? 7 : j; +} + +/** + * Límites UTC del día natural `dateIso` **en la tz del negocio**, en formato + * ISO-Z. Emparéjalo con `start_at >= ? AND start_at <= ?` (comparación + * lexicográfica sobre el formato canónico almacenado). + * + * A diferencia de `bizDayBoundsIso`, que trabaja con desplazamientos respecto + * de "hoy", este acepta la fecha explícita que pide el cliente. + */ +export function bizDayBoundsIsoFor(tz: string, dateIso: string): { start: string; end: string } { + const [y, m, d] = dateIso.split("-").map(Number); + return { + start: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 0, 0, 0)), + end: toIsoUtc(wallToUtcDate(tz || DEFAULT_TZ, y, m, d, 23, 59, 59)), + }; +} diff --git a/server/routes/admin.ts b/server/routes/admin.ts index 626ee1a..0b15ba7 100644 --- a/server/routes/admin.ts +++ b/server/routes/admin.ts @@ -1,4 +1,6 @@ import { Router } from "express"; +import { DEFAULT_WORKING_HOURS, uniqueSlug } from "../lib/businessDefaults.ts"; +import { DEFAULT_TZ } from "../lib/time.ts"; import { db } from "../db.ts"; import { adminOnly, err, type AuthedRequest } from "../lib/auth.ts"; import { TEMPLATES, getTemplate, type TemplateDef } from "../lib/templates.ts"; @@ -103,8 +105,8 @@ adminRouter.post("/businesses", (req: AuthedRequest, res) => { const biz = db .prepare( - `INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template) - VALUES (?, ?, ?, ?, ?, 'active', ?) RETURNING id` + `INSERT INTO businesses (name, industry, currency, currency_symbol, plan, status, template, slug, working_hours, timezone) + VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?) RETURNING id` ) .get( name, @@ -112,7 +114,12 @@ adminRouter.post("/businesses", (req: AuthedRequest, res) => { currency || tpl.currency || "MXN", currency_symbol || tpl.currency_symbol || "$", plan || "trial", - tpl.key + tpl.key, + // Sin estos tres, el tenant nace sin página pública (/b/:slug → 404) y sin + // ninguna franja agendable, porque el backfill solo corre en migraciones. + uniqueSlug(db, name), + DEFAULT_WORKING_HOURS, + DEFAULT_TZ ) as { id: number }; const result = seedBusiness({ diff --git a/server/routes/appointments.ts b/server/routes/appointments.ts index 8dab331..09a1dbf 100644 --- a/server/routes/appointments.ts +++ b/server/routes/appointments.ts @@ -3,6 +3,7 @@ import { db } from "../db.ts"; import type { AuthedRequest } from "../lib/auth.ts"; import { err } from "../lib/auth.ts"; import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts"; +import { DEFAULT_TZ } from "../lib/time.ts"; export const appointmentsRouter = Router(); @@ -138,18 +139,23 @@ appointmentsRouter.post("/", (req: AuthedRequest, res) => { if (!empId && req.user!.role === "employee" && req.user!.employee_id) { empId = req.user!.employee_id; } + // La tz del negocio manda: la ventana laboral y el rango del día se anclan a + // ella, no a la del proceso (el contenedor de producción corre en UTC). + const bizRow = db + .prepare(`SELECT working_hours wh, timezone tz FROM businesses WHERE id = ?`) + .get(req.user!.business_id) as { wh?: string; tz?: string } | undefined; + const tz = bizRow?.tz || DEFAULT_TZ; 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: Number(req.user!.business_id), serviceId: service.id, serviceName: service.name, serviceCategory: service.category, - startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any, + startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any, tz, }); 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" }; + if (!isAvailable(db, empId, startMs, endMs, tz)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" }; const inserted = db .prepare( diff --git a/server/routes/booking.ts b/server/routes/booking.ts index e812d12..b93ef46 100644 --- a/server/routes/booking.ts +++ b/server/routes/booking.ts @@ -5,6 +5,7 @@ import { pickBestSlotEmployee, autoAssign, isAvailable, runInTransaction, type WorkingHoursMap, } from "../lib/scheduling.ts"; +import { DEFAULT_TZ } from "../lib/time.ts"; export const bookingRouter = Router(); @@ -39,8 +40,10 @@ bookingRouter.get("/:slug/slots", (req, res) => { if (!biz || !biz.booking_enabled) return res.status(404).json({ error: "No disponible" }); const serviceId = Number(req.query.service_id); const employeeId = req.query.employee_id ? Number(req.query.employee_id) : null; - const date = req.query.date as string; // YYYY-MM-DD + const date = req.query.date as string; // YYYY-MM-DD, en la tz del negocio if (!serviceId || !date) return res.status(400).json({ error: "service_id y date son obligatorios" }); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return res.status(400).json({ error: "Fecha inválida (formato AAAA-MM-DD)" }); + const tz = biz.timezone || DEFAULT_TZ; const service = db.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ? AND active = 1`).get(serviceId, biz.id) as any; if (!service) return res.status(400).json({ error: "Servicio no válido" }); @@ -64,17 +67,16 @@ bookingRouter.get("/:slug/slots", (req, res) => { // 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(); + const candBusyMap = useRanking ? getExistingBusy(db, candInfos.map((c) => c.id), date, tz) : 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); + ? (getWorkingHoursForDate(singleEmpWh as WorkingHoursMap | null, bizWh, date, tz) ?? getWorkingHoursForDate(null, bizWh, date, tz)) + : getWorkingHoursForDate(null, bizWh, date, tz); const now = new Date(); const slots: { time: string; iso: string; employee_id: number; employee_name: string }[] = []; @@ -88,7 +90,7 @@ bookingRouter.get("/:slug/slots", (req, res) => { // citas existentes del día para el caso de especialista fijo const existing = employeeId - ? (getExistingBusy(db, [employeeId], date).get(employeeId) ?? []) + ? (getExistingBusy(db, [employeeId], date, tz).get(employeeId) ?? []) : []; for (let t = dayStart; t + dur * 60000 <= dayEnd; t += 30 * 60000) { @@ -97,7 +99,7 @@ bookingRouter.get("/:slug/slots", (req, res) => { 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); + chosen = pickBestSlotEmployee(candInfos, candBusyMap, bizWh, { name: service.name, category: service.category }, slotStart, slotEnd, tz); } else { const busy = existing.some((b) => overlapsRange(slotStart, slotEnd, b.startMs, b.endMs)); if (!busy) { @@ -107,7 +109,9 @@ bookingRouter.get("/:slug/slots", (req, res) => { } if (chosen) { slots.push({ - time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true }), + // La etiqueta se rinde en la tz del NEGOCIO: sin `timeZone` salía en la del + // proceso (UTC en producción) y no coincidía con la hora real del `iso`. + time: new Date(t).toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", hour12: true, timeZone: tz }), iso: new Date(t).toISOString(), employee_id: chosen.id, employee_name: chosen.name, @@ -126,6 +130,7 @@ function overlapsRange(aStart: number, aEnd: number, bStart: number, bEnd: numbe 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 tz = biz.timezone || DEFAULT_TZ; 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; @@ -149,7 +154,7 @@ bookingRouter.post("/:slug/book", (req, res) => { 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, + startMs, endMs, bizWh, tz, }); if (!aa) throw { status: 409, error: "ESE_HORARIO_OCUPADO" }; empId = aa.employeeId; @@ -159,7 +164,7 @@ bookingRouter.post("/:slug/book", (req, res) => { 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" }; + if (!isAvailable(db, empId, startMs, endMs, tz)) throw { status: 409, error: "ESE_HORARIO_OCUPADO" }; reasons = ["Tu especialista elegido"]; } diff --git a/server/scripts/booking-e2e.mjs b/server/scripts/booking-e2e.mjs index 6740dec..fd198e8 100644 --- a/server/scripts/booking-e2e.mjs +++ b/server/scripts/booking-e2e.mjs @@ -27,13 +27,21 @@ check("tiene slug", !!slug, String(slug)); 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}`); +// 3) Slots disponibles. +// Se busca el próximo día ABIERTO en vez de asumir "mañana": el negocio demo cierra +// sábado y domingo, así que fijar mañana hacía que la suite fallara cada viernes y +// sábado por calendario, no por un defecto del producto. +let date = null; +let slotsRes = null; +for (let i = 1; i <= 8; i++) { + const d = new Date(Date.now() + i * 86400000).toISOString().slice(0, 10); + const r = await req("GET", `/public/${slug}/slots?service_id=${svc.id}&date=${d}`); + if (i === 1) check("slots devuelve lista", Array.isArray(r.json.slots)); + if (Array.isArray(r.json.slots) && r.json.slots.length > 0) { date = d; slotsRes = r.json; break; } +} +check("hay slots en algún día abierto de la próxima semana", !!slotsRes, date ? `fecha ${date}` : "ningún día con slots"); +if (!slotsRes) { console.error("Sin slots en 8 días: revisa working_hours del negocio demo."); process.exit(1); } // 4) Reservar (auto-asignación, sin employee_id) const slot = slotsRes.slots[0]; diff --git a/server/scripts/seed.ts b/server/scripts/seed.ts index f637a97..0357b6c 100644 --- a/server/scripts/seed.ts +++ b/server/scripts/seed.ts @@ -1,4 +1,6 @@ import { db } from "../db.ts"; +import { DEFAULT_WORKING_HOURS, uniqueSlug } from "../lib/businessDefaults.ts"; +import { DEFAULT_TZ } from "../lib/time.ts"; import { pathToFileURL } from "node:url"; import { TEMPLATES, getTemplate, DEFAULT_TEMPLATE_KEY, type TemplateDef } from "../lib/templates.ts"; @@ -328,8 +330,14 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) let biz = db.prepare(`SELECT id FROM businesses WHERE name = ?`).get("Lumière Estética & Spa") as { id: number } | undefined; if (!biz) { biz = db - .prepare(`INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status) VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active') RETURNING id`) - .get("Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX") as { id: number }; + .prepare( + `INSERT INTO businesses (name, industry, currency, currency_symbol, phone, address, plan, status, slug, working_hours, timezone) + VALUES (?, ?, ?, ?, ?, ?, 'trial', 'active', ?, ?, ?) RETURNING id` + ) + .get( + "Lumière Estética & Spa", "Estética y Spa", "MXN", "$", "+52 55 1234 5678", "Av. Reforma 245, CDMX", + uniqueSlug(db, "Lumière Estética & Spa"), DEFAULT_WORKING_HOURS, DEFAULT_TZ + ) as { id: number }; seedBusiness({ businessId: biz.id, template: getTemplate(DEFAULT_TEMPLATE_KEY)!, ownerEmail: "owner@agendamax.demo", ownerName: "Daniela Reyes" }); console.log(`[seed] Created default business #${biz.id} with estetica-spa template + admin user.`); } else { diff --git a/src/components/MonthCalendar.tsx b/src/components/MonthCalendar.tsx index efa383b..d02453c 100644 --- a/src/components/MonthCalendar.tsx +++ b/src/components/MonthCalendar.tsx @@ -59,8 +59,12 @@ export function MonthCalendar({ 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; + // `new Date(y, m, 0)` es el ÚLTIMO día del mes anterior. Se comparaba contra el + // día 1 del mes visible, que siempre es mayor, así que `canPrev` era false + // permanentemente: como BookingPage siempre pasa `min={todayStr()}`, la flecha de + // "mes anterior" nunca se podía pulsar y el cliente que avanzaba de mes quedaba + // atrapado. La comparación correcta es contra `minDate`. + const canPrev = !minDate || new Date(cursor.getFullYear(), cursor.getMonth(), 0) >= minDate; const canNext = !maxDate || new Date(cursor.getFullYear(), cursor.getMonth() + 2, 0) <= new Date(maxDate.getFullYear(), maxDate.getMonth() + 1, 0); return ( diff --git a/src/lib/format.ts b/src/lib/format.ts index 6c999ae..87f1c98 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -24,11 +24,24 @@ export function formatDate(iso: string | null | undefined, opts: Intl.DateTimeFo return d.toLocaleDateString("es-MX", { day: "numeric", month: "short", year: "numeric", ...opts }); } -export function formatTime(iso: string | null) { +/** + * Hora de un instante. `tz` fija la zona: en el panel se omite (el usuario está en + * el negocio), pero en la reserva pública es obligatoria — un cliente que reserva + * desde otra zona horaria debe ver la hora **del negocio**, no la suya. + */ +export function formatTime(iso: string | null, tz?: string) { if (!iso) return "—"; const d = new Date(iso); if (isNaN(d.getTime())) return "—"; - return d.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit" }); + return d.toLocaleTimeString("es-MX", { hour: "2-digit", minute: "2-digit", ...(tz ? { timeZone: tz } : {}) }); +} + +/** Hora del día (0-23) de un instante en la zona `tz`. Para agrupar mañana/tarde + * sin parsear la cadena ya localizada (que trae "p.m." y rompe un /PM/i). */ +export function hourInTz(iso: string, tz: string): number { + const h = new Intl.DateTimeFormat("en-GB", { hour: "2-digit", hour12: false, timeZone: tz }).format(new Date(iso)); + const n = Number(h); + return Number.isFinite(n) ? n % 24 : 0; } export function formatRelative(iso: string | null) { diff --git a/src/lib/publicApi.ts b/src/lib/publicApi.ts index f8e5359..6d68142 100644 --- a/src/lib/publicApi.ts +++ b/src/lib/publicApi.ts @@ -10,6 +10,8 @@ export interface PublicBusiness { currency_symbol: string; phone: string | null; address: string | null; + /** Zona IANA del negocio. Toda hora mostrada al cliente se rinde en ella. */ + timezone: string; booking_enabled: boolean; cancel_window_hours: number; cancel_penalty_pct: number; diff --git a/src/pages/public/BookingPage.tsx b/src/pages/public/BookingPage.tsx index ac37588..afd7ae2 100644 --- a/src/pages/public/BookingPage.tsx +++ b/src/pages/public/BookingPage.tsx @@ -28,7 +28,7 @@ import { } from "../../lib/publicApi"; import { Spinner } from "../../components/ui"; import { MonthCalendar } from "../../components/MonthCalendar"; -import { cn, formatCurrency, formatDate, formatTime } from "../../lib/format"; +import { cn, formatCurrency, formatDate, formatTime, hourInTz } from "../../lib/format"; function todayStr() { const d = new Date(); @@ -82,6 +82,9 @@ export default function BookingPage() { const services = data?.services ?? []; const employees = data?.employees ?? []; + // Toda hora que ve el cliente se rinde en la zona del NEGOCIO. Si se cae a la del + // navegador, alguien que reserva desde otro huso ve una hora que no es la de su cita. + const tz = business?.timezone || "America/Mexico_City"; const autoAssign = !!business?.auto_assign_specialist; const steps = useMemo(() => stepsFor(autoAssign), [autoAssign]); const stepCount = steps.length; @@ -312,6 +315,7 @@ export default function BookingPage() { isError={slotsQuery.isError} selectedSlot={selectedSlot} onPick={pickSlot} + tz={tz} /> )} @@ -349,8 +353,8 @@ export default function BookingPage() { summary={ <> - - + + {!autoAssign && } } @@ -373,7 +377,7 @@ export default function BookingPage() { selectedService ? formatCurrency(selectedService.price, currency) : undefined } serviceName={selectedService?.name} - slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short" })} · ${selectedSlot.time}` : undefined} + slotLabel={selectedSlot ? `${formatDate(selectedSlot.iso, { weekday: "short", day: "numeric", month: "short", timeZone: tz })} · ${formatTime(selectedSlot.iso, tz)}` : undefined} /> @@ -642,6 +646,7 @@ function DateTimePicker({ isError, selectedSlot, onPick, + tz, }: { date: string; onDate: (d: string) => void; @@ -652,19 +657,19 @@ function DateTimePicker({ isError: boolean; selectedSlot: PublicSlot | null; onPick: (s: PublicSlot) => void; + tz: string; }) { + // La hora sale del instante `iso` resuelto en la tz del negocio. Antes se parseaba + // `s.time`, ya localizado: es-MX rinde "05:00 p.m." y `/PM/i` no casa con "p.m.", + // así que TODOS los slots de la tarde se agrupaban bajo "Mañana". 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); + (hourInTz(s.iso, tz) < 12 ? morning : afternoon).push(s); } return { morning, afternoon }; - }, [slots]); + }, [slots, tz]); return (
@@ -707,8 +712,8 @@ function DateTimePicker({ )} {!loading && !isError && slots.length > 0 && (
- - + +
)}
@@ -722,11 +727,13 @@ function SlotGroup({ slots, selectedSlot, onPick, + tz, }: { title: string; slots: PublicSlot[]; selectedSlot: PublicSlot | null; onPick: (s: PublicSlot) => void; + tz: string; }) { if (slots.length === 0) return null; return ( @@ -750,7 +757,7 @@ function SlotGroup({ : "border-slate-200 bg-white text-slate-700 hover:border-brand-400 hover:bg-brand-50" )} > - {slot.time} + {formatTime(slot.iso, tz)} ); })}