Skip to content

Commit

Permalink
Add business days functions to Date and Date_Time (#3726)
Browse files Browse the repository at this point in the history
Implements https://www.pivotaltracker.com/story/show/183082087

# Important Notes
- Removed unnecessary invocations of `Error.throw` improving performance of `Vector.distinct`. The time of the `add_work_days and work_days_until should be consistent with each other` test suite came down from 15s to 3s after the changes.
  • Loading branch information
radeusgd authored Sep 22, 2022
1 parent 7dc971f commit e9ebc66
Show file tree
Hide file tree
Showing 11 changed files with 505 additions and 12 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@
- [Added `databases`, `schemas`, `tables` support to database Connection.][3632]
- [Implemented `start_of` and `end_of` methods for date/time types allowing to
find start and end of a period of time containing the provided time.][3695]
- [Implemented `work_days_until` for counting work dys between dates and
`add_work_days` which allows to shift a date by a number of work days.][3726]

[debug-shortcuts]:
https://github.com/enso-org/enso/blob/develop/app/gui/docs/product/shortcuts.md#debug
Expand Down Expand Up @@ -316,6 +318,7 @@
[3684]: https://github.com/enso-org/enso/pull/3684
[3691]: https://github.com/enso-org/enso/pull/3691
[3695]: https://github.com/enso-org/enso/pull/3695
[3726]: https://github.com/enso-org/enso/pull/3726

#### Enso Compiler

Expand Down
16 changes: 8 additions & 8 deletions distribution/lib/Standard/Base/0.0.0-dev/src/Data/Map.enso
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,7 @@ type Map
example_get = Examples.map.get 1
get : Any -> Any ! No_Value_For_Key_Error
get self key =
go map = case map of
Tip -> Error.throw (No_Value_For_Key_Error_Data key)
Bin _ k v l r ->
if k == key then v else
if k > key then @Tail_Call go l else @Tail_Call go r
result = go self
result
self.get_or_else key (Error.throw (No_Value_For_Key_Error_Data key))

## Gets the value associated with `key` in this map, or returns `other` if
it isn't present.
Expand All @@ -217,7 +211,13 @@ type Map
example_get_or_else = Examples.map.get_or_else 2 "zero"
get_or_else : Any -> Any -> Any
get_or_else self key ~other =
self.get key . catch No_Value_For_Key_Error_Data (_ -> other)
go map = case map of
Tip -> other
Bin _ k v l r ->
if k == key then v else
if k > key then @Tail_Call go l else @Tail_Call go r
result = go self
result

## Transforms the map's keys and values to create a new map.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ rank_data input method=Rank_Method.Average =

report_nullpointer caught_panic = Error.throw (Illegal_Argument_Error_Data caught_panic.payload.cause.getMessage)
handle_nullpointer = Panic.catch NullPointerException handler=report_nullpointer
handle_classcast = Panic.catch ClassCastException handler=(Error.throw Vector.Incomparable_Values_Error)
handle_classcast = Panic.catch ClassCastException handler=(_ -> Error.throw Vector.Incomparable_Values_Error)

handle_classcast <| handle_nullpointer <|
java_ranks = Rank.rank input.to_array Comparator.new java_method
Expand Down
185 changes: 185 additions & 0 deletions distribution/lib/Standard/Base/0.0.0-dev/src/Data/Time/Date.enso
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,47 @@ type Date
end_of : Date_Period -> Date
end_of self period=Date_Period.Month = period.adjust_end self

## Counts workdays between self (inclusive) and the provided end date
(exclusive).

Arguments:
- end: the end date of the interval to count workdays in.
- holidays: dates of holidays to skip when counting workdays.
- include_end_date: whether to include the end date in the count.
By default the end date is not included in the interval.

? Including the end date
To be consistent with how we usually represent intervals (in an
end-exclusive manner), by default the end date is not included in the
count. This has the nice property that for example to count the work
days within the next week you can do
`date.work_days_until (date + 7.days)` and it will look at the 7 days
starting from the current `date` and not 8 days. This also gives us a
property that `date.work_days_until (date.add_work_days N) == N` for
any non-negative N. On the other hand, sometimes we may want the end
date to be included in the count, so we provide the `include_end_date`
argument for that purpose. Setting it to `True` should make the result
consistent with the `NETWORKDAYS` function in Excel and similar
products.

> Example
Count the number of workdays between two dates.

from Standard.Base import Date

example_workdays = Date.new 2020 1 1 . work_days_until (Date.new 2020 1 5)
work_days_until : Date -> Vector Date -> Boolean -> Integer
work_days_until self end holidays=[] include_end_date=False =
if include_end_date then self.work_days_until (end + 1.day) holidays include_end_date=False else
weekdays = week_days_between self end
## We count holidays that occurred within the period, but not on the
weekends (as weekend days have already been excluded from the count).
We also need to ensure we exclude each holiday only once, even if the
user provided it multiple times.
overlapping_holidays = holidays.filter holiday->
fits_in_range self end holiday && (is_weekend holiday).not
weekdays - overlapping_holidays.distinct.length

## ALIAS Date to Time

Combine this date with time of day to create a point in time.
Expand Down Expand Up @@ -271,6 +312,114 @@ type Date
+ self amount = if amount.is_time then Error.throw (Time_Error_Data "Date does not support time intervals") else
(Time_Utils.date_adjust self Time_Utils.AdjustOp.PLUS amount.internal_period) . internal_local_date

## Shift the date by the specified amount of business days.

For the purpose of this method, the business days are defined to be
Monday through Friday.

This method always returns a day which is a business day - if the shift
amount is zero, the closest following business day is returned. For the
purpose of calculating the shift, the holidays are treated as if we were
starting at the next business day after them, for example counting the
shift starting on Saturday or Sunday works as if we were counting the
shift from Monday (for positive shifts). So shifting Sunday by zero days
will return Monday, but shifting it by one day will return a Tuesday
(so that there is the full work day - Monday) within the interval. For
negative shifts, shifting either Saturday or Sunday one day backwards
will return Friday, but shifting Monday one day backwards will return a
Friday. The whole logic is made consistent with `work_days_until`, so
that the following properties hold:
date.work_days_until (date.add_work_days N) == N for any N >= 0
(date.add_work_days N).work_days_until date == -N for any N < 0

Arguments:
- amount: The number of business days to shift the date by. If `amount`
is zero, the current date is returned, unless it is a weekend or a
holiday, in which case the next business day is returned.
- holidays: An optional list of dates of custom holidays that should also
be skipped. If it is not provided, only weekends are skipped.

> Example
Shift the date by 5 business days.

example_shift = Date.new 2020 2 3 . add_work_days 5
add_work_days : Integer -> Vector Date -> Date
add_work_days self days=1 holidays=[] = case days >= 0 of
True ->
full_weeks = days.div 5
remaining_days = days % 5

# If the current day is a Saturday, the ordinal will be 6.
ordinal = self.day_of_week.to_integer first_day=Day_Of_Week.Monday start_at_zero=False

## If the current day is a Sunday, we just need to shift by one day
to 'escape' the weekend, regardless of the overall remaining
shift. On any other day, we check if current day plus the shift
overlaps a weekend, we need the shift to be 2 days since we need
to skip both Saturday and Sunday.
additional_shift = if ordinal == 7 then 1 else
if ordinal + remaining_days > 5 then 2 else 0

days_to_shift = full_weeks*7 + remaining_days + additional_shift
end = self + days_to_shift.days

## We have shifted the date so that weekends are taken into account,
but other holidays may have happened during that shift period.
Thus we may have shifted by less workdays than really desired. We
compute the difference and if there are still remaining workdays
to shift by, we re-run the whole shift procedure.
workdays = self.work_days_until end holidays include_end_date=False
diff = days - workdays
if diff > 0 then @Tail_Call end.add_work_days diff holidays else
## Otherwise we have accounted for all workdays we were asked
to. But that is still not the end - we still need to ensure
that the final day on which we have 'landed' is a workday
too. Our procedure ensures that it is not a weekend, but it
can still be a holiday. So we will be shifting the end date
as long as needed to fall on a non-weekend non-holiday
workday.
go end_date =
if holidays.contains end_date || is_weekend end_date then @Tail_Call go (end_date + 1.day) else end_date
go end
False ->
## We shift a bit so that if shifting by N full weeks, the 'last'
shift is done on `remaining_days` and not full weeks. That is
because shifting a Saturday back 5 days does not want us to get
to the earlier Saturday and fall back to the Friday before it,
but we want to stop at the Monday just after that Saturday.
full_weeks = (days + 1).div 5
remaining_days = (days + 1) % 5 - 1

# If the current day is a Sunday, the ordinal will be 1.
ordinal = self.day_of_week.to_integer first_day=Day_Of_Week.Sunday start_at_zero=False

## If we overlapped the weekend, we need to increase the shift by
one day (our current shift already shifts us by one day, but we
need one more to skip the whole two-day weekend).
additional_shift = if ordinal == 1 then -1 else
if ordinal + remaining_days <= 1 then -2 else 0

## The rest of the logic is analogous to the positive case, we
just need to correctly handle the reverse order of dates. The
`days_to_shift` will be negative so `end` will come _before_
`self`.
days_to_shift = full_weeks*7 + remaining_days + additional_shift
end = self + days_to_shift.days
workdays = end.work_days_until self holidays include_end_date=False

## `days` is negative but `workdays` is positive, `diff` will be
zero if we accounted for all days or negative if there are
still workdays we need to shift by - then it will be exactly
the remaining offset that we need to shift by.
diff = days + workdays
if diff < 0 then @Tail_Call end.add_work_days diff holidays else
## As in the positive case, if the final end date falls on a
holiday, we need to ensure that we move it - this time
backwards - to the first workday.
go end_date =
if holidays.contains end_date || is_weekend end_date then @Tail_Call go (end_date - 1.day) else end_date
go end

## Subtract the specified amount of time from this instant to get another
date.

Expand Down Expand Up @@ -355,3 +504,39 @@ type Date
== self that =
sign = Time_Utils.compare_to_localdate self that
0 == sign

## PRIVATE
week_days_between start end =
## We split the interval into 3 periods: the first week (containing the
starting point), the last week (containing the end point), and the full
weeks in between those. In some cases there may be no weeks in-between
and the first and last week can be the same week.
start_of_first_full_week = Time_Utils.start_of_next_week start
start_of_last_week = Time_Utils.start_of_week end
full_weeks_between = (Time_Utils.days_between start_of_first_full_week start_of_last_week).div 7
case full_weeks_between < 0 of
# Either start is before end or they both lie within the same week.
True ->
days_between = Time_Utils.days_between start end
if days_between <= 0 then 0 else
## The end day is not counted, but if the end day was a Sunday,
the last day was a Saturday and it should not be counted
either, so we need to subtract it.
case end.day_of_week of
Day_Of_Week.Sunday -> days_between - 1
_ -> days_between
False ->
# We count the days in the first week up until Friday - the weekend is not counted.
first_week_days = Math.max 0 (Time_Utils.days_between start (start_of_first_full_week - 2.days))
# We count the days in the last week, not including the weekend.
last_week_days = Math.min (Time_Utils.days_between start_of_last_week end) 5
full_weeks_between * 5 + first_week_days + last_week_days

