teqo-backup/scripts/lib/cityReportFormat.mjs

94 lines
3.3 KiB
JavaScript

/**
* pt-BR formatters shared by the city report blocks and renderers (C163).
* Pure and unit-tested: the PDF and the companion `.md` must read identically.
*/
import { municipalitySignalAgeInDays } from '../../src/utilities/municipality/municipalitySignal.ts'
const integerFormatter = new Intl.NumberFormat('pt-BR')
const decimalFormatters = new Map()
const decimalFormatter = (digits) => {
const cached = decimalFormatters.get(digits)
if (cached) return cached
const formatter = new Intl.NumberFormat('pt-BR', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
})
decimalFormatters.set(digits, formatter)
return formatter
}
const toFinite = (value) => {
if (value === null || value === undefined || value === '') return null
const number = Number(value)
return Number.isFinite(number) ? number : null
}
export const formatInteger = (value) => {
const number = toFinite(value)
return number === null ? '—' : integerFormatter.format(Math.round(number))
}
const formatDecimal = (value, digits = 1) => {
const number = toFinite(value)
return number === null ? '—' : decimalFormatter(digits).format(number)
}
export const formatPercent = (ratio, digits = 1) => {
if (ratio === null || ratio === undefined || !Number.isFinite(Number(ratio))) return '—'
return `${formatDecimal(Number(ratio) * 100, digits)}%`
}
export const formatMoneyCompact = (value) => {
const number = toFinite(value)
if (number === null) return '—'
if (Math.abs(number) < 1_000_000) {
const thousands = number / 1000
return `R$ ${formatDecimal(thousands, Math.abs(thousands) >= 100 ? 0 : 1)} mil`
}
const millions = number / 1_000_000
if (Math.abs(millions) >= 1000) return `R$ ${formatDecimal(millions / 1000)} bi`
return `R$ ${formatDecimal(millions)} mi`
}
const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
export const formatDateBr = (value) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
// Date-only strings ("2026-08-17") parse as UTC midnight; formatting them in
// America/Bahia would print the previous day. Keep the calendar date.
const timeZone =
typeof value === 'string' && DATE_ONLY_PATTERN.test(value) ? 'UTC' : 'America/Bahia'
return new Intl.DateTimeFormat('pt-BR', { timeZone }).format(date)
}
export const formatDateTimeBr = (value) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('pt-BR', {
timeZone: 'America/Bahia',
dateStyle: 'short',
timeStyle: 'short',
}).format(date)
}
export const formatFreshness = (value, now = new Date()) => {
const days = municipalitySignalAgeInDays(value, now)
if (days === null) return 'sem registro'
if (days <= 0) return 'atualizado hoje'
if (days === 1) return 'atualizado ontem'
if (days < 14) return `atualizado há ${days} dias`
const weeks = Math.floor(days / 7)
if (weeks < 9) return `atualizado há ${weeks} semanas`
const months = Math.floor(days / 30)
return `atualizado há ${months} ${months === 1 ? 'mês' : 'meses'}`
}
export const formatRank = (entry) => {
if (!entry || !Number.isFinite(Number(entry.rank))) return '—'
return `${formatInteger(entry.rank)}º de ${formatInteger(entry.totalUnits)}`
}