defmodule Timex.PosixTimezone do @moduledoc """ Used when parsing POSIX-TZ timezone rules. """ alias Timex.TimezoneInfo defstruct name: nil, std_abbr: nil, std_offset: 0, dst_abbr: nil, dst_offset: nil, dst_start: nil, dst_end: nil @type rule_bound :: {{:julian_leap, 0..365}, Time.t()} | {{:julian, 1..365}, Time.t()} | {{:mwd, {month :: 1..12, week :: 1..5, day_of_week :: 0..6}}, Time.t()} | nil @type t :: %__MODULE__{ name: nil | String.t(), std_abbr: nil | String.t(), dst_abbr: nil | String.t(), std_offset: integer(), dst_offset: integer(), dst_start: rule_bound(), dst_end: rule_bound() } @doc """ Obtains a `NaiveDateTime` representing the start of DST for this zone. Returns nil if there is no DST period. """ @spec dst_start(t, DateTime.t() | NaiveDateTime.t() | Date.t()) :: NaiveDateTime.t() | nil def dst_start(posix_tz, date) def dst_start(%__MODULE__{dst_start: nil}, _), do: nil def dst_start(%__MODULE__{dst_start: dst_start}, %{year: year}) do bound_to_naive_datetime(dst_start, year) end @doc """ Obtains a `NaiveDateTime` representing the end of DST for this zone. Returns nil if there is no DST period. """ @spec dst_end(t, DateTime.t() | NaiveDateTime.t() | Date.t()) :: NaiveDateTime.t() | nil def dst_end(posix_tz, date) def dst_end(%__MODULE__{dst_end: nil}, _), do: nil def dst_end(%__MODULE__{dst_end: dst_end}, %{year: year}) do bound_to_naive_datetime(dst_end, year) end @doc """ Returns a `TimezoneInfo` struct representing this timezone for the given datetime """ @spec to_timezone_info(t, DateTime.t() | NaiveDateTime.t() | Date.t()) :: TimezoneInfo.t() def to_timezone_info(%__MODULE__{} = tz, date) do date = to_naive_datetime(date) if is_dst?(tz, date) do %TimezoneInfo{ full_name: tz.name, abbreviation: tz.dst_abbr, offset_std: tz.dst_offset, offset_utc: tz.std_offset, from: :min, until: :max } else %TimezoneInfo{ full_name: tz.name, abbreviation: tz.std_abbr, offset_std: 0, offset_utc: tz.std_offset, from: :min, until: :max } end end @doc """ Returns a `Calendar.TimeZoneDatabase` compatible map, representing this timezone for the given datetime """ def to_period_for_date(%__MODULE__{} = tz, date) do date = to_naive_datetime(date) if is_dst?(tz, date) do std_offset = tz.dst_offset utc_offset = tz.std_offset %{ std_offset: std_offset, utc_offset: utc_offset, zone_abbr: tz.dst_abbr, time_zone: tz.name } else %{std_offset: 0, utc_offset: tz.std_offset, zone_abbr: tz.std_abbr, time_zone: tz.name} end end @doc """ Returns a boolean indicating if the datetime provided occurs during DST of the given POSIX timezone. """ @spec is_dst?(t, DateTime.t() | NaiveDateTime.t() | Date.t()) :: boolean def is_dst?(%__MODULE__{} = tz, date) do with %NaiveDateTime{} = dst_start <- dst_start(tz, date), %NaiveDateTime{} = dst_end <- dst_end(tz, date) do cond do NaiveDateTime.compare(date, dst_start) == :lt -> false NaiveDateTime.compare(date, dst_end) == :gt -> false :else -> true end else nil -> false end end defp bound_to_naive_datetime({{:mwd, month, week, weekday}, time}, year) do month_start = Timex.Date.new!(year, month, 1) month_start_dow = Timex.Date.day_of_week(month_start, :sunday) - 1 if weekday == month_start_dow and week == 1 do # Got lucky, we're done Timex.NaiveDateTime.new!(month_start, time) else first_week_date = if month_start_dow <= weekday do # The week starting on the 1st includes our weekday, so it is the first week of the month %{month_start | day: month_start.day + (weekday - month_start_dow)} else # The week starting on the 1st does not include our weekday, so shift forward a week eow = Timex.Date.end_of_week(month_start) %{eow | day: eow.day + 1 + weekday} end cond do week == 1 -> first_week_date :else -> day_shift = (week - 1) * 7 day = first_week_date.day + day_shift ldom = :calendar.last_day_of_the_month(year, month) date = if ldom > day do # Last occurrence is in week 4, so shift back a week %{first_week_date | day: day - 7} else %{first_week_date | day: day} end Timex.NaiveDateTime.new!(date, time) end end end defp bound_to_naive_datetime({{:julian, day}, time}, year) do date = Timex.Calendar.Julian.date_for_day_of_year(day - 1, year, leaps: false) Timex.NaiveDateTime.new!(date, time) end defp bound_to_naive_datetime({{:julian_leap, day}, time}, year) do date = Timex.Calendar.Julian.date_for_day_of_year(day, year, leaps: true) Timex.NaiveDateTime.new!(date, time) end defp to_naive_datetime(%NaiveDateTime{} = date), do: date defp to_naive_datetime(%DateTime{} = date), do: DateTime.to_naive(date) defp to_naive_datetime(%Date{} = date), do: Timex.NaiveDateTime.new!(date, ~T[12:00:00]) end