Gerenciando fusos horários e horário de verão em Lua

Funções utilitárias

Calcular o deslocamento do fuso local em relação ao UTC.

local Ferramentas = {}

function Ferramentas.deslocamentoLocal()
    local agora = os.time()
    local utc = os.date("!*t", agora)
    return os.difftime(agora, os.time(utc))
end

Exibir um timestamp no formato UTC+0.

function Ferramentas.imprimirUTC0(epoch)
    local d = os.date("!*t", epoch)
    print(string.format("%04d-%02d-%02d %02d:%02d:%02d UTC+0",
        d.year, d.month, d.day, d.hour, d.min, d.sec))
end

Converter uma string ISO 8601 em uma tabela de data, opcionalmente com o deslocamento do fuso.

function Ferramentas.parseISO8601(texto)
    local padrao =
        "^(%d%d%d%d)%-(%d%d)%-(%d%d)[T ](%d%d):(%d%d):(%d%d)([%+%-]?)(%d?%d?):?(%d?%d?)$"
    local ano, mes, dia, hora, min, seg, sinal, htz, mtz =
        string.match(texto, padrao)

    if not ano then
        error("formato inválido: " .. tostring(texto))
    end

    local data = {
        year = tonumber(ano),
        month = tonumber(mes),
        day = tonumber(dia),
        hour = tonumber(hora),
        min = tonumber(min),
        sec = tonumber(seg),
        tzOffset = nil,
    }

    if htz and htz ~= "" then
        local desl = tonumber(htz) * 3600
        if mtz and mtz ~= "" then
            desl = desl + tonumber(mtz) * 60
        end
        if sinal == "-" then
            desl = -desl
        end
        data.tzOffset = desl
    end

    return data
end

Classe ZonaHoraria

A classe armazena o deslocamento do fuso de interesse, o deslocamento da máquina local e as fronteiras do horário de verão.

local ZonaHoraria = {}
ZonaHoraria.__index = ZonaHoraria

function ZonaHoraria:novo()
    return setmetatable({
        deslocamentoFuso = 0,
        deslocamentoLocal = 0,
        inicioVerao = 0,
        fimVerao = 0,
    }, ZonaHoraria)
end

function ZonaHoraria:definirDeslocamentoLocal(v)
    self.deslocamentoLocal = v
end

function ZonaHoraria:definirDeslocamentoFuso(v)
    self.deslocamentoFuso = v
end

local Interno = {}

function Interno.epochDeStringISO(self, texto)
    local data = Ferramentas.parseISO8601(texto)
    -- os.time interpreta os campos como horário local; reverte-se essa suposição.
    local epoch = os.time(data) + self.deslocamentoLocal
    local desl = data.tzOffset

    if desl then
        epoch = epoch - desl
    else
        epoch = epoch - self.deslocamentoFuso
    end

    return epoch
end

function ZonaHoraria:definirVeraoPorTexto(inicio, fim)
    self.inicioVerao = Interno.epochDeStringISO(self, inicio)
    self.fimVerao = Interno.epochDeStringISO(self, fim)
end

function ZonaHoraria:definirVeraoPorEpoch(inicio, fim)
    self.inicioVerao = inicio
    self.fimVerao = fim
end

function ZonaHoraria:suportaVerao()
    return self.inicioVerao ~= 0 and self.fimVerao ~= 0
end

Convertendo data para timestamp

Base comum que impede que o sistema operacional aplique regras locais de DST.

function Interno.epochBase(self, data)
    local copia = {
        year = data.year,
        month = data.month,
        day = data.day,
        hour = data.hour,
        min = data.min,
        sec = data.sec,
        isdst = false,
    }
    return os.time(copia) + self.deslocamentoLocal
end

Escolha entre os candidatos padrão e de verão quando o sinalizador isdst está ausente.

function Interno.escolherEpoch(self, base)
    local tsPadrao = base - self.deslocamentoFuso
    local tsVerao = base - (self.deslocamentoFuso + 3600)

    local padraoEVerao = self:timestampEhVerao(tsPadrao)
    local veraoEVerao = self:timestampEhVerao(tsVerao)

    if padraoEVerao == veraoEVerao then
        return padraoEVerao and tsVerao or tsPadrao
    elseif padraoEVerao then
        -- hora inexistente na mudança de primavera: usa o instante anterior
        return tsVerao
    else
        -- hora ambígua na mudança de outono: usa o instante posterior
        return tsPadrao
    end
end

Conversão a partir de uma tabela de data.

function ZonaHoraria:dataParaEpoch(data)
    local base = Interno.epochBase(self, data)
    local estado = data.isdst

    if estado == true then
        return base - (self.deslocamentoFuso + 3600)
    elseif estado == false then
        return base - self.deslocamentoFuso
    else
        if self:suportaVerao() then
            return Interno.escolherEpoch(self, base)
        else
            return base - self.deslocamentoFuso
        end
    end
end

Conversão a partir de uma string ISO 8601.

function ZonaHoraria:textoParaEpoch(texto)
    local data = Ferramentas.parseISO8601(texto)
    local base = Interno.epochBase(self, data)

    if data.tzOffset then
        return base - data.tzOffset
    elseif self:suportaVerao() then
        return Interno.escolherEpoch(self, base)
    else
        return base - self.deslocamentoFuso
    end
end

Conversão a partir de componentes individuais.

function ZonaHoraria:componentesParaEpoch(ano, mes, dia, hora, min, seg, ehVerao)
    return self:dataParaEpoch({
        year = ano, month = mes, day = dia,
        hour = hora, min = min, sec = seg,
        isdst = ehVerao,
    })
end

Verificação de horário de verão

function ZonaHoraria:timestampEhVerao(epoch)
    if not self:suportaVerao() then
        return false
    end
    return epoch >= self.inicioVerao and self.fimVerao > epoch