## PRIVATE
is_weekend date =
dow = date.day_of_week
(dow == Day_Of_Week.Saturday) || (dow == Day_Of_Week.Sunday)

## PRIVATE
fits_in_range start end date =
(start <= date) && (date < end)
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,43 @@ type Date_Time
+ self amount =
Time_Utils.datetime_adjust self Time_Utils.AdjustOp.PLUS amount.internal_period amount.internal_duration

## Shift the date by the specified amount of business days.

For the purpose of this method, the business days are defined to be
Monday through Friday.

This method always returns a day which is a business day - if the shift
amount is zero, the closest following business day is returned. For the
purpose of calculating the shift, the holidays are treated as if we were
starting at the next business day after them, for example counting the
shift starting on Saturday or Sunday works as if we were counting the
shift from Monday (for positive shifts). So shifting Sunday by zero days
will return Monday, but shifting it by one day will return a Tuesday
(so that there is the full work day - Monday) within the interval. For
negative shifts, shifting either Saturday or Sunday one day backwards
will return Friday, but shifting Monday one day backwards will return a
Friday. The whole logic is made consistent with `work_days_until`, so
that the following properties hold:
date.work_days_until (date.add_work_days N) == N for any N >= 0
(date.add_work_days N).work_days_until date == -N for any N < 0

