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:
co-authored by
Claude Opus 5
parent
0069d23744
commit
f48a9ac3bf
@@ -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 (
|
||||
|
||||
+15
-2
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</StepShell>
|
||||
)}
|
||||
@@ -349,8 +353,8 @@ export default function BookingPage() {
|
||||
summary={
|
||||
<>
|
||||
<SummaryRow label="Servicio" value={selectedService.name} />
|
||||
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long" })} />
|
||||
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso)} />
|
||||
<SummaryRow label="Fecha" value={formatDate(selectedSlot.iso, { weekday: "long", timeZone: tz })} />
|
||||
<SummaryRow label="Hora" value={formatTime(selectedSlot.iso, tz)} />
|
||||
{!autoAssign && <SummaryRow label="Especialista" value={selectedSlot.employee_name} />}
|
||||
</>
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<FooterNote name={business.name} />
|
||||
@@ -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 (
|
||||
<div className="md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] md:items-start md:gap-8">
|
||||
@@ -707,8 +712,8 @@ function DateTimePicker({
|
||||
)}
|
||||
{!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} />
|
||||
<SlotGroup title="Mañana" slots={morning} selectedSlot={selectedSlot} onPick={onPick} tz={tz} />
|
||||
<SlotGroup title="Tarde" slots={afternoon} selectedSlot={selectedSlot} onPick={onPick} tz={tz} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -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)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user