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) })