Packages
until_then
0.0.1
This library tells you how many milliseconds to the next occurrence of a scheduled event. This is very convenient to combine with `:timer.sleep/1` or `Process.send_after/3` as a means of repeatedly invoking some code on a schedule and not having those invocations drift.
Current section
Files
Jump to
Current section
Files
lib/until_then.ex
defmodule UntilThen do
@moduledoc ~S"""
This library tells you how many milliseconds to the next occurrence of a
scheduled event. This is very convenient to combine with `:timer.sleep/1` or
`Process.send_after/3` as a means of repeatedly invoking some code on a
schedule and not having those invocations drift.
"""
@days ~w[monday tuesday wednesday thursday friday saturday sunday]a
@typedoc ~S"""
An atom that is a named day of the week or the special flag `:weekdays`.
"""
@type day :: :monday | :tuesday | :wednesday | :thursday | :friday |
:saturday | :sunday | :weekdays
alias Calendar.{Date, DateTime, Time}
@doc ~S"""
This function returns the time zone assumed for string times passed into
`UntilThen.next_occurrence/2`. It defaults to `"UTC"` but it can be
configured:
config :until_then, scheduled_time_zone: "US/Pacific"
"""
@spec scheduled_time_zone() :: String.t
def scheduled_time_zone do
Application.get_env(:until_then, :scheduled_time_zone, "UTC")
end
@doc ~S"""
This function is the primary interface of this module. You can call it
anytime you need to know the delay until the next occurrence of a scheduled
event.
You pass this function a day name (like `:tuesday`) or the special flag
`:weekdays` followed by a time string in the 24-hour format HH:MM:SS (like
`"13:45:00"`). It will return the number of milliseconds to the next
occurrence of the indicated day and time.
An example usage could be:
defmodule WeekdayCheckins do
def run_checkins do
UntilThen.next_occurrence(:weekdays, "12:00:00") |> :timer.sleep
checkin
run_checkins
end
def checkin do
# do the work here...
end
end
"""
@spec next_occurrence(day, String.t) :: integer
def next_occurrence(day, time) do
next_occurrence(day, time, DateTime.now!(scheduled_time_zone))
end
@doc ~S"""
This is just like `UntilThen.next_occurrence/2`, save that you also pass a
`Calendar.DateTime` to be used as the point `from` which the next occurrence
is calculated. This is the pure function that powers the calculation and is
used to test it, but it's generally more convenient to work with the impure
wrapper in your own code. However, you could use this function to vary time
zones across calls, if needed.
"""
@spec next_occurrence(day, String.t, %Calendar.DateTime{ }) :: integer
def next_occurrence(day, _time, _from)
when not (day in @days or day == :weekdays) do
raise ArgumentError,
"day must be #{@days |> Enum.map(&inspect/1) |> Enum.join(", ")}" <>
", or :weekdays"
end
def next_occurrence(day, time, from) do
if time =~ ~r"\A(?:2[0-3]|[01]\d):[0-5]\d:[0-5]\d\z" do
find_next_occurrence(day, Time.Parse.iso8601!(time), from, 0)
else
raise ArgumentError, "please provide a valid time in the format HH:MM:SS"
end
end
# This function perform the actual search through days to locate the next
# occurrence of the passed event. It adjusts when it crosses Daylight Savings
# Time boundaries, so it doesn't skip events. When a match is found, it's
# converted to an offset of milliseconds from the start time before it is
# returned. The search is limited to 10 days to prevent infinite recursion.
@spec find_next_occurrence(day,
%Calendar.Time{ },
%Calendar.DateTime{ },
0..10) :: integer
defp find_next_occurrence(_day, _time, _from, 10) do
raise "An occurrence could not be found within ten days"
end
defp find_next_occurrence(day, time, from, offset) do
occurrence =
with {:ok, next} <- DateTime.add(from, 60 * 60 * 24 * offset),
{:ok, next_w_dst} <- DateTime.add(next, from.std_off - next.std_off),
date = DateTime.to_date(next_w_dst),
do: DateTime.from_date_and_time_and_zone(date, time, from.timezone)
if valid?(occurrence, day, from) do
to_microseconds(occurrence, from)
else
find_next_occurrence(day, time, from, offset + 1)
end
end
# This function validates that a passed DateTime was properly constructed,
# is after the start time, and occurs on the indicated day.
@spec valid?(tuple, day, %Calendar.DateTime{ }) :: :true | :false
defp valid?(occurrence, :weekdays, from) do
@days
|> Enum.take(5)
|> Enum.any?(fn day -> valid?(occurrence, day, from) end)
end
defp valid?({:ok, date}, restriction, from) when restriction in @days do
DateTime.after?(date, from) and apply(Date, :"#{restriction}?", [date])
end
defp valid?(_occurrence, _restriction, _from), do: false
# This function converts a later DateTime to an offset of milliseconds from
# the start of the search.
@spec to_microseconds({:ok, %Calendar.DateTime{ }}, %Calendar.DateTime{ }) ::
integer
defp to_microseconds({:ok, to}, from) do
{:ok, seconds, microseconds, :after} = DateTime.diff(to, from)
milliseconds = seconds * 1_000
if microseconds > 0, do: milliseconds + 1_000, else: milliseconds
end
end