diff --git a/cal.go b/cal.go index d6bd2f3..574e716 100644 --- a/cal.go +++ b/cal.go @@ -7,6 +7,11 @@ import ( "time" ) +const ( + dayStart = 9 + dayEnd = 17 +) + // IsWeekend reports whether the given date falls on a weekend. func IsWeekend(date time.Time) bool { day := date.Weekday() @@ -100,6 +105,8 @@ type WorkdayFn func(date time.Time) bool type Calendar struct { holidays [13][]Holiday // 0 for offset based holidays, 1-12 for month based workday [7]bool // flags to indicate a day of the week is a workday + dayStart time.Duration // the time offset at which the workdays starts + dayEnd time.Duration // the time offset at which workdays end WorkdayFunc WorkdayFn // optional function to override workday flags Observed ObservedRule } @@ -116,6 +123,8 @@ func NewCalendar() *Calendar { c.workday[time.Wednesday] = true c.workday[time.Thursday] = true c.workday[time.Friday] = true + c.dayStart = time.Duration(dayStart * time.Hour) + c.dayEnd = time.Duration(dayEnd * time.Hour) return c } @@ -317,6 +326,105 @@ func (c *Calendar) CountWorkdays(start, end time.Time) int64 { return int64(factor * result) } +func maxTime(ts ...time.Time) time.Time { + r := time.Time{} + for _, t := range ts { + if t.After(r) { + r = t + } + } + return r +} + +func minTime(ts ...time.Time) time.Time { + if len(ts) == 0 { + return time.Time{} + } + r := ts[0] + for _, t := range ts { + if t.Before(r) { + r = t + } + } + return r +} + +// DailyWorkedTime returns the total time worked per day +// it makes it easy to compute working times per day +// allowing calls like c.AddWorkHours(time.Now(), 8 * c.DailyWorkedTime()) +func (c *Calendar) DailyWorkedTime() time.Duration { + return c.dayEnd - c.dayStart +} + +// StartWorkTime returns the time at which work starts in the current day +func (c *Calendar) StartWorkTime(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, + 0, t.Location()).Add(c.dayStart) + +} + +// EndWorkTime returns the time at which work ends in the current day +func (c *Calendar) EndWorkTime(t time.Time) time.Time { + return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, + 0, t.Location()).Add(c.dayEnd) + +} + +// NextWorkStart determines what will be the next future time work will start +func (c *Calendar) NextWorkStart(t time.Time) time.Time { + start := c.StartWorkTime(t) + for !c.IsWorkday(start) || t.After(start) { + start = start.Add(24 * time.Hour) + } + return start +} + +// CountWorkHours counts the actual number of worked hours between 2 different times +func (c *Calendar) CountWorkHours(start, end time.Time) time.Duration { + r := time.Duration(0) + if end.Before(start) { + start, end = end, start + } + current := maxTime(start, c.StartWorkTime(start)) + if current.After(c.EndWorkTime(start)) { + current = c.NextWorkStart(start) + } + for current.Before(end) { + lastTimeInDay := minTime(c.EndWorkTime(current), end) + r += lastTimeInDay.Sub(current) + current = c.NextWorkStart(lastTimeInDay) + } + return r +} + +// AddWorkHours determines the time in the future where the worked hours will be completed +func (c *Calendar) AddWorkHours(t time.Time, worked time.Duration) time.Time { + wStart := maxTime(t, c.StartWorkTime(t)) + for !c.IsWorkday(wStart) { + wStart = c.NextWorkStart(wStart) + } + for worked > 0 { + + t = minTime(wStart.Add(worked), c.EndWorkTime(wStart)) + worked -= c.CountWorkHours(wStart, t) + + wStart = c.NextWorkStart(t) + } + return t +} + +// SetWorkingHours configures the calendar to override the default 9-17 working hours +func (c *Calendar) SetWorkingHours(start time.Duration, end time.Duration) { + if start > end { + // This should not really happen, but SetWorkingHours(18*time.Hour, 9*time.Hour) should also mean a 9-18 time range + c.dayStart = end + c.dayEnd = start + } else { + c.dayStart = start + c.dayEnd = end + } +} + // AddSkipNonWorkdays returns start time plus d working duration func (c *Calendar) AddSkipNonWorkdays(start time.Time, d time.Duration) time.Time { const day = 24 * time.Hour diff --git a/cal_test.go b/cal_test.go index 1497f0d..f8d9aa5 100644 --- a/cal_test.go +++ b/cal_test.go @@ -797,3 +797,115 @@ func TestCalendar_WorkdaysNrInRangeAustralia(t *testing.T) { }) } } + +func TestStartWorkTime(t *testing.T) { + c := NewCalendar() + got := c.StartWorkTime(time.Date(2020, 04, 15, 01, 20, 0, 0, time.UTC)) + expected := time.Date(2020, 04, 15, dayStart, 0, 0, 0, time.UTC) + if got != expected { + t.Errorf("Calendar.StartWorkTime() = %v, want %v", got, expected) + } +} + +func TestEndWorkTime(t *testing.T) { + c := NewCalendar() + got := c.EndWorkTime(time.Date(2020, 04, 15, 01, 20, 0, 0, time.UTC)) + expected := time.Date(2020, 04, 15, dayEnd, 0, 0, 0, time.UTC) + if got != expected { + t.Errorf("Calendar.EndWorkTime() = %v, want %v", got, expected) + } +} + +func TestNextWorkStart(t *testing.T) { + c := NewCalendar() + got := c.NextWorkStart(time.Date(2020, 04, 15, 01, 20, 0, 0, time.UTC)) + expected := time.Date(2020, 04, 15, dayStart, 0, 0, 0, time.UTC) + if got != expected { + t.Errorf("Calendar.NextWorkStart() = %v, want %v", got, expected) + } + got = c.NextWorkStart(time.Date(2020, 04, 15, 10, 20, 0, 0, time.UTC)) + expected = time.Date(2020, 04, 16, dayStart, 0, 0, 0, time.UTC) + if got != expected { + t.Errorf("Calendar.NextWorkStart() = %v, want %v", got, expected) + } + got = c.NextWorkStart(time.Date(2020, 04, 15, 21, 20, 0, 0, time.UTC)) + expected = time.Date(2020, 04, 16, dayStart, 0, 0, 0, time.UTC) + if got != expected { + t.Errorf("Calendar.NextWorkStart() = %v, want %v", got, expected) + } + got = c.NextWorkStart(time.Date(2020, 04, 18, 21, 20, 0, 0, time.UTC)) + expected = time.Date(2020, 04, 20, dayStart, 0, 0, 0, time.UTC) + if got != expected { + t.Errorf("Calendar.NextWorkStart() = %v, want %v", got, expected) + } +} + +func TestWorkedHours(t *testing.T) { + c := NewCalendar() + + got := c.CountWorkHours(time.Now(), time.Now().Add(7*24*time.Hour)) + expected := time.Duration(5 * c.DailyWorkedTime()) + if got != expected { + t.Errorf("Calendar.CountWorkHours() = %v, want %v", got, expected) + } + + got = c.CountWorkHours(time.Date(2020, 01, 01, 0, 0, 0, 0, time.UTC), time.Date(2019, 01, 01, 0, 0, 0, 0, time.UTC)) + expected = time.Duration(261 * c.DailyWorkedTime()) + if got != expected { + t.Errorf("Calendar.CountWorkHours() = %v, want %v", got, expected) + } + + loc, err := time.LoadLocation("Europe/Madrid") + if err != nil { + t.Errorf("failed to load GMT+1 location: %v", err) + } + + got = c.CountWorkHours(time.Date(2020, 4, 15, dayStart+2, 0, 0, 0, time.UTC), time.Date(2020, 4, 15, dayStart+2, 0, 0, 0, loc)) + // In april in Spain, there are 2 hours difference with Coordinated Universal Time + expected = time.Duration(2 * time.Hour) + if got != expected { + t.Errorf("Calendar.CountWorkHours() = %v, want %v", got, expected) + } + + c.SetWorkingHours(2*time.Hour, 4*time.Hour) // night shift + got = c.CountWorkHours(time.Date(2020, 03, 29, 2, 0, 0, 0, loc), time.Date(2020, 03, 29, 4, 0, 0, 0, loc)) + // 2020/03/29 is the daylight saving date in 2020 for Spain + expected = time.Duration(1 * time.Hour) + if got != expected { + t.Errorf("Calendar.CountWorkHours() = %v, want %v", got, expected) + } + +} + +func TestAddWorkedHours(t *testing.T) { + c := NewCalendar() + + got := c.AddWorkHours(time.Date(2020, 04, 15, 0, 0, 0, 0, time.UTC), 5*c.DailyWorkedTime()) + expected := time.Date(2020, 04, 21, dayEnd, 0, 0, 0, time.UTC) + if got != expected { + t.Errorf("Calendar.AddWorkHours() = %v, want %v", got, expected) + } + + got = c.AddWorkHours(time.Date(2020, 04, dayEnd+1, 3, 0, 0, 0, time.UTC), c.DailyWorkedTime()) + expected = time.Date(2020, 04, 20, dayEnd, 0, 0, 0, time.UTC) + if got != expected { + t.Errorf("Calendar.AddWorkHours() = %v, want %v", got, expected) + } + + c.SetWorkingHours(9*time.Hour, 18*time.Hour) + + got = c.AddWorkHours(time.Date(2020, 04, 15, 3, 0, 0, 0, time.UTC), 10*time.Hour) + expected = time.Date(2020, 04, 16, 10, 0, 0, 0, time.UTC) + if got != expected { + t.Errorf("Calendar.AddWorkHours() = %v, want %v", got, expected) + } +} + +func TestDailyWorkedTime(t *testing.T) { + got := NewCalendar().DailyWorkedTime() + expected := 8 * time.Hour + if got != expected { + t.Errorf("Calendar.DailyWorkedTime() = %v, want %v", got, expected) + } + +} diff --git a/holiday_defs_sk_test.go b/holiday_defs_sk_test.go index 75675dc..95882dc 100644 --- a/holiday_defs_sk_test.go +++ b/holiday_defs_sk_test.go @@ -5,7 +5,7 @@ import ( "time" ) -func TestSlovakiaHolidays(t *testing.T) { +func TestSlovakHolidays(t *testing.T) { c := NewCalendar() c.Observed = ObservedExact AddSlovakHolidays(c)