teqo/src/lib/campaignTime.ts
Francisco Ignácio Fontoura Solla 4806c417c8 Engineering Pass 3: consolidate patterns and lock them with guards.
Fold duplicated campanha access, loaders, list/filter/form shells, and scripts into shared modules; reorganize utilities by domain; stabilize e2e; and document prevention-first audit workflow so the same smells cannot creep back in.
2026-07-29 06:11:29 -03:00

140 lines
4.2 KiB
TypeScript

const BAHIA_TIME_ZONE = 'America/Bahia'
type CivilDateTime = {
year: number
month: number
day: number
hour: number
minute: number
second: number
}
const zonedFormatter = new Intl.DateTimeFormat('en-CA-u-ca-iso8601-nu-latn', {
timeZone: BAHIA_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
})
const getZonedParts = (date: Date): CivilDateTime => {
const parts = Object.fromEntries(
zonedFormatter
.formatToParts(date)
.filter(({ type }) => type !== 'literal')
.map(({ type, value }) => [type, Number(value)]),
)
return {
year: parts.year,
month: parts.month,
day: parts.day,
hour: parts.hour,
minute: parts.minute,
second: parts.second,
}
}
const civilAsUtcMilliseconds = ({
year,
month,
day,
hour,
minute,
second,
}: CivilDateTime): number => Date.UTC(year, month - 1, day, hour, minute, second)
const zonedCivilToInstant = (civil: CivilDateTime): Date => {
const desired = civilAsUtcMilliseconds(civil)
let candidate = desired
for (let attempt = 0; attempt < 6; attempt += 1) {
const observed = civilAsUtcMilliseconds(getZonedParts(new Date(candidate)))
const correction = desired - observed
if (correction === 0) return new Date(candidate)
candidate += correction
}
throw new Error('Não foi possível calcular o intervalo semanal no fuso da Bahia.')
}
const pad = (value: number): string => String(value).padStart(2, '0')
/**
* `aaaa-mm-dd` of an instant in Bahia civil time. Fixed width, so the result
* is lexicographically comparable against a date anchor written the same way —
* which is how the calendar phase (E13) decides its cut without importing a
* date library, and why the day turns at midnight in Bahia rather than in UTC.
*/
export const formatBahiaCivilDate = (date: Date): string => {
const { year, month, day } = getZonedParts(date)
return `${year}-${pad(month)}-${pad(day)}`
}
const bahiaDateTimeInputPattern = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
/**
* Converts a `datetime-local` input value (interpreted as Bahia civil time,
* since campaign staff operate in that timezone) into a UTC ISO instant.
* Returns null when the value does not match the expected input shape.
*/
export const parseBahiaDateTimeInput = (value: string): string | null => {
const match = bahiaDateTimeInputPattern.exec(value)
if (!match) return null
const [, year, month, day, hour, minute] = match
return zonedCivilToInstant({
year: Number(year),
month: Number(month),
day: Number(day),
hour: Number(hour),
minute: Number(minute),
second: 0,
}).toISOString()
}
/** Formats a UTC ISO instant as a `datetime-local` input value in Bahia civil time. */
export const formatIsoAsBahiaDateTimeInput = (iso: string): string => {
const { year, month, day, hour, minute } = getZonedParts(new Date(iso))
return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${pad(minute)}`
}
const bahiaDateTimeDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
timeZone: BAHIA_TIME_ZONE,
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
/** Formats a UTC ISO instant as `dd/mm/aaaa às hh:mm` in Bahia civil time. */
export const formatBahiaDateTimeLabel = (iso: string): string =>
bahiaDateTimeDisplayFormatter.format(new Date(iso)).replace(', ', ' às ')
const bahiaDateDisplayFormatter = new Intl.DateTimeFormat('pt-BR', {
timeZone: BAHIA_TIME_ZONE,
day: '2-digit',
month: '2-digit',
})
/** Formats an instant as `dd/mm` in Bahia civil time — the day the staff names things by. */
export const formatBahiaDayLabel = (date: Date): string => bahiaDateDisplayFormatter.format(date)
/**
* Latest of two ISO timestamps, ignoring nulls. String comparison is only
* sound because every writer here produces fixed-width UTC — Payload's own
* `createdAt`/`updatedAt` and our `new Date().toISOString()` hooks. An offset
* form (`-03:00`) or variable precision would break it.
*/
export const latestIsoTimestamp = (
left: string | null | undefined,
right: string | null | undefined,
): string | null => {
if (!left) return right ?? null
if (!right) return left
return right > left ? right : left
}