feat(timezone): exibição no fuso de armazenamento — corrige o -1h (Fase 3)

start_time é naive; o node-pg o serializa no fuso do container (America/Sao_Paulo).
Exibir no fuso do NAVEGADOR (ex.: UTC-4) mostrava -1h (09:30 -> 08:30). Agora o
frontend formata SEMPRE em America/Sao_Paulo (o mesmo frame do armazenamento),
recuperando o wall-clock literal para qualquer navegador:
- utils/appTime.ts: STORAGE_TZ + fmtHora/fmtDataCurta/fmtDataHora.
- FullCalendar timeZone="America/Sao_Paulo".
- AgendaDetailModal (card "Data e hora" + histórico) e AgendaView (interesses) usam os helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-08 07:46:20 +02:00
parent 6087846556
commit 7cfd14ae09
3 changed files with 28 additions and 7 deletions
+7 -6
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { StackModal, StackPage, useStackModal } from './StackModal.tsx';
import { fmtHora, fmtDataCurta, fmtDataHora } from '../utils/appTime.ts';
import { HybridBackend } from '../services/backend.ts';
import { useToast } from '../contexts/ToastContext.tsx';
import { AvaliacaoEditor } from './AvaliacaoEditor.tsx';
@@ -137,9 +138,9 @@ const HistoryPage: React.FC<{ appointmentId: string }> = ({ appointmentId }) =>
<p className="text-[10px] text-gray-400 font-medium">{new Date(h.at).toLocaleString('pt-BR')}</p>
{h.detalhes?.de && h.detalhes?.para && (
<p className="text-[10px] text-gray-500 mt-0.5">
{new Date(h.detalhes.de.start).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
{fmtDataHora(h.detalhes.de.start)}
{' → '}
{new Date(h.detalhes.para.start).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
{fmtDataHora(h.detalhes.para.start)}
</p>
)}
</div>
@@ -304,8 +305,8 @@ const DentistaRestritoView: React.FC<{ ag: any; nome: string; dentNome: string;
<div className="grid grid-cols-2 gap-4 py-2 border-y border-gray-100">
<div className="space-y-1.5">
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Data e hora</label>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short' }) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? fmtDataCurta(ag.start) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? fmtHora(ag.start) : '—'}</p>
</div>
<div className="space-y-1.5">
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
@@ -421,8 +422,8 @@ const DetailPage: React.FC<Props> = (p) => {
<div className="grid grid-cols-2 gap-4 py-2 border-y border-gray-100">
<div className="space-y-1.5">
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Data e hora</label>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short' }) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? new Date(ag.start).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><CalendarRange size={14} className="text-teal-500" />{ag.start ? fmtDataCurta(ag.start) : '—'}</p>
<p className="flex items-center gap-1.5 text-xs font-bold text-gray-700"><Clock size={14} className="text-teal-500" />{ag.start ? fmtHora(ag.start) : '—'}</p>
</div>
<div className="space-y-1.5">
<label className="text-[9px] font-black text-gray-400 uppercase tracking-widest">Profissional</label>
+18
View File
@@ -0,0 +1,18 @@
// Fuso de REFERÊNCIA do armazenamento. As colunas de horário (agendamentos.start_time)
// são `timestamp` naive; o backend (container) roda em America/Sao_Paulo, então o node-pg
// serializa o wall-clock naive nesse fuso. Para exibir o horário LITERAL (o mesmo que a
// Secretária ofereceu/gravou) em qualquer navegador, formatamos SEMPRE neste fuso — e não
// no fuso local do navegador (que causaria o deslocamento de ±1h).
export const STORAGE_TZ = 'America/Sao_Paulo';
const D = (v?: string | number | Date | null) => (v === null || v === undefined || v === '' ? null : new Date(v));
export const fmtHora = (v?: string | number | Date | null): string => {
const d = D(v); return d ? d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', timeZone: STORAGE_TZ }) : '—';
};
export const fmtDataCurta = (v?: string | number | Date | null): string => {
const d = D(v); return d ? d.toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short', timeZone: STORAGE_TZ }) : '—';
};
export const fmtDataHora = (v?: string | number | Date | null): string => {
const d = D(v); return d ? d.toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', timeZone: STORAGE_TZ }) : '—';
};
+3 -1
View File
@@ -3,6 +3,7 @@ import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import { fmtDataHora } from '../utils/appTime.ts';
import { Calendar as CalendarIcon, Plus, X, Loader2, GripVertical, Settings as GearIcon, Edit, Trash2, CalendarRange, Clock, User, Stethoscope, AlertCircle, CheckCircle2, Bell, CalendarClock, History, Download, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { AgendaSettingsModal } from '../components/AgendaSettingsModal.tsx';
import { HorariosConfig } from '../components/HorariosConfig.tsx';
@@ -749,6 +750,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
<FullCalendar
key={isMobile ? 'mobile' : 'desktop'}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
timeZone="America/Sao_Paulo"
initialView={isMobile ? 'timeGridDay' : 'timeGridWeek'}
headerToolbar={isMobile
? { left: 'prev,next', center: 'title', right: 'today' }
@@ -943,7 +945,7 @@ export const AgendaView: React.FC<{ onNavigate?: (view: string) => void; sidebar
<div key={i.id} className="border border-gray-100 rounded-xl p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-bold text-gray-800">
{new Date(i.start_time).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
{fmtDataHora(i.start_time)}
</p>
<span className={`text-[9px] font-black uppercase px-2 py-0.5 rounded-full ${i.status === 'aguardando' ? 'bg-yellow-50 text-yellow-700' : i.status === 'liberado' ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500'}`}>{i.status}</span>
</div>