The time of day is preserved, only the date is shifted.

Arguments:
- amount: The number of business days to shift the date by. If `amount`
is zero, the current date is returned, unless it is a weekend or a
holiday, in which case the next business day is returned.
- holidays: An optional list of dates of custom holidays that should also
be skipped. If it is not provided, only weekends are skipped.

> Example
Shift the date by 5 business days.

example_shift = Date_Time.new 2020 2 3 11 45 . add_work_days 5
add_work_days : Integer -> Vector Date -> Date_Time
add_work_days self days=1 holidays=[] =
self.date.add_work_days days holidays . to_date_time self.time_of_day self.zone

## Subtract the specified amount of time from this instant to get a new
instant.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1119,7 +1119,7 @@ type Incomparable_Values_Error
Catches possible errors from comparing values and throws an
Incomparable_Values_Error if any occur.
handle_incomparable_value ~function =
handle t = Panic.catch t handler=(Error.throw Incomparable_Values_Error)
handle t = Panic.catch t handler=(_-> Error.throw Incomparable_Values_Error)
handle No_Such_Method_Error_Data <| handle Type_Error_Data <| handle Unsupported_Argument_Types_Data <| function

## PRIVATE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1108,7 +1108,7 @@ Text.write self path encoding=Encoding.utf_8 on_existing_file=Existing_File_Beha
[36, -62, -93, -62, -89, -30, -126, -84, -62, -94].write_bytes Examples.scratch_file.write_bytes Examples.scratch_file Existing_File_Behavior.Append
Vector.Vector.write_bytes : (File|Text) -> Existing_File_Behavior -> Nothing ! Illegal_Argument_Error | File_Not_Found | IO_Error | File_Already_Exists_Error
Vector.Vector.write_bytes self path on_existing_file=Existing_File_Behavior.Backup =
Panic.catch Unsupported_Argument_Types_Data handler=(Error.throw (Illegal_Argument_Error_Data "Only Vectors consisting of bytes (integers in the range from -128 to 127) are supported by the `write_bytes` method.")) <|
Panic.catch Unsupported_Argument_Types_Data handler=(_ -> Error.throw (Illegal_Argument_Error_Data "Only Vectors consisting of bytes (integers in the range from -128 to 127) are supported by the `write_bytes` method.")) <|
## Convert to a byte array before writing - and fail early if there is any problem.
byte_array = Array_Builder.ensureByteArray self.to_array

Expand Down
17 changes: 17 additions & 0 deletions std-bits/base/src/main/java/org/enso/base/Time_Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,21 @@ public static TimeUtilsBase utils_for(Value value) {
public static ZoneOffset get_datetime_offset(ZonedDateTime datetime) {
return datetime.getOffset();
}

/**
* Counts days within the range from start (inclusive) to end (exclusive).
*
* <p>If start is before end, it will return 0.
*/
public static long days_between(LocalDate start, LocalDate end) {
return ChronoUnit.DAYS.between(start, end);
}

public static LocalDate start_of_week(LocalDate date) {
return date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
}

public static LocalDate start_of_next_week(LocalDate date) {
return date.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
}
}
2 changes: 1 addition & 1 deletion test/Tests/src/Data/Ordering/Comparator_Spec.enso
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type No_Ord
# Tests

spec = Test.group "Object Comparator" <|
handle_classcast = Panic.catch ClassCastException handler=(Error.throw Vector.Incomparable_Values_Error)
handle_classcast = Panic.catch ClassCastException handler=(_ -> Error.throw Vector.Incomparable_Values_Error)
default_comparator a b = handle_classcast <| Comparator.new.compare a b
case_insensitive a b = handle_classcast <| Comparator.for_text_ordering (Text_Ordering_Data False Case_Insensitive_Data) . compare a b

Expand Down
Loading

0 comments on commit e9ebc66

Please sign in to comment.