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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user