feat(timezone): bridge usa o fuso da clínica no "agora" (Fase 2a)

Helpers de TZ (partsNow/tzOffsetMin/zonedToUtc/getClinicaTZ; Brasil sem DST).
- Janelas de contato (contatoPermitidoAgora/proximaJanela) passam a receber o
  "agora" no fuso da clínica; proxima_janela_ts vira o instante REAL no fuso.
  Usado em /dentista-contato, /responsavel-contato, /contatos.
- /slots: "hoje"/"agora" (filtro de passado) no fuso da clínica, não do container.

Verificado E2E: clínica=Campo_Grande(UTC-4), container UTC-3 21h → janela às 20h
(hora da clínica) libera; às 21h (hora do container) bloqueia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-08 02:35:48 +02:00
parent 2d0396fcb1
commit 27dd45efbd
+55 -20
View File
@@ -123,12 +123,38 @@ async function findConflict(db, dentistaId, start, end, excludeId = null) {
return rows[0] || null;
}
// ── Fuso horário por clínica ─────────────────────────────────────────────────────
// Brasil não tem horário de verão → offset estável, dispensa lib de TZ.
const DOW_MAP = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
async function getClinicaTZ(pool, clinicaId) {
try { const { rows } = await pool.query('SELECT timezone FROM clinicas WHERE id=$1', [clinicaId]); return rows[0]?.timezone || 'America/Sao_Paulo'; }
catch { return 'America/Sao_Paulo'; }
}
// "Agora" no fuso da clínica → { year, month, day, hour, minute, dow, hm }.
function partsNow(tz, base = new Date()) {
const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, weekday: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
const p = {}; for (const x of f.formatToParts(base)) p[x.type] = x.value;
const hour = p.hour === '24' ? 0 : +p.hour;
return { year: +p.year, month: +p.month, day: +p.day, hour, minute: +p.minute, dow: DOW_MAP[p.weekday], hm: hour * 60 + (+p.minute) };
}
// Offset do fuso (min) num instante.
function tzOffsetMin(tz, date) {
const f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
const p = {}; for (const x of f.formatToParts(date)) p[x.type] = x.value;
const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, (p.hour === '24' ? 0 : +p.hour), +p.minute, +p.second);
return Math.round((asUTC - date.getTime()) / 60000);
}
// Instante UTC de um wall-clock (y, mon, d, h, min) no fuso tz.
function zonedToUtc(y, mon, d, h, min, tz) {
const guess = Date.UTC(y, mon - 1, d, h, min, 0);
return new Date(guess - tzOffsetMin(tz, new Date(guess)) * 60000);
}
// ── Comunicação interna: janelas de horário permitido p/ contatar um número ──────
const toMin = (t) => { const [h, m] = String(t ?? '').split(':').map(Number); return (h || 0) * 60 + (m || 0); };
// Retorna o 1º número contactável AGORA (dia/horário dentro de alguma janela) ou null.
function contatoPermitidoAgora(numeros, now = new Date()) {
const dow = now.getDay();
const hm = now.getHours() * 60 + now.getMinutes();
// clock = partsNow(tz) da clínica. Retorna o 1º número contactável AGORA ou null.
function contatoPermitidoAgora(numeros, clock) {
const dow = clock.dow, hm = clock.hm;
for (const n of (numeros || [])) {
const tel = String(n?.numero || '').replace(/\D/g, '');
if (tel.length < 10) continue;
@@ -139,10 +165,9 @@ function contatoPermitidoAgora(numeros, now = new Date()) {
}
return null;
}
// Próxima janela permitida (varre os próximos 7 dias) → { label:"segunda-feira às 08:00", ts:ISO } | null.
function proximaJanela(numeros, now = new Date()) {
const nowDow = now.getDay();
const nowHm = now.getHours() * 60 + now.getMinutes();
// Próxima janela permitida → { label, ts:ISO } | null. ts é o instante REAL no fuso tz.
function proximaJanela(numeros, clock, tz) {
const nowDow = clock.dow, nowHm = clock.hm;
let best = null; // { offset, ini, dow, inicio }
for (const n of (numeros || [])) {
if (String(n?.numero || '').replace(/\D/g, '').length < 10) continue;
@@ -158,9 +183,11 @@ function proximaJanela(numeros, now = new Date()) {
}
if (!best) return null;
const quando = best.offset === 0 ? 'hoje' : best.offset === 1 ? 'amanhã' : DIAS_NOME[best.dow];
const dt = new Date(now); dt.setDate(dt.getDate() + best.offset);
const [h, m] = String(best.inicio).split(':').map(Number); dt.setHours(h || 0, m || 0, 0, 0);
return { label: `${quando} às ${best.inicio}`, ts: dt.toISOString() };
// data-alvo = hoje (da clínica) + offset dias, resolvida como instante no fuso tz.
const td = new Date(Date.UTC(clock.year, clock.month - 1, clock.day)); td.setUTCDate(td.getUTCDate() + best.offset);
const [h, m] = String(best.inicio).split(':').map(Number);
const ts = zonedToUtc(td.getUTCFullYear(), td.getUTCMonth() + 1, td.getUTCDate(), h || 0, m || 0, tz).toISOString();
return { label: `${quando} às ${best.inicio}`, ts };
}
export function createAgendaBridge(pool) {
@@ -206,7 +233,13 @@ export function createAgendaBridge(pool) {
// Folgas por data dos dentistas (férias/indisponibilidade).
const folgasBy = await carregarFolgas(pool, clinicaId);
const start0 = new Date(from); start0.setHours(0, 0, 0, 0);
// "Hoje" e "agora" no fuso da CLÍNICA (não do container). Os slots são rótulos
// wall-clock (fmt); comparamos contra o "agora" da clínica expresso no mesmo frame.
const tz = await getClinicaTZ(pool, clinicaId);
const cn = partsNow(tz);
const start0 = req.query.date ? new Date(`${req.query.date}T00:00:00`) : new Date(cn.year, cn.month - 1, cn.day);
start0.setHours(0, 0, 0, 0);
const nowClinic = new Date(cn.year, cn.month - 1, cn.day, cn.hour, cn.minute, 0, 0);
const end0 = new Date(start0); end0.setDate(end0.getDate() + days);
const { rows: busy } = await pool.query(
`SELECT dentistaid, start_time, end_time FROM agendamentos
@@ -216,7 +249,7 @@ export function createAgendaBridge(pool) {
const busyBy = new Map();
for (const bz of busy) { const a = busyBy.get(bz.dentistaid) || []; a.push(bz); busyBy.set(bz.dentistaid, a); }
const now = new Date();
const now = nowClinic;
const slots = [];
for (let i = 0; i < days && slots.length < CANDIDATE_CAP; i++) {
const day = new Date(start0); day.setDate(day.getDate() + i);
@@ -464,8 +497,9 @@ export function createAgendaBridge(pool) {
contato_agora: legacyTel ? { numero: legacyTel, descricao: null } : null, bloqueado_por_janela: false, proxima_janela: null });
}
// Com perfil → só permite contato dentro de uma janela; senão devolve a próxima.
const agora = contatoPermitidoAgora(numeros);
const prox = agora ? null : proximaJanela(numeros);
const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz);
const agora = contatoPermitidoAgora(numeros, clock);
const prox = agora ? null : proximaJanela(numeros, clock, tz);
res.json({ encontrado: true, dentista_id: d.id, nome: d.nome, tem_perfil: true,
telefone: agora ? agora.numero : null, contato_agora: agora,
bloqueado_por_janela: !agora, proxima_janela: prox ? prox.label : null, proxima_janela_ts: prox ? prox.ts : null });
@@ -505,8 +539,9 @@ export function createAgendaBridge(pool) {
}
if (!row) return res.json({ encontrado: false });
const numeros = Array.isArray(row.numeros) ? row.numeros : [];
const agora = contatoPermitidoAgora(numeros);
const prox = agora ? null : proximaJanela(numeros);
const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz);
const agora = contatoPermitidoAgora(numeros, clock);
const prox = agora ? null : proximaJanela(numeros, clock, tz);
res.json({ encontrado: true, usuario_id: row.usuario_id, nome: row.nome, papel: row.role,
telefone: agora ? agora.numero : null, contato_agora: agora,
bloqueado_por_janela: !agora, proxima_janela: prox ? prox.label : null, proxima_janela_ts: prox ? prox.ts : null });
@@ -528,14 +563,14 @@ export function createAgendaBridge(pool) {
LEFT JOIN vinculos v ON v.usuario_id = pc.usuario_id AND v.clinica_id = pc.clinica_id
LEFT JOIN usuarios u ON u.id = pc.usuario_id
WHERE pc.clinica_id = $1`, [clinicaId]);
const now = new Date();
const tz = await getClinicaTZ(pool, clinicaId); const clock = partsNow(tz);
const contatos = [];
for (const r of rows) {
const nums = Array.isArray(r.numeros) ? r.numeros : [];
for (const n of nums) {
if (String(n.numero || '').replace(/\D/g, '').length < 10) continue;
const agora = contatoPermitidoAgora([n], now);
const prox = agora ? null : proximaJanela([n], now);
const agora = contatoPermitidoAgora([n], clock);
const prox = agora ? null : proximaJanela([n], clock, tz);
contatos.push({
nome: r.nome || null, papel: r.role || null,
descricao: n.descricao || null, situacoes: n.situacoes || null,