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.
140 lines
4.2 KiB
TypeScript
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
|
|
}
|