end

function ZonaHoraria:dataEhVerao(data)
    return self:timestampEhVerao(self:dataParaEpoch(data))
end

function ZonaHoraria:textoEhVerao(texto)
    return self:timestampEhVerao(self:textoParaEpoch(texto))
end

Timestamp para data

function ZonaHoraria:epochParaData(epoch)
    local desl = self.deslocamentoFuso
    if self:timestampEhVerao(epoch) then
        desl = desl + 3600
    end

    local data = os.date("!*t", epoch + desl)
    data.isdst = self:timestampEhVerao(epoch)
    return data
end

function Ferramentas.formatarData(data)
    local extra = data.isdst and " dst" or ""
    return string.format("%04d-%02d-%02d %02d:%02d:%02d%s",
        data.year, data.month, data.day,
        data.hour, data.min, data.sec, extra)
end

function ZonaHoraria:epochParaTexto(epoch)
    local data = self:epochParaData(epoch)
    local desl = self.deslocamentoFuso
    if data.isdst then
        desl = desl + 3600
    end

    local sinal = desl >= 0 and "+" or "-"
    local h = math.floor(math.abs(desl) / 3600)
    local m = math.floor(math.abs(desl) % 3600 / 60)

    return string.format("%04d-%02d-%02d %02d:%02d:%02d%s%02d:%02d",
        data.year, data.month, data.day,
        data.hour, data.min, data.sec,
        sinal, h, m)
end

Testes

local function assertIgual(esperado, obtido)
    if esperado ~= obtido then
        error(string.format("esperado %s, obtido %s",
            tostring(esperado), tostring(obtido)))
    end
end

local fuso = ZonaHoraria:novo()
fuso:definirDeslocamentoLocal(Ferramentas.deslocamentoLocal())
fuso:definirDeslocamentoFuso(-5 * 3600)
fuso:definirVeraoPorTexto(
    "2024-03-10T02:00:00-05:00",
    "2024-11-03T02:00:00-04:00"
)

local inicio = 1710054000
local fim = 1730613600

assertIgual(0, fuso.inicioVerao - inicio)
assertIgual(0, fuso.fimVerao - fim)
assertIgual(true, fuso:suportaVerao())

-- texto -> epoch
do
    local t1 = fuso:textoParaEpoch("2024-03-10 01:00:00")
    local t2 = fuso:textoParaEpoch("2024-03-10 02:00:00")
    local t3 = fuso:textoParaEpoch("2024-03-10 03:00:00")
    assertIgual(-3600, t1 - inicio)
    assertIgual(-3600, t2 - inicio)
    assertIgual(0, t3 - inicio)

    local t4 = fuso:textoParaEpoch("2024-11-03 00:00:00")
    local t5 = fuso:textoParaEpoch("2024-11-03 01:00:00")
    local t6 = fuso:textoParaEpoch("2024-11-03 02:00:00")
    assertIgual(-7200, t4 - fim)
    assertIgual(0, t5 - fim)
    assertIgual(3600, t6 - fim)
end

-- data -> epoch
do
    local t1 = fuso:dataParaEpoch({year=2024, month=3, day=10, hour=1, min=0, sec=0})
    local t2 = fuso:dataParaEpoch({year=2024, month=3, day=10, hour=2, min=0, sec=0})
    local t3 = fuso:dataParaEpoch({year=2024, month=3, day=10, hour=2, min=0, sec=0, isdst=false})
    local t4 = fuso:dataParaEpoch({year=2024, month=3, day=10, hour=2, min=0, sec=0, isdst=true})
    local t5 = fuso:dataParaEpoch({year=2024, month=3, day=10, hour=3, min=0, sec=0})
    assertIgual(-3600, t1 - inicio)
    assertIgual(-3600, t2 - inicio)
    assertIgual(0, t3 - inicio)
    assertIgual(-3600, t4 - inicio)
    assertIgual(0, t5 - inicio)
end

-- verificação de DST
do
    assertIgual(false, fuso:textoEhVerao("2024-03-10 01:00:00"))
    assertIgual(false, fuso:textoEhVerao("2024-03-10 02:00:00"))
    assertIgual(true, fuso:textoEhVerao("2024-03-10 03:00:00"))

    assertIgual(true, fuso:textoEhVerao("2024-11-03 00:00:00"))
    assertIgual(false, fuso:textoEhVerao("2024-11-03 01:00:00"))
    assertIgual(false, fuso:textoEhVerao("2024-11-03 02:00:00"))
end

-- epoch -> data
do
    local d1 = fuso:epochParaData(inicio - 3600)
    local d2 = fuso:epochParaData(inicio)
    local d3 = fuso:epochParaData(inicio + 3600)
    assertIgual("2024-03-10 01:00:00", Ferramentas.formatarData(d1))
    assertIgual("2024-03-10 03:00:00 dst", Ferramentas.formatarData(d2))
    assertIgual("2024-03-10 04:00:00 dst", Ferramentas.formatarData(d3))

    local d4 = fuso:epochParaData(fim - 3600)
    local d5 = fuso:epochParaData(fim - 1)
    local d6 = fuso:epochParaData(fim)
    local d7 = fuso:epochParaData(fim + 3600)
    assertIgual("2024-11-03 01:00:00 dst", Ferramentas.formatarData(d4))
    assertIgual("2024-11-03 01:59:59 dst", Ferramentas.formatarData(d5))
    assertIgual("2024-11-03 01:00:00", Ferramentas.formatarData(d6))
    assertIgual("2024-11-03 02:00:00", Ferramentas.formatarData(d7))
end

Tags: Lua fuso horário Horário de Verão ISO 8601 os.time

Publicado em 7-11 00:33