mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Cross-checked by parallel code-quality/performance/reuse reviewers against the E9 commit. Behavior-preserving: - laterTimestamp/resolveMunicipalityLastSignalAt were the same "latest of two ISO timestamps" written twice; both now delegate to a new latestIsoTimestamp in campaignTime.ts, which documents the fixed-width UTC invariant that makes the string compare sound. - Hoisted the frescor comparator's Date.now() out of the per-comparison accessor so the sort key stops drifting mid-sort. - Deleted the unread baseTotal/growthFactor/belowBase passthrough from the statewide goal entry point and fixed a comment citing a symbol (goalDecompositionBelowBase) that never existed; renamed computeStatewideGoalDecomposition -> computeStatewideSuggestedGoals now that nothing is proportionally decomposed. - Collapsed the advisor badge's two copies (desktop table vs mobile card) into one AdvisorStatusBadge; they had quietly diverged on which icon renders for the "no advisor" state. - Renamed the sort-menu's stale "Cobertura" label for advisor coverage to "Assessoria" (E8 already renamed the list column for the same reason); derived the sort-key set from the label record instead of a third parallel list. - Consolidated three zeroed-scenario-record literals into one zeroByVoteEstimateScenario() factory in voteEstimate.ts. - Routed three separate votesByYear[2022] reads through one votesInYear helper in municipalityPotential.ts. - Gated the goal card's empty-state copy on !usesMesaEstimate so it stops telling a município with an existing mesa estimate to enter one. - Swapped a test assertion pinned to a raw Tailwind token for a stable data-signal="cold" hook, and tightened the "desert" test's bound to check against the actual growth factor (the old bound was true for every slug regardless of the formula). Left alone (roadmap decision, not cleanup): sanityCheckSuggestedGoalsByTerritory has no production caller yet (deferred to E11/E12); comment shortened instead of deleting the dead-but-documented code. Verified: tsc --noEmit, pnpm lint (0 warnings), 337 unit + 366 integration tests, pnpm build all green.
148 lines
4.3 KiB
TypeScript
148 lines
4.3 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 ')
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
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),
|
|
}
|
|
}
|