Pular para o conteúdo
Números, datas & unidades

Conteúdo

Números, datas & unidades

Todo formato do produto é pt-BR e sai de src/lib/format.ts ou src/lib/format-date.ts — nunca de um toLocaleString inline na tela. Os exemplos abaixo são gerados pelas funções reais, agora.
src/lib/format.tssrc/lib/format-date.tssrc/lib/phone.ts

Dinheiro

Duas casas por padrão. A exceção é o custo unitário, que pode ser fração de centavo.
FunçãoEntradaSaída
formatBRL(1234.5)1234.5R$ 1.234,50
formatBRL(0)0R$ 0,00
formatCustoUnitario(0.24)0.24R$ 0,24
formatCustoUnitario(0.0375)0.0375R$ 0,0375
formatBRL(18420)18420R$ 18.420,00
valores gerados agora por formatBRL / formatCustoUnitario

NotaPor que 4 casas abaixo de R$ 0,10

Um insumo pode custar fração de centavo (toxina por unidade internacional, fio por centímetro). Em duas casas, isso vira “R$ 0,00” — um valor mentiroso que zera o custo da ficha inteira. O > 0 na condição mantém o zero de verdade em duas casas.
RegraDetalhe
Sempre tabular-numsValor é a coluna mais comparada da tela.
Alinhado à direita em tabelaNúmeros comparáveis alinham pela unidade.
Sinal explícito em delta“+12%”, “−R$ 340” — sem sinal, quem lê não sabe a direção.
Nunca abreviar em tela de dinheiro“R$ 18.420”, não “R$ 18,4k”: é conta de clínica, não gráfico de investidor.

Números, quantidades e percentuais

Vírgula decimal, ponto de milhar. É pt-BR em todo lugar, inclusive no que a máquina gera.
FunçãoSaída
formatIntBR(1284)1.284
formatNum(161.74)161,74
formatNum(12.5, 2, 2)12,50
formatQty(1.5)1,5
formatPercent(0.125)12,5%
formatPercent(0.84)84%
formatAnos(1)1 ano
formatAnos(2.5)2,5 anos
gerados agora por formatIntBR / formatNum / formatQty / formatPercent / formatAnos

formatAnos existe por um detalhe de português: sem ela, a lista da entrevista escrevia “1 anos”. Toda unidade que pode chegar no singular merece a mesma atenção.

Armadilha já pagaIntl.NumberFormat é caro

toLocaleString constrói um formatador NOVO a cada chamada, e uma lista paga isso por linha. Os helpers guardam singletons por combinação de opções — é por isso que eles existem, além da consistência.

Telefone

O banco guarda E.164; a tela mostra pontuado. As duas formas nunca se misturam.
ContextoFunçãoResultado
Exibição em listaformatPhoneBR('+5531982968101')+55 31 98296-8101
Campo de formulárioformatPhoneInput('+5511912345678')(11) 91234-5678
DigitandomaskPhoneInput('11912345678')(11) 91234-5678
Sem telefoneformatPhoneBR(null)
formatPhoneBR / formatPhoneInput / maskPhoneInput
RegraDetalhe
Telefone é monofont-mono text-[13px]: lê-se caractere a caractere.
Canônico é E.164+5511912345678 no banco. A máscara é conveniência da tela; a action normaliza de qualquer jeito.
Começou com +, a máscara desligaNúmero internacional entra cru e a validação cuida.
DDD nunca começa com zero“011 3123-4567” é discagem com tronco, não número — sem o guard virava +5501…, E.164 que o WhatsApp rejeita.
O 9º dígito tem duas formasO WhatsApp devolve número antigo SEM o 9 enquanto a agenda guarda COM. Dedup e reconhecimento aceitam as duas — ou fabricam duplicata.
Ausência é “—”Travessão, não “null”, não string vazia.

Datas e horas

Fuso de EXIBIÇÃO fixo no Brasil. Sem isso, o SSR (que roda em UTC) mostrava um dia a menos à noite — bug recorrente em três telas.
FunçãoPara queNota
formatBrDate(iso)Data em pt-BR com timeZone de exibiçãoAceita as opções do Intl.
formatBrDateTime(iso)Data + horaIdem.
brDayKey(value)Chave YYYY-MM-DD pra comparar “mesmo dia?”Carimbo estragado agrupa numa chave própria em vez de derrubar a lista.
labelDoMes('2026-08-01')“agosto de 2026”UTC no formatter de propósito: a entrada já é data de CALENDÁRIO, e reinterpretar num fuso local voltaria um dia na virada do mês.
shortDuration(ms)“2 h 10 min”, “45 min”, “menos de 1 min”Duração curta e humana.
sinceShort(iso)Quanto tempo fazUsa Date.now() — fora do corpo do componente.
30000 ms → menos de 1 min60000 ms → 1 min2700000 ms → 45 min7800000 ms → 2 h 10 min82800000 ms → 23 h
shortDuration, gerado agora

Armadilha já pagaFuso de exibição ≠ fuso da clínica

format-date.ts crava São Paulo — serve pra MOSTRAR. Onde o dia muda o que a clínica recebe (assinatura, agenda, grade de horários), a chave sai de chaveDoDia(instante, tenants.timezone). Confundir os dois é como uma clínica no Acre perde uma consulta de madrugada.

Armadilha já pagaRótulo de régua vem do domínio

A hora de cada linha da grade sai de fromMinutes(minuto), nunca de Math.floor(minuto / 60): a régua começa no primeiro opens_at da semana, que pode não ser hora cheia. Uma clínica que abre 13:30 via a primeira linha rotulada “13:00” — e o clique no vão pré-preenchia meia hora errada em TODO agendamento feito pelo calendário.
SituaçãoFormato
Data numa lista12/08/2026
Data com dia da semanaqui, 12/08
Hora14:00 (24h, sempre — “2 PM” não existe em pt-BR)
Faixa de horário14:00–14:30 (travessão sem espaço)
Tempo relativo curtohá 2 min · há 3 h · ontem
Mêsagosto de 2026
HidrataçãosuppressHydrationWarning quando o texto depende do relógio

Documentos e endereço

Máscaras de exibição e de digitação andam em par: uma lê o valor canônico, a outra pontua enquanto a pessoa escreve.
FunçãoSaída
formatCnpj('12ABC34501DE35')12.ABC.345/01DE-35
formatCep('01310930')01310-930
formatCnpj / formatCep, gerados agora

O CNPJ é alfanumérico desde a IN RFB 2.229/2024 — daí [A-Z0-9] nas 12 primeiras posições e [0-9] só nos dois dígitos verificadores. Máscara que assume \d em tudo já nasce errada.

Regras gerais

Valem pra qualquer número na tela.

Sim

124 agendados · 84% de comparecimento · +55 11 91234-5678

R$ 18.420,00 recuperáveis

Tabular em tudo, telefone em mono, moeda com duas casas, percentual com uma.

Não

124 agendados · 84.0% de comparecimento · +5511912345678

R$ 18420 recuperáveis

Ponto decimal em pt-BR, E.164 cru na tela, moeda sem separador de milhar e sem casas — e nada tabular.

// Nunca na tela:
value.toLocaleString("pt-BR", { style: "currency", currency: "BRL" })

// Sempre:
import { formatBRL } from "@/lib/format";
formatBRL(value)