Date and Time that go beyond midnight

Current problem

I have a situation where I have two parameters that are a

date_str = "20230115"
time_str = "25:30".

The time_str when it goes above 24 implies the next day.

My goal
I want to take these two parameters and convert them into datetime that returns it into unix seconds as it would be easier to manage. I have a lot of time_str that are "24:30", "25:30", even "26:30".

My Solution
I have something working but I feel like I’m not doing it in an Elixir way or maybe my approach could be done in a better way.

defmodule Helper do

  @doc """
  Parses date and time strings into a unix seconds.
  The `time_str` can go beyond midnight and into the next day and this will return the correct

  #Parameters
  - `date_str`: String and should follow this format "20230225". Meaning year, month, day
  - `time_str`: String and should follow this format "18:00:00". Meaning hour, min and sec

  ## Example
    iex> Helper.parse_date_time_to_unix_seconds("20230225", "18:00:00")
    1677371400

  ## Example with time_str is `25:30:00` and goes into the next day
    iex> Helper.parse_date_time_to_unix_seconds("20230225", "25:30:00")
    1677375000
  """
  def parse_date_time_to_unix_seconds(date_str, time_str) do
    with  {:ok, noon}          <- to_naive_datetime(date_str),
          {:ok, total_seconds} <- parsing_time_string_to_seconds(time_str) do
      (noon
        |> DateTime.from_naive!("Etc/UTC")
        |> DateTime.to_unix()
    ) + total_seconds
    else
      {:error, message} ->
        IO.puts(message)
        raise "#{message}"
      _ ->
        IO.puts("parse_to_datetime error")
    end
  end


  def to_naive_datetime(<<yyyy::binary-4, mm::binary-2, dd::binary-2>>) do
    [yyyy, mm, dd] = for i <- [yyyy, mm, dd], do: String.to_integer(i)
    NaiveDateTime.new(yyyy, mm, dd, 0, 0, 0)
  end

  def parsing_time_string_to_seconds(time_str) do
    [hr, min, sec] = String.split(time_str, ":")
    case hr do
      "24" -> {:tomorrow, "00:#{min}:#{sec}"}
      "25" -> {:tomorrow, "01:#{min}:#{sec}"}
      "26" -> {:tomorrow, "02:#{min}:#{sec}"}
      "27" -> {:tomorrow, "03:#{min}:#{sec}"}
      "28" -> {:tomorrow, "04:#{min}:#{sec}"}
        _  -> {:today, time_str}
    end
    |> format_string_to_seconds()
  end

  def format_string_to_seconds({:tomorrow, str}) do
    {time_after_midnight_in_seconds, _} = Time.from_iso8601!(str) |> Time.to_seconds_after_midnight
    {in_seconds, _} = Time.from_seconds_after_midnight(-1) |> Time.to_seconds_after_midnight
    full_day_of_seconds = in_seconds + 1 # need to add one second back
    total_seconds = full_day_of_seconds + time_after_midnight_in_seconds
    {:ok, total_seconds}
  end

  def format_string_to_seconds({:today, str}) do
    {:ok, offset} = Time.from_iso8601(str)
    {total_seconds, _ } = Time.to_seconds_after_midnight(offset)
    {:ok, total_seconds}
  end


end


Now if you try this out

 iex> Helper.parse_date_time_to_unix_seconds("20230225", "18:00:00")
 1677348000

iex> DateTime.from_unix(1677348000)
{:ok, ~U[2023-02-25 18:00:00Z]}

# And works with dates that go beyond midnight. 
# We can see in this example the date is coming back for the next day at 1:30 in the morning.

 iex> Helper.parse_date_time_to_unix_seconds("20230225", "25:30:00")
 1677348000

iex> DateTime.from_unix(1677375000)
{:ok, ~U[2023-02-26 01:30:00Z]} # see the next day it works

Looking for Feedback

My solution is working but I have a feeling like I might not be writing this in the most elixir way. Just looking for any feedback, tips or gotchas.

LGTM, Good places to look for best practice is in elixir own source GitHub - elixir-lang/elixir: Elixir is a dynamic, functional language designed for building scalable and maintainable applications, and with benchee | Hex you can benchmark your solutions.

I did do some research looking at the Elixir/calendar/time.ex for any existing solution and ideas.

Thanks for the tip on benchee.

Slightly shorter version :slight_smile:

defmodule Helpers do
  def parse(date, time_str) do
    date = parse_date(date)
    seconds = parse_time(time_str)

    date = Date.add(date, div(seconds, 86400))
    time = Time.from_seconds_after_midnight(rem(seconds, 86400))

    {:ok, datetime} = DateTime.new(date, time)
    DateTime.to_unix(datetime)
  end

  defp parse_date(<<yyyy::binary-4, mm::binary-2, dd::binary-2>>) do
    Date.new!(
      String.to_integer(yyyy),
      String.to_integer(mm),
      String.to_integer(dd)
    )
  end

  defp parse_time(time_str) do
    [hr, min, sec] = 
      time_str
      |> String.split(":")
      |> Enum.map(&String.to_integer/1)

    hr * 3600 + min * 60 + sec
  end
end
2 Likes

The parse_time/1 to break the string into total seconds is the right mental model. Makes everything else much easier to construct inside the parse/2.

Thanks for sharing.

1 Like