teqo-backup/src/utilities/campaignTime.ts
Francisco Ignácio Fontoura Solla e995aefbd4 Add action-plan agenda vertical for /campanha/planos.
Ship C3 mobilization events with the actionPlan collection, list/detail/forms UI, upcoming-event blocks, access control, and migration.
2026-07-18 20:56:20 -03:00

133 lines
3.8 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 addCivilDays = (civil: CivilDateTime, days: number): CivilDateTime => {
const date = new Date(civilAsUtcMilliseconds(civil))
date.setUTCDate(date.getUTCDate() + days)
return {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
hour: civil.hour,
minute: civil.minute,
second: civil.second,
}
}
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))
const pad = (value: number): string => String(value).padStart(2, '0')
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 ')
export const getBahiaWeekRange = (now: Date): { start: Date; end: Date } => {
const local = getZonedParts(now)
const localMidnight = { ...local, hour: 0, minute: 0, second: 0 }
const weekday = new Date(
Date.UTC(localMidnight.year, localMidnight.month - 1, localMidnight.day),
).getUTCDay()
const daysSinceMonday = weekday === 0 ? 6 : weekday - 1
const monday = addCivilDays(localMidnight, -daysSinceMonday)
const nextMonday = addCivilDays(monday, 7)
return {
start: zonedCivilToInstant(monday),
end: zonedCivilToInstant(nextMonday),
}
}