1

Suppose I have some deadline time object deadline = project_start + 34.days

Now let's assume there is some sort of vacation/postpone business logic which must make me extend the deadline with the duration of those vacations.

Let's assume my vacations are an array or ranges (or couple start/end date, whatever) And the project started on december 15th

project_start = Time.parse('2017-12-15 0:00') # December 15th
deadline = project_start + 34.days            # January 18th

vacations = [
  Time.parse('2017-12-10 0:00')..Time.parse('2018-01-20 0:00'), # Starts 5 days before the project start, so only 5 out of 10 days extend the deadline
  Time.parse('2018-01-05 0:00')..Time.parse('2018-01-10 0:00'), # Starts during the project so whole vacation period must extend the deadline
  Time.parse('2018-01-20 0:00')..Time.parse('2018-01-25 0:00'), # STarts after the original deadline, but dued to previous vacations, this vacation fully extends the deadline
]

How can I (easily) compute the extended deadline taking into account the vacations ?

I tried looking for existing solutions out there, but most gems I found involved static holidays, but I am interested in something that is per-user.

Basically I was thinking of ordering the vacations, and extending the deadline at each step, but I need to take into account that maybe a vacation was started before the actual project start. It's becoming a bit hard to figure out the right thing to do. I'll post my first attempt at this

Cyril Duchon-Doris
  • 12,964
  • 9
  • 77
  • 164
  • Your question needs to be clarified. You refer to "start date" and have a variable `deadline`. One would normally think they refer to a project start date and a deadline for completion of the project, but in the example they are the same date. Please edit to clarify. In the example, ensure all inputs are given and show the expected result for the given inputs. Choose data to reflect possible complications such as a vacation starting before the start date or after the completion date, and vacations overlapping if that's a possibility (and say so if that's not a possibility). – Cary Swoveland Jun 13 '18 at 21:41
  • Another troublesome detail concerns vacations that start after the initial completion date but start before the completion date after the later is pushed later by one or more other vacations. In your edit please clarify whether that is a possibility. – Cary Swoveland Jun 13 '18 at 21:50
  • THanks @CarySwoveland there were some mistakes in the definition, hope it's clearer now – Cyril Duchon-Doris Jun 14 '18 at 08:11
  • Yes, it's clearer. I seem to have guessed right. – Cary Swoveland Jun 14 '18 at 18:13

2 Answers2

0

So here's the first method I designed, I'm writing specs and maybe it's already good but I have doubts

# Extends a deadline with extra time provided as "vacations"
#
# @param range: [Range<Time>]
# @param vacations: [Array<Range<Time>>]
#
# @return [Range]
def extend_time_range_with_vacations(range:, vacations:)
  extended_deadline = range.last.dup
  vacations.each do |vacation|
    cover = (range.first..extended_deadline) & vacation
    next unless cover.present?
    # Here I need to pick more than just the intersection
    #   For eg maybe the vacation is 2.weeks long
    #   but the range started only in the 2nd week of the vacation,
    #   hence the time extension is 1.week only)
    actual_cover = cover.first..vacation.last
    extended_deadline += (actual_cover.last - actual_cover.first)
  end
  extended_deadline
end

But it feels like there's a lot to do and maybe something simpler exists ?

EDIT : code changed to use this range intersection monkeypatch

EDIT : Spec

describe '#extend_time_range_with_vacations' do
  let(:start_d) { Time.parse('2018-04-20 8:42') }
  let(:end_d) { start_d + 42.days }
  let(:range) { start_d..end_d }
  let(:vacations) do
    [
      (start_d - 4.days)..(start_d + 4.days), # 4 days extension out of 8
      (start_d + 10.days)..(start_d + 14.days), # 4 days extension
      (end_d + 4.days)..(end_d + 8.days), # 4 days extensions unlocked because of previous vacations
    ]
  end

  it 'returns the expected extended deadline' do
    new_range = Utility.extend_time_range_with_vacations(range: range, vacations: vacations)
    expect(new_range).to be_within(1).of(end_d + (4 * 3).days)
  end
end
Cyril Duchon-Doris
  • 12,964
  • 9
  • 77
  • 164
0

I have addressed the following problem. We are given a start date and the number of days of work required to complete a project. We are also given an array of vacation periods. If the project has begun and by a given date has not been completed, no work is performed on that date if that date falls within any vacation period. Each vacation period is a range of consecutive days which may begin or end before the start date and which may overlap other vacation periods.1.

The objective is to determine the date on which the project will be completed.

The question is cast differently, but if my interpretation is accurate other than for the units (e.g., I've assumed date inputs and the date output are strings rather than Time objects`), it would not be difficult to modify my answer to be consistent with requirements.

Code

require 'date'
require 'set'

DATE_FMT = "%Y-%m-%d"

def end_date(start_date, duration, vacations)
  start_date = Date.strptime(start_date, DATE_FMT)
  vacation_dates = construct_vacations_dates_set(vacations)
  d = start_date
  while duration > 0
    duration -= 1 unless vacation_dates.include?(d)
    d = d.next
  end
  d.strftime(DATE_FMT)
end

def construct_vacations_dates_set(vacations)
  vacations.map do |a|
    s, e = a.map { |s| Date.strptime(s, DATE_FMT) }
    (s..e).to_a
  end.
  reduce(:|).
  to_set
end

Example

start_date = '2017-12-15'
vacations = [['2017-11-20', '2017-12-03'],
             ['2017-12-13', '2017-12-19'],
             ['2017-12-23', '2018-01-02'],
             ['2017-12-29', '2018-01-08'],
             ['2018-01-14', '2018-02-28'],
             ['2018-05-18', '2018-06-01']]

(1..20).each { |n| puts "for duration %2d days: %s" %
   [n, end_date(start_date, n, vacations)] }

prints

for duration  1 days: 2017-12-21
for duration  2 days: 2017-12-22
for duration  3 days: 2017-12-23

for duration  4 days: 2018-01-10
for duration  5 days: 2018-01-11
for duration  6 days: 2018-01-12
for duration  7 days: 2018-01-13
for duration  8 days: 2018-01-14

for duration  9 days: 2018-03-02
for duration 10 days: 2018-03-03
for duration 11 days: 2018-03-04
for duration 12 days: 2018-03-05
for duration 13 days: 2018-03-06
for duration 14 days: 2018-03-07
for duration 15 days: 2018-03-08
for duration 16 days: 2018-03-09
for duration 17 days: 2018-03-10
for duration 18 days: 2018-03-11
for duration 19 days: 2018-03-12
for duration 20 days: 2018-03-13

Explanation

See Date#strftime, Date::strptime, Date#next (aka next_day) and Enumerable#reduce (aka inject). reduce takes the "set union" of the vacation arrays (of consecutive dates) by applying the method Array#|.

I converted the array of vacation dates to a set for fast lookups. For the example given, if the elements of vacation_dates were converted to an array of non-overlapping ranges, and then the range endpoint were converted to date strings, that array would be as follows.

["2017-11-20".."2017-12-03",
 "2017-12-13".."2017-12-19",
 "2017-12-23".."2018-01-08",
 "2018-01-14".."2018-02-28",
 "2018-05-18".."2018-06-01"]

The first range is wholly before start_date, but there is no need to remove it. More generally, there is no need to remove vacation dates before start_date.

The steps to compute the set of vacation dates can be seen by adding some puts statements. To improve readability I have removed Date objects that fall within ranges in the return value and printed lines, replacing groups of them with ,...,. I also abbreviated Date objects. For example, I shortened #<Date: 2017-12-13 ((2458101j,0s,0n),+0s,2299161j)> to #<Date: 2017-12-13...>.

def construct_vacations_dates_set(vacations)
  vacations.map do |a|
      puts "a = #{a}"
    s, e = a.map { |s| Date.strptime(s, DATE_FMT) }
      puts "  s = #{s}, e = #{e}"
    (s..e).to_a.
      tap { |o| puts "  (s..e).to_a = #{o}" }
  end.
  reduce(:|).
    tap { |o| puts "after reduce(:|) = #{o}" }.
  to_set
end

construct_vacations_dates_set([['2017-12-13', '2017-12-19'],
  ['2017-12-23', '2018-01-02'], ['2017-12-29', '2018-01-08']])
  #=> #<Set: {#<Date: 2017-12-13...>,..., #<Date: 2017-12-19...>, 
  #           #<Date: 2017-12-23...>,..., #<Date: 2018-01-08...>}>

prints (with elements in ranges not shown)

a = ["2017-12-13", "2017-12-19"]
  s = 2017-12-13, e = 2017-12-19
  (s..e).to_a = [#<Date: 2017-12-13...>,.., #<Date: 2017-12-19..>]
a = ["2017-12-23", "2018-01-02"]
  s = 2017-12-23, e = 2018-01-02
  (s..e).to_a = [#<Date: 2017-12-23...>,..., #<Date: 2018-01-02...>]
a = ["2017-12-29", "2018-01-08"]
  s = 2017-12-29, e = 2018-01-08
  (s..e).to_a = [#<Date: 2017-12-29...>,..., #<Date: 2018-01-08...>]
after reduce(:|) = [#<Date: 2017-12-13...>,..., #<Date: 2017-12-19...>,
  #<Date: 2017-12-23...>,..., #<Date: 2018-01-08...>]

1 My solution would be the same if it were guaranteed that vacation ranges do not overlap.

Cary Swoveland
  • 106,649
  • 6
  • 63
  • 100