Skip to content

Commit

Permalink
DateTime util module and tests (#2372)
Browse files Browse the repository at this point in the history
* date_time module and tests

* docs

* linting

* some cleanup

* some cleanup

* remove broken tests and add new tests

* this week test

* make service rollover time configurable

* split into two modules

* more tests

* 100% coverage

* some docs

* some docs

* format

* helper funcs

* move test to helper func

* docs

* format

* move private funcs to factory

* format

* alphabetize config

* remove unused import

* service range and tests

* PR feedback

* microsecond fidelity

* microsecond fidelity

* nil in range and docs

* more tests

* docs

* move in_range?

* call it date time range
  • Loading branch information
anthonyshull authored Feb 11, 2025
1 parent 54a668c commit 177282b
Show file tree
Hide file tree
Showing 12 changed files with 741 additions and 15 deletions.
38 changes: 23 additions & 15 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import Config

config :elixir, ansi_enabled: true

config :dotcom, :aws_client, AwsClient.Behaviour

config :dotcom, :content_security_policy_definition, ""
config :dotcom, :cms_api_module, CMS.Api

config :dotcom, :httpoison, HTTPoison
config :dotcom, :content_security_policy_definition, ""

config :dotcom, :mbta_api_module, MBTA.Api
config :dotcom, :date_time_module, Dotcom.Utils.DateTime

config :dotcom, :httpoison, HTTPoison

config :dotcom, :location_service, LocationService

config :dotcom, :repo_modules,
predictions: Predictions.Repo,
route_patterns: RoutePatterns.Repo,
routes: Routes.Repo,
stops: Stops.Repo
config :dotcom, :mbta_api_module, MBTA.Api

config :dotcom, :otp_module, OpenTripPlannerClient

config :dotcom, :predictions_phoenix_pub_sub, Predictions.Phoenix.PubSub
config :dotcom, :predictions_pub_sub, Predictions.PubSub
Expand All @@ -27,20 +24,26 @@ config :dotcom, :redis, Dotcom.Cache.Multilevel.Redis
config :dotcom, :redix, Redix
config :dotcom, :redix_pub_sub, Redix.PubSub

config :dotcom, :otp_module, OpenTripPlannerClient
config :dotcom, :repo_modules,
predictions: Predictions.Repo,
route_patterns: RoutePatterns.Repo,
routes: Routes.Repo,
stops: Stops.Repo

config :dotcom, :req_module, Req

config :dotcom, :service_rollover_time, ~T[03:00:00]

config :dotcom, :timezone, "America/New_York"

tile_server_url =
if config_env() == :prod,
do: "https://cdn.mbta.com",
else: "https://mbta-map-tiles-dev.s3.amazonaws.com"

config :dotcom, tile_server_url: tile_server_url

config :sentry,
enable_source_code_context: true,
root_source_code_paths: [File.cwd!()],
context_lines: 5
config :elixir, ansi_enabled: true

config :mbta_metro, custom_icons: ["#{File.cwd!()}/priv/static/icon-svg/*"]

Expand Down Expand Up @@ -74,4 +77,9 @@ config :mbta_metro, :map, %{
zoom: 14
}

config :sentry,
enable_source_code_context: true,
root_source_code_paths: [File.cwd!()],
context_lines: 5

import_config "#{config_env()}.exs"
3 changes: 3 additions & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ config :dotcom, :cache, Dotcom.Cache.TestCache
config :dotcom, :httpoison, HTTPoison.Mock

config :dotcom, :cms_api_module, CMS.Api.Static

config :dotcom, :date_time_module, Dotcom.Utils.DateTime.Mock

config :dotcom, :mbta_api_module, MBTA.Api.Mock

config :dotcom, :location_service, LocationService.Mock
Expand Down
75 changes: 75 additions & 0 deletions lib/dotcom/utils/date_time.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule Dotcom.Utils.DateTime do
@moduledoc """
A collection of functions for working with date_times.
Consuming modules are responsible for parsing or converting date_times.
They should *always* call `coerce_ambiguous_date_time/1` before using a date_time.
This is mainly because Timex has so many functions that serve as entry points to date_times.
Those functions can return ambiguous date_times during DST transitions.
"""

use Timex

alias Dotcom.Utils.DateTime.Behaviour

@behaviour Behaviour

@typedoc """
A date_time_range is a tuple of two date_times: {start, stop}.
Either the start or stop can be nil, but not both.
"""
@type date_time_range() ::
{DateTime.t(), DateTime.t()} | {nil, DateTime.t()} | {DateTime.t(), nil}

@timezone Application.compile_env!(:dotcom, :timezone)

@doc """
Get the date_time in the set @timezone.
"""
@impl Behaviour
def now(), do: Timex.now(@timezone)

@doc """
In the default case, we'll return a DateTime when given one.
Timex can give us ambiguous times when we "fall-back" in DST transitions.
That is because the same hour occurs twice.
In that case, we choose the later time.
Timex will return an error if the time occurs when we "spring-forward" in DST transitions.
That is because one hour does not occur--02:00:00am to 03:00:00am.
In that case, we set the time to 03:00:00am.
"""
@impl Behaviour
def coerce_ambiguous_date_time(%DateTime{} = date_time), do: date_time
def coerce_ambiguous_date_time(%Timex.AmbiguousDateTime{after: later}), do: later

def coerce_ambiguous_date_time({:error, {_, @timezone, seconds_from_zeroyear, _}}) do
Timex.zero()
|> Timex.shift(seconds: seconds_from_zeroyear)
|> Timex.to_datetime(@timezone)
|> coerce_ambiguous_date_time()
|> Timex.shift(hours: 2)
|> coerce_ambiguous_date_time()
end

@doc """
Given a date_time_range and a date_time, returns true if the date_time is within the date_time_range.
"""
@impl Behaviour
def in_range?({nil, nil}, _), do: false

def in_range?({nil, %DateTime{} = stop}, %DateTime{} = date_time) do
Timex.before?(date_time, stop) || Timex.equal?(date_time, stop, :microsecond)
end

def in_range?({%DateTime{} = start, nil}, %DateTime{} = date_time) do
Timex.after?(date_time, start) || Timex.equal?(date_time, start, :microsecond)
end

def in_range?({%DateTime{} = start, %DateTime{} = stop}, %DateTime{} = date_time) do
in_range?({start, nil}, date_time) && in_range?({nil, stop}, date_time)
end

def in_range?(_, _), do: false
end
19 changes: 19 additions & 0 deletions lib/dotcom/utils/date_time/behaviour.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
defmodule Dotcom.Utils.DateTime.Behaviour do
@moduledoc """
A behaviour for working with date_times.
"""

@callback now() :: DateTime.t()

@callback coerce_ambiguous_date_time(
DateTime.t()
| Timex.AmbiguousDateTime.t()
| {:error, term()}
) ::
DateTime.t()

@callback in_range?(
Dotcom.Utils.DateTime.date_time_range(),
DateTime.t()
) :: boolean
end
209 changes: 209 additions & 0 deletions lib/dotcom/utils/service_date_time.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
defmodule Dotcom.Utils.ServiceDateTime do
@moduledoc """
A collection of functions that helps to work with date_times with regard to service ranges.
Currently, we consider the most general case where service starts at 03:00:00am and ends at 02:59:59am.
In the future, we aim to add route-specific service times.
The service range continuum:
<---before today---|---later this week---|---next week---|---after next week--->
today
Before today and after next week are open intervals.
"""

require Logger

use Timex

alias Dotcom.Utils

@type named_service_range() ::
:before_today | :today | :later_this_week | :next_week | :after_next_week
@date_time_module Application.compile_env!(:dotcom, :date_time_module)
@service_rollover_time Application.compile_env!(:dotcom, :service_rollover_time)
@timezone Application.compile_env!(:dotcom, :timezone)

@doc """
Returns the time at which service rolls over from 'today' to 'tomorrow'.
"""
def service_rollover_time(), do: @service_rollover_time

@doc """
Get the service date for the given date_time.
If the time is before 03:00:00am, we consider it to be the previous day.
"""
@spec service_date() :: Date.t()
@spec service_date(DateTime.t()) :: Date.t()
def service_date(date_time \\ @date_time_module.now()) do
if date_time.hour < @service_rollover_time.hour do
Timex.shift(date_time, hours: -@service_rollover_time.hour)
|> @date_time_module.coerce_ambiguous_date_time()
|> Timex.to_date()
else
Timex.to_date(date_time)
end
end

@doc """
The service range for the given date_time.
"""
@spec service_range(DateTime.t()) :: named_service_range()
def service_range(date_time) do
Enum.find(
[
&service_before_today?/1,
&service_today?/1,
&service_later_this_week?/1,
&service_next_week?/1,
&service_after_next_week?/1
],
fn f ->
f.(date_time)
end
)
|> Kernel.inspect()
|> Kernel.then(fn module -> Regex.run(~r/_(\w+)\?/, module) end)
|> List.last()
|> String.to_atom()
end

@doc """
Get the beginning of the service day for the day after the given date_time.
"""
@spec beginning_of_next_service_day() :: DateTime.t()
@spec beginning_of_next_service_day(DateTime.t()) :: DateTime.t()
def beginning_of_next_service_day(datetime \\ @date_time_module.now()) do
datetime
|> end_of_service_day()
|> Timex.shift(microseconds: 1)
|> @date_time_module.coerce_ambiguous_date_time()
end

@doc """
Get the beginning of the service day for the given date_time.
"""
@spec beginning_of_service_day() :: DateTime.t()
@spec beginning_of_service_day(DateTime.t()) :: DateTime.t()
def beginning_of_service_day(date_time \\ @date_time_module.now()) do
date_time
|> service_date()
|> Timex.to_datetime(@timezone)
|> @date_time_module.coerce_ambiguous_date_time()
|> Map.put(:hour, @service_rollover_time.hour)
end

@doc """
Get the end of the service day for the given date_time.
"""
@spec end_of_service_day() :: DateTime.t()
@spec end_of_service_day(DateTime.t()) :: DateTime.t()
def end_of_service_day(date_time \\ @date_time_module.now()) do
date_time
|> service_date()
|> Timex.to_datetime(@timezone)
|> @date_time_module.coerce_ambiguous_date_time()
|> Timex.shift(days: 1, hours: @service_rollover_time.hour, microseconds: -1)
|> @date_time_module.coerce_ambiguous_date_time()
|> Map.put(:hour, @service_rollover_time.hour - 1)
end

@doc """
Get a service range for the day of the given date_time.
Service days go from 03:00:00am to 02:59:59am the following day.
"""
@spec service_range_day() :: Utils.DateTime.date_time_range()
@spec service_range_day(DateTime.t()) :: Utils.DateTime.date_time_range()
def service_range_day(date_time \\ @date_time_module.now()) do
beginning_of_service_day = beginning_of_service_day(date_time)
end_of_service_day = end_of_service_day(date_time)

{beginning_of_service_day, end_of_service_day}
end

@doc """
Get a service range for the week of the given date_time.
Service weeks go from Monday at 03:00:00am to the following Monday at 02:59:59am.
If today is the last day in the service week, the range will be the same as the range for today.
"""
@spec service_range_later_this_week() :: Utils.DateTime.date_time_range()
@spec service_range_later_this_week(DateTime.t()) :: Utils.DateTime.date_time_range()
def service_range_later_this_week(date_time \\ @date_time_module.now()) do
beginning_of_next_service_day = beginning_of_next_service_day(date_time)

end_of_later_this_week = date_time |> Timex.end_of_week() |> end_of_service_day()

case Timex.compare(beginning_of_next_service_day, end_of_later_this_week) do
1 -> service_range_day(date_time)
_ -> {beginning_of_next_service_day, end_of_later_this_week}
end
end

@doc """
Get a service range for the week following the current week of the given date_time.
"""
@spec service_range_next_week() :: Utils.DateTime.date_time_range()
@spec service_range_next_week(DateTime.t()) :: Utils.DateTime.date_time_range()
def service_range_next_week(date_time \\ @date_time_module.now()) do
{_, end_of_later_this_week} = service_range_later_this_week(date_time)
beginning_of_next_week = Timex.shift(end_of_later_this_week, microseconds: 1)

end_of_next_week =
beginning_of_next_week |> Timex.end_of_week() |> end_of_service_day()

{beginning_of_next_week, end_of_next_week}
end

@doc """
Get a service range for all time after the following week of the given date_time.
"""
@spec service_range_after_next_week() :: Utils.DateTime.date_time_range()
@spec service_range_after_next_week(DateTime.t()) :: Utils.DateTime.date_time_range()
def service_range_after_next_week(date_time \\ @date_time_module.now()) do
{_, end_of_next_week} = date_time |> service_range_next_week()
beginning_of_after_next_week = Timex.shift(end_of_next_week, microseconds: 1)

{beginning_of_after_next_week, nil}
end

@doc """
Is the given date_time before the beginning of service today?
"""
@spec service_before_today?(DateTime.t()) :: boolean
def service_before_today?(date_time) do
Timex.before?(date_time, beginning_of_service_day())
end

@doc """
Does the given date_time fall within today's service range?
"""
@spec service_today?(DateTime.t()) :: boolean
def service_today?(date_time) do
service_range_day() |> @date_time_module.in_range?(date_time)
end

@doc """
Does the given date_time fall within the service range of this week?
"""
@spec service_later_this_week?(DateTime.t()) :: boolean
def service_later_this_week?(date_time) do
service_range_later_this_week() |> @date_time_module.in_range?(date_time)
end

@doc """
Does the given date_time fall within the service range of next week?
"""
@spec service_next_week?(DateTime.t()) :: boolean
def service_next_week?(date_time) do
service_range_next_week() |> @date_time_module.in_range?(date_time)
end

@doc """
Does the given date_time fall within the service range after next week?
"""
@spec service_after_next_week?(DateTime.t()) :: boolean
def service_after_next_week?(date_time) do
service_range_after_next_week() |> @date_time_module.in_range?(date_time)
end
end
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ defmodule DotCom.Mixfile do
{:sentry, "10.8.1"},
{:server_sent_event_stage, "1.2.1"},
{:sizeable, "1.0.2"},
{:stream_data, "1.1.3", only: [:dev, :test]},
{:sweet_xml, "0.7.5", only: [:dev, :prod]},
{:telemetry, "1.3.0", override: true},
{:telemetry_metrics, "1.1.0", override: true},
Expand Down
Loading

0 comments on commit 177282b

Please sign in to comment.