From 44a638bb226a2c3bd76fddbe9da7ec0dc0a9c2d6 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 22 Nov 2021 18:03:55 +0100 Subject: [PATCH] Localize time zones when calculating overtime The times from Odoo are returned in UTC. However, there are cases where some overtime hours are moved to the next date in cases of midnight. --- pkg/odoo/contract.go | 9 +++- pkg/timesheet/dailysummary.go | 2 + pkg/timesheet/dailysummary_test.go | 30 ++++++++++++ pkg/timesheet/report.go | 47 ++++++++++++------ pkg/timesheet/report_test.go | 76 ++++++++++++++++++++++++++++-- pkg/web/report_handler.go | 4 +- 6 files changed, 145 insertions(+), 23 deletions(-) diff --git a/pkg/odoo/contract.go b/pkg/odoo/contract.go index 26f19a3..c9bf8d6 100644 --- a/pkg/odoo/contract.go +++ b/pkg/odoo/contract.go @@ -8,12 +8,17 @@ import ( type ContractList []Contract type Contract struct { - ID float64 `json:"id"` - Start *Date `json:"date_start"` + ID float64 `json:"id"` + // Start is the first day of the contract in UTC. + Start *Date `json:"date_start"` + // Start is the last day of the contract in UTC. + // It is nil or Zero if the contract hasn't ended yet. End *Date `json:"date_end"` WorkingSchedule *WorkingSchedule `json:"working_hours"` } +// GetFTERatioForDay returns the workload ratio that is active for the given day. +// All involved dates are expected to be in UTC. func (l ContractList) GetFTERatioForDay(day Date) (float64, error) { date := day.ToTime() for _, contract := range l { diff --git a/pkg/timesheet/dailysummary.go b/pkg/timesheet/dailysummary.go index 40bf27b..825a459 100644 --- a/pkg/timesheet/dailysummary.go +++ b/pkg/timesheet/dailysummary.go @@ -7,6 +7,7 @@ import ( ) type DailySummary struct { + // Date is the localized date of the summary. Date time.Time Blocks []AttendanceBlock Absences []AbsenceBlock @@ -15,6 +16,7 @@ type DailySummary struct { // NewDailySummary creates a new instance. // The fteRatio is the percentage (input a value between 0..1) of the employee and is used to calculate the daily maximum hours an employee should work. +// Date is expected to be in a localized timezone. func NewDailySummary(fteRatio float64, date time.Time) *DailySummary { return &DailySummary{ FTERatio: fteRatio, diff --git a/pkg/timesheet/dailysummary_test.go b/pkg/timesheet/dailysummary_test.go index 429bd73..db1e682 100644 --- a/pkg/timesheet/dailysummary_test.go +++ b/pkg/timesheet/dailysummary_test.go @@ -173,3 +173,33 @@ func TestDailySummary_CalculateDailyMaxHours(t *testing.T) { }) } } + +func Test_findDailySummaryByDate(t *testing.T) { + tests := map[string]struct { + givenDailies []*DailySummary + givenDate time.Time + expectedSummary *DailySummary + }{ + "GivenDailies_WhenDateMatches_ThenReturnDaily": { + givenDailies: []*DailySummary{ + NewDailySummary(1, *date(t, "2021-02-03")), + }, + givenDate: *date(t, "2021-02-03"), + expectedSummary: NewDailySummary(1, *date(t, "2021-02-03")), + }, + "GivenDailies_WhenDateMatchesInUTC_ThenReturnDaily": { + givenDailies: []*DailySummary{ + NewDailySummary(1, date(t, "2021-02-04").UTC()), + NewDailySummary(1, date(t, "2021-02-03").UTC()), + }, + givenDate: newDateTime(t, "2021-02-03 23:30").ToTime(), + expectedSummary: NewDailySummary(1, date(t, "2021-02-03").UTC()), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + result, _ := findDailySummaryByDate(tt.givenDailies, tt.givenDate) + assert.Equal(t, tt.expectedSummary, result) + }) + } +} diff --git a/pkg/timesheet/report.go b/pkg/timesheet/report.go index 662ca47..aa83383 100644 --- a/pkg/timesheet/report.go +++ b/pkg/timesheet/report.go @@ -28,8 +28,15 @@ const ( // for testing purposes var now = time.Now +const ( + ActionSignIn = "sign_in" + ActionSignOut = "sign_out" +) + type AttendanceBlock struct { - Start time.Time + // Start is the localized beginning time of the attendance + Start time.Time + // End is the localized finish time of the attendance End time.Time Reason string } @@ -57,6 +64,7 @@ type Reporter struct { year int month int contracts odoo.ContractList + timezone *time.Location } func NewReporter(attendances []odoo.Attendance, leaves []odoo.Leave, employee *odoo.Employee, contracts []odoo.Contract) *Reporter { @@ -67,6 +75,7 @@ func NewReporter(attendances []odoo.Attendance, leaves []odoo.Leave, employee *o year: now().UTC().Year(), month: int(now().UTC().Month()), contracts: contracts, + timezone: time.Local, } } @@ -76,11 +85,19 @@ func (r *Reporter) SetMonth(year, month int) *Reporter { return r } +func (r *Reporter) SetTimeZone(zone string) *Reporter { + loc, err := time.LoadLocation(zone) + if err == nil { + r.timezone = loc + } + return r +} + func (r *Reporter) CalculateReport() Report { filteredAttendances := r.filterAttendancesInMonth() - blocks := reduceAttendancesToBlocks(filteredAttendances) + blocks := r.reduceAttendancesToBlocks(filteredAttendances) filteredLeaves := r.filterLeavesInMonth() - absences := reduceLeavesToBlocks(filteredLeaves) + absences := r.reduceLeavesToBlocks(filteredLeaves) dailySummaries := r.prepareDays() r.addAttendanceBlocksToDailies(blocks, dailySummaries) @@ -98,33 +115,33 @@ func (r *Reporter) CalculateReport() Report { } } -func reduceAttendancesToBlocks(attendances []odoo.Attendance) []AttendanceBlock { +func (r *Reporter) reduceAttendancesToBlocks(attendances []odoo.Attendance) []AttendanceBlock { sortAttendances(attendances) blocks := make([]AttendanceBlock, 0) var tmpBlock AttendanceBlock for _, attendance := range attendances { - if attendance.Action == "sign_in" { + if attendance.Action == ActionSignIn { tmpBlock = AttendanceBlock{ - Start: attendance.DateTime.ToTime(), + Start: attendance.DateTime.ToTime().In(r.timezone), Reason: attendance.Reason.String(), } } - if attendance.Action == "sign_out" { - tmpBlock.End = attendance.DateTime.ToTime() + if attendance.Action == ActionSignOut { + tmpBlock.End = attendance.DateTime.ToTime().In(r.timezone) blocks = append(blocks, tmpBlock) } } return blocks } -func reduceLeavesToBlocks(leaves []odoo.Leave) []AbsenceBlock { +func (r *Reporter) reduceLeavesToBlocks(leaves []odoo.Leave) []AbsenceBlock { blocks := make([]AbsenceBlock, 0) for _, leave := range leaves { // Only consider approved leaves if leave.State == StateApproved { blocks = append(blocks, AbsenceBlock{ Reason: leave.Type.String(), - Date: leave.DateFrom.ToTime().UTC().Truncate(24 * time.Hour), + Date: leave.DateFrom.ToTime().In(r.timezone).Truncate(24 * time.Hour), }) } } @@ -137,8 +154,8 @@ func (r *Reporter) prepareDays() []*DailySummary { firstDay := time.Date(r.year, time.Month(r.month), 1, 0, 0, 0, 0, time.UTC) lastDay := firstDay.AddDate(0, 1, 0) - if lastDay.After(now().UTC()) { - lastDay = getDateTomorrow() + if lastDay.After(now().In(r.timezone)) { + lastDay = r.getDateTomorrow() } for currentDay := firstDay; currentDay.Before(lastDay); currentDay = currentDay.AddDate(0, 0, 1) { @@ -147,14 +164,14 @@ func (r *Reporter) prepareDays() []*DailySummary { fmt.Println(err) currentRatio = 0 } - days = append(days, NewDailySummary(currentRatio, currentDay)) + days = append(days, NewDailySummary(currentRatio, currentDay.In(r.timezone))) } return days } -func getDateTomorrow() time.Time { - return now().UTC().Truncate(24*time.Hour).AddDate(0, 0, 1) +func (r *Reporter) getDateTomorrow() time.Time { + return now().In(r.timezone).Truncate(24*time.Hour).AddDate(0, 0, 1) } func (r *Reporter) addAttendanceBlocksToDailies(blocks []AttendanceBlock, dailySums []*DailySummary) { diff --git a/pkg/timesheet/report_test.go b/pkg/timesheet/report_test.go index 0976740..5d0e1f7 100644 --- a/pkg/timesheet/report_test.go +++ b/pkg/timesheet/report_test.go @@ -29,12 +29,28 @@ func parse(t *testing.T, pattern string) time.Time { } func date(t *testing.T, date string) *time.Time { + zone, err := time.LoadLocation("Europe/Zurich") + require.NoError(t, err) tm, err := time.Parse(odoo.DateFormat, date) require.NoError(t, err) - return &tm + tmzone := tm.In(zone) + return &tmzone +} + +func newDateTime(t *testing.T, value string) *odoo.Date { + tm, err := time.Parse(odoo.DateTimeFormat, fmt.Sprintf("%s:00", value)) + require.NoError(t, err) + ptr := odoo.Date(tm) + return &ptr } -func TestReduceAttendanceBlocks(t *testing.T) { +func localzone(t *testing.T) *time.Location { + zone, err := time.LoadLocation("Europe/Zurich") + require.NoError(t, err) + return zone +} + +func TestReporter_AddAttendanceBlocksToDailies(t *testing.T) { tests := map[string]struct { givenDailySummaries []*DailySummary givenBlocks []AttendanceBlock @@ -80,6 +96,55 @@ func TestReduceAttendanceBlocks(t *testing.T) { } } +func TestReporter_ReduceAttendancesToBlocks(t *testing.T) { + tests := map[string]struct { + givenAttendances []odoo.Attendance + expectedBlocks []AttendanceBlock + }{ + "GivenAttendancesInUTC_WhenReducing_ThenApplyLocalZone": { + givenAttendances: []odoo.Attendance{ + {DateTime: newDateTime(t, "2021-02-03 19:00"), Action: ActionSignIn}, // these times are UTC + {DateTime: newDateTime(t, "2021-02-03 22:59"), Action: ActionSignOut}, + }, + expectedBlocks: []AttendanceBlock{ + {Start: newDateTime(t, "2021-02-03 19:00").ToTime().In(localzone(t)), + End: newDateTime(t, "2021-02-03 22:59").ToTime().In(localzone(t)), + }, + }, + }, + "GivenAttendancesInUTC_WhenSplitOverMidnight_ThenSplitInTwoDays": { + givenAttendances: []odoo.Attendance{ + {DateTime: newDateTime(t, "2021-02-03 19:00"), Action: ActionSignIn}, // these times are UTC + {DateTime: newDateTime(t, "2021-02-03 22:59"), Action: ActionSignOut}, + {DateTime: newDateTime(t, "2021-02-03 23:00"), Action: ActionSignIn}, + {DateTime: newDateTime(t, "2021-02-04 00:00"), Action: ActionSignOut}, + }, + expectedBlocks: []AttendanceBlock{ + { + Start: newDateTime(t, "2021-02-03 19:00").ToTime().In(localzone(t)), + End: newDateTime(t, "2021-02-03 22:59").ToTime().In(localzone(t)), + }, + { + Start: newDateTime(t, "2021-02-03 23:00").ToTime().In(localzone(t)), + End: newDateTime(t, "2021-02-04 00:00").ToTime().In(localzone(t)), + }, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + r := Reporter{ + year: 2021, + month: 2, + timezone: localzone(t), + } + result := r.reduceAttendancesToBlocks(tt.givenAttendances) + + assert.Equal(t, tt.expectedBlocks, result) + }) + } +} + func TestReporter_prepareWorkDays(t *testing.T) { tests := map[string]struct { givenYear int @@ -125,7 +190,7 @@ func TestReporter_prepareWorkDays(t *testing.T) { }, }, "GivenCurrentMonth_ThenReturnNoMoreThanToday": { - givenYear: time.Now().Year(), + givenYear: 2021, givenMonth: 3, expectedDays: []*DailySummary{ {Date: *date(t, "2021-03-01")}, @@ -149,8 +214,9 @@ func TestReporter_prepareWorkDays(t *testing.T) { } r := &Reporter{ - year: tt.givenYear, - month: tt.givenMonth, + year: tt.givenYear, + month: tt.givenMonth, + timezone: localzone(t), } result := r.prepareDays() require.Len(t, result, len(tt.expectedDays)) diff --git a/pkg/web/report_handler.go b/pkg/web/report_handler.go index 03840ab..15ec4d9 100644 --- a/pkg/web/report_handler.go +++ b/pkg/web/report_handler.go @@ -68,7 +68,9 @@ func (s Server) OvertimeReport() http.Handler { return } - reporter := timesheet.NewReporter(attendances, leaves, employee, contracts).SetMonth(input.Year, input.Month) + reporter := timesheet.NewReporter(attendances, leaves, employee, contracts). + SetMonth(input.Year, input.Month). + SetTimeZone("Europe/Zurich") // hardcoded for now report := reporter.CalculateReport() view.ShowAttendanceReport(w, report) })