Skip to content

Commit d2d4d0f

Browse files
Add a Month Range struct with support for Enumerable and Zeitraum protocols
1 parent c6f0050 commit d2d4d0f

File tree

4 files changed

+266
-0
lines changed

4 files changed

+266
-0
lines changed

lib/month.ex

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,28 @@ defmodule Shared.Month do
417417
compare(first, from_day!(second))
418418
end
419419

420+
@doc """
421+
Creates a range of months from start to stop.
422+
423+
## Example
424+
425+
iex> Month.range(@third_month_of_2018, @third_month_of_2019)
426+
%Shared.Month.Range{direction: :forward, size: 13, start: ~m[2018-03]}
427+
"""
428+
@spec range(Month.t(), Month.t()) :: Month.Range.t()
429+
defdelegate range(start, stop), to: Shared.Month.Range, as: :new
430+
431+
@doc """
432+
Creates a range of months from start to stop in the given direction.
433+
434+
## Example
435+
436+
iex> Month.range(@third_month_of_2019, @third_month_of_2018, :backward)
437+
%Shared.Month.Range{direction: :backward, size: 13, start: ~m[2019-03]}
438+
"""
439+
@spec range(t(), t(), Shared.Month.Range.direction()) :: Shared.Month.Range.t()
440+
defdelegate range(start, stop, direction), to: Shared.Month.Range, as: :new
441+
420442
@doc ~S"""
421443
## Examples
422444

lib/month/range.ex

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
defmodule Shared.Month.Range do
2+
@moduledoc false
3+
alias Shared.Month
4+
5+
@type direction :: :forward | :backward
6+
@type t :: %__MODULE__{
7+
start: Month.t(),
8+
size: non_neg_integer(),
9+
direction: direction()
10+
}
11+
12+
defstruct [:start, :size, :direction]
13+
14+
@doc """
15+
Creates a Month Range with the given start month and end month or size.
16+
17+
## Examples
18+
19+
iex> Month.Range.forward(~m[2024-01], ~m[2024-03])
20+
%Month.Range{start: ~m[2024-01], size: 3, direction: :forward}
21+
22+
iex> Month.Range.forward(~m[2024-01], 2)
23+
%Month.Range{start: ~m[2024-01], size: 2, direction: :forward}
24+
"""
25+
@spec forward(Month.t(), Month.t() | (size :: pos_integer())) :: t()
26+
def forward(start, size_or_end), do: new(start, size_or_end, :forward)
27+
28+
@doc """
29+
Creates a Month Range with the given start month and end month or size.
30+
31+
## Examples
32+
33+
iex> Month.Range.backward(~m[2024-04], ~m[2024-02])
34+
%Month.Range{start: ~m[2024-04], size: 3, direction: :backward}
35+
36+
iex> Month.Range.backward(~m[2024-05], 2)
37+
%Month.Range{start: ~m[2024-05], size: 2, direction: :backward}
38+
"""
39+
@spec backward(Month.t(), Month.t() | (size :: pos_integer())) :: t()
40+
def backward(start, size_or_end), do: new(start, size_or_end, :backward)
41+
42+
@doc """
43+
Creates a Month Range with the given start month and end month or size.
44+
45+
## Examples
46+
47+
iex> Month.Range.forward(~m[2024-01], ~m[2024-03])
48+
%Month.Range{start: ~m[2024-01], size: 3, direction: :forward}
49+
50+
iex> Month.Range.forward(~m[2024-03], ~m[2024-01])
51+
%Month.Range{start: ~m[2024-03], size: 0, direction: :forward}
52+
53+
iex> Month.Range.forward(~m[2024-03], 2)
54+
%Month.Range{start: ~m[2024-03], size: 2, direction: :forward}
55+
"""
56+
@spec new(Month.t(), Month.t() | pos_integer()) :: t()
57+
def new(%Month{} = start, %Month{} = stop) do
58+
diff = Month.diff(start, stop)
59+
direction = if diff < 0, do: :backward, else: :forward
60+
%__MODULE__{start: start, size: abs(diff) + 1, direction: direction}
61+
end
62+
63+
def new(%Month{} = start, size), do: new(start, size, :forward)
64+
65+
@spec new(Month.t(), pos_integer(), direction()) :: t()
66+
def new(_, _, direction) when direction not in [:forward, :backward],
67+
do: raise(ArgumentError, "Invalid direction: #{inspect(direction)}")
68+
69+
def new(%Month{} = start, %Month{} = stop, :forward) do
70+
size = max(Month.diff(start, stop) + 1, 0)
71+
%__MODULE__{start: start, size: size, direction: :forward}
72+
end
73+
74+
def new(%Month{} = start, %Month{} = stop, :backward) do
75+
size = max(Month.diff(stop, start) + 1, 0)
76+
%__MODULE__{start: start, size: size, direction: :backward}
77+
end
78+
79+
def new(_, size, _) when not is_integer(size) or size < 0,
80+
do: raise(ArgumentError, "Invalid size: #{inspect(size)}")
81+
82+
def new(%Month{} = start, size, direction),
83+
do: %__MODULE__{start: start, size: size, direction: direction}
84+
85+
@doc """
86+
Returns the earliest month of the range.
87+
88+
Returns `nil` for empty ranges.
89+
90+
## Examples
91+
92+
iex> Month.Range.earliest(Month.Range.forward(~m[2024-01], ~m[2024-03]))
93+
~m[2024-01]
94+
95+
iex> Month.Range.earliest(Month.Range.backward(~m[2024-03], ~m[2024-02]))
96+
~m[2024-02]
97+
98+
iex> Month.Range.earliest(Month.Range.backward(~m[2024-01], ~m[2024-02]))
99+
nil
100+
"""
101+
def earliest(%__MODULE__{size: 0}), do: nil
102+
def earliest(%__MODULE__{start: start, direction: :forward}), do: start
103+
104+
def earliest(%__MODULE__{start: start, direction: :backward, size: size}),
105+
do: Month.add(start, (size - 1) * -1)
106+
107+
@doc """
108+
Returns the latest month of the range.
109+
110+
Returns `nil` for empty ranges.
111+
112+
## Examples
113+
114+
iex> Month.Range.latest(Month.Range.forward(~m[2024-01], ~m[2024-03]))
115+
~m[2024-03]
116+
117+
iex> Month.Range.latest(Month.Range.backward(~m[2024-04], ~m[2024-02]))
118+
~m[2024-04]
119+
120+
iex> Month.Range.latest(Month.Range.backward(~m[2024-01], ~m[2024-02]))
121+
nil
122+
"""
123+
def latest(%__MODULE__{size: 0}), do: nil
124+
def latest(%__MODULE__{start: start, direction: :backward}), do: start
125+
126+
def latest(%__MODULE__{start: start, direction: :forward, size: size}),
127+
do: Month.add(start, size - 1)
128+
129+
@doc """
130+
Converts Month Range to an end-inclusive Date.Range from earliest to latest Month.
131+
132+
The resulting date range will always be built with a step size of 1, but the
133+
range might be empty.
134+
135+
## Example
136+
137+
iex> Shared.Month.Range.forward(~m[2024-01], ~m[2024-03]) |> Shared.Month.Range.to_date_range()
138+
Date.range(~D[2024-01-01], ~D[2024-03-31], 1)
139+
140+
iex> Shared.Month.Range.backward(~m[2024-01], ~m[2024-03]) |> Shared.Month.Range.to_date_range()
141+
Date.range(~D[2024-01-01], ~D[2023-12-31], 1)
142+
"""
143+
@spec to_date_range(t()) :: Date.Range.t()
144+
def to_date_range(%__MODULE__{size: 0, start: start}),
145+
do: Date.range(Month.first_day(start), Date.add(Month.first_day(start), -1), 1)
146+
147+
def to_date_range(%__MODULE__{} = month_range) do
148+
first = month_range |> earliest() |> Month.first_day()
149+
last = month_range |> latest() |> Month.last_day()
150+
Date.range(first, last)
151+
end
152+
153+
defimpl Enumerable do
154+
alias Shared.Month
155+
156+
def count(%Month.Range{size: size}), do: {:ok, size}
157+
158+
def member?(%Month.Range{size: 0}, _), do: {:ok, false}
159+
def member?(%Month.Range{start: month, size: 1}, %Month{} = month), do: {:ok, true}
160+
161+
def member?(%Month.Range{} = range, %Month{} = month) do
162+
earliest = Month.Range.earliest(range)
163+
latest = Month.Range.latest(range)
164+
165+
{:ok,
166+
Month.compare(month, earliest) in [:eq, :gt] and
167+
Month.compare(month, latest) in [:eq, :lt]}
168+
end
169+
170+
def reduce(%Month.Range{start: start, size: size, direction: direction}, acc, fun) do
171+
step =
172+
case direction do
173+
:forward -> 1
174+
:backward -> -1
175+
end
176+
177+
start
178+
|> Stream.iterate(&Month.add(&1, step))
179+
|> Enum.take(size)
180+
|> Enumerable.reduce(acc, fun)
181+
end
182+
183+
def slice(%Month.Range{start: start, size: size, direction: direction}) do
184+
{:ok, size,
185+
fn start_at, amount, step ->
186+
step =
187+
case direction do
188+
:forward -> step
189+
:backward -> step * -1
190+
end
191+
192+
start_at =
193+
case direction do
194+
:forward -> start_at
195+
:backward -> start_at * -1
196+
end
197+
198+
start
199+
|> Month.add(start_at)
200+
|> Stream.iterate(&Month.add(&1, step))
201+
|> Enum.take(amount)
202+
end}
203+
end
204+
end
205+
end

lib/month/range_test.exs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
defmodule Shared.Month.RangeTest do
2+
@moduledoc false
3+
use ExUnit.Case
4+
alias Shared.Month
5+
6+
import Month, only: [sigil_m: 2]
7+
8+
doctest(Month.Range)
9+
10+
describe "is enumerable" do
11+
test "can be countet" do
12+
assert 648 = Enum.count(Month.Range.forward(~m[2024-01], ~m[2077-12]))
13+
end
14+
15+
test "can be converted to a list" do
16+
assert [~m[2024-01], ~m[2024-02]] ==
17+
Enum.to_list(Month.Range.forward(~m[2024-01], ~m[2024-02]))
18+
end
19+
20+
test "can be sliced" do
21+
assert [~m[2024-05], ~m[2024-06]] ==
22+
Enum.slice(Month.Range.forward(~m[2024-01], ~m[2024-12]), 4, 2)
23+
end
24+
end
25+
26+
describe "is supported by ZeitraumProtokoll" do
27+
alias Shared.Zeitraum
28+
29+
test "can be converted to intervall" do
30+
assert %{from: ~N[2024-01-01 00:00:00], until: ~N[2078-01-01 00:00:00]} =
31+
Zeitraum.als_intervall(Month.Range.forward(~m[2024-01], ~m[2077-12]))
32+
end
33+
end
34+
end

lib/zeitraum_protokoll.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ defimpl Shared.ZeitraumProtokoll, for: Shared.Month do
1010
end
1111
end
1212

13+
defimpl Shared.ZeitraumProtokoll, for: Shared.Month.Range do
14+
def als_intervall(%@for{} = month_range),
15+
do: @protocol.als_intervall(@for.to_date_range(month_range))
16+
end
17+
1318
defimpl Shared.ZeitraumProtokoll, for: Shared.Week do
1419
def als_intervall(week) do
1520
{beginns_at, ends_at} = Shared.Week.to_dates(week)

0 commit comments

Comments
 (0)