Skip to content

Commit

Permalink
Merge pull request #30 from vshn/timezone
Browse files Browse the repository at this point in the history
Localize time zones when calculating overtime
  • Loading branch information
ccremer authored Nov 22, 2021
2 parents a4e220a + 44a638b commit 43b0d84
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 23 deletions.
9 changes: 7 additions & 2 deletions pkg/odoo/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions pkg/timesheet/dailysummary.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
)

type DailySummary struct {
// Date is the localized date of the summary.
Date time.Time
Blocks []AttendanceBlock
Absences []AbsenceBlock
Expand All @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions pkg/timesheet/dailysummary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
47 changes: 32 additions & 15 deletions pkg/timesheet/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}

Expand All @@ -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)
Expand All @@ -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),
})
}
}
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
76 changes: 71 additions & 5 deletions pkg/timesheet/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")},
Expand All @@ -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))
Expand Down
4 changes: 3 additions & 1 deletion pkg/web/report_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down

0 comments on commit 43b0d84

Please sign in to comment.