From 628a58111589019d3a905853ae37a99738de1a70 Mon Sep 17 00:00:00 2001 From: Tamara deMent Date: Wed, 2 Sep 2026 16:36:57 -0400 Subject: [PATCH 1/6] Add handleAllDay and handleHalfDay utilities --- core/calendar.go | 62 ++++++++++++++++++++----------- core/calendar_utils.go | 72 ++++++++++++++++++++++++++++++++++++ core/clockify.go | 29 +++++++++++---- core/findClockifyCalEvent.go | 2 - 4 files changed, 134 insertions(+), 31 deletions(-) create mode 100644 core/calendar_utils.go diff --git a/core/calendar.go b/core/calendar.go index d0ac454..b2ff346 100644 --- a/core/calendar.go +++ b/core/calendar.go @@ -39,22 +39,43 @@ func InsertOOOEvents(ctx context.Context, jwtCfg jwt.Config, r ClockifyRequest, return nil, fmt.Errorf("req=%s user=%s: bad period.end: %w", r.ID, r.UserEmail, err) } - // Normalize to local dates - startLocal := startUTC.In(loc) - endLocal := endUTC.In(loc) + var eventStart, eventEnd time.Time - y1, m1, d1 := startLocal.Date() - y2, m2, d2 := endLocal.Date() + if r.TimeOffPeriod.HalfDay { + eventStart, eventEnd, err = handleHalfDay( + r.TimeOffPeriod.HalfDayHours, + loc, + ) + if err != nil { + return nil, fmt.Errorf( + "req=%s user=%s: %w", + r.ID, + r.UserEmail, + err, + ) + } + } else { + eventStart, eventEnd = handleAllDay(startUTC, endUTC, loc) + } - // All-day local time window used for Events.List (TimeMin / TimeMax). - allDayStart := time.Date(y1, m1, d1, 0, 0, 0, 0, loc) - // Clockify is inclusive; GCal all-day is [start, end) exclusive. - // So cover the last OOO day by adding +1 local day to the end date. - allDayEndExclusive := time.Date(y2, m2, d2, 0, 0, 0, 0, loc).AddDate(0, 0, 1) + var calendarStart, calendarEnd *calendar.EventDateTime - // YYYY-MM-DD string format is used for the Insert event payload. - startDate := allDayStart.Format("2006-01-02") - endDate := allDayEndExclusive.Format("2006-01-02") + if r.TimeOffPeriod.HalfDay { + calendarStart = &calendar.EventDateTime{ + DateTime: eventStart.Format(time.RFC3339), + } + calendarEnd = &calendar.EventDateTime{ + DateTime: eventEnd.Format(time.RFC3339), + } + } else { + // YYYY-MM-DD string format is used for the Insert event payload. + calendarStart = &calendar.EventDateTime{ + Date: eventStart.Format("2006-01-02"), + } + calendarEnd = &calendar.EventDateTime{ + Date: eventEnd.Format("2006-01-02"), + } + } cfg := jwtCfg cfg.Subject = r.UserEmail @@ -73,10 +94,9 @@ func InsertOOOEvents(ctx context.Context, jwtCfg jwt.Config, r ClockifyRequest, ev := &calendar.Event{ Summary: summary, Description: fmt.Sprintf("Clockify request: %s\nCreatedAt: %s", r.ID, r.CreatedAt), - Start: &calendar.EventDateTime{Date: startDate}, - End: &calendar.EventDateTime{Date: endDate}, // exclusive + Start: calendarStart, + End: calendarEnd, // Attaching the Clockify request ID as a private extended property. - // TODO: Before inserting, check for an existing event with this key and insert event/skip: ExtendedProperties: &calendar.EventExtendedProperties{ Private: map[string]string{ "clockifyRequestId": r.ID, @@ -87,8 +107,8 @@ func InsertOOOEvents(ctx context.Context, jwtCfg jwt.Config, r ClockifyRequest, // Insert into calendars for _, calID := range calendarIDs { existing, err := findClockifyEvents( - ctx, srv, calID, r.ID, - allDayStart, allDayEndExclusive, + srv, calID, r.ID, + eventStart, eventEnd, ) if err != nil { log.Printf("lookup %s (user=%s cal=%s) failed: %v", @@ -110,8 +130,8 @@ func InsertOOOEvents(ctx context.Context, jwtCfg jwt.Config, r ClockifyRequest, r.UserEmail, calID, e.Id, - e.Start.Date, - e.End.Date, + calendarEventTimeValue(e.Start), + calendarEventTimeValue(e.End), ) } @@ -134,7 +154,7 @@ func InsertOOOEvents(ctx context.Context, jwtCfg jwt.Config, r ClockifyRequest, log.Printf( "Inserted OOO for req=%s user=%s cal=%s (%s → %s)\n", - r.ID, r.UserEmail, calID, startDate, endDate, + r.ID, r.UserEmail, calID, calendarEventTimeValue(calendarStart), calendarEventTimeValue(calendarEnd), ) } diff --git a/core/calendar_utils.go b/core/calendar_utils.go new file mode 100644 index 0000000..e65c4fe --- /dev/null +++ b/core/calendar_utils.go @@ -0,0 +1,72 @@ +package core + +import ( + "fmt" + "time" + + "google.golang.org/api/calendar/v3" +) + +func handleAllDay(startUTC time.Time, + endUTC time.Time, + loc *time.Location) (time.Time, time.Time) { + + // Normalize to local dates + startLocal := startUTC.In(loc) + endLocal := endUTC.In(loc) + + y1, m1, d1 := startLocal.Date() + y2, m2, d2 := endLocal.Date() + + // All-day local time window used for Events.List (TimeMin / TimeMax). + allDayStart := time.Date(y1, m1, d1, 0, 0, 0, 0, loc) + // Clockify is inclusive; GCal all-day is [start, end) exclusive. + // So cover the last OOO day by adding +1 local day to the end date. + allDayEndExclusive := time.Date(y2, m2, d2, 0, 0, 0, 0, loc).AddDate(0, 0, 1) + + return allDayStart, allDayEndExclusive +} + +func handleHalfDay( + halfDayHours *ClockifyTimePeriod, + loc *time.Location, +) (time.Time, time.Time, error) { + if halfDayHours == nil { + return time.Time{}, time.Time{}, + fmt.Errorf("half-day request is missing halfDayHours") + } + + startUTC, err := ParseTimeAny(halfDayHours.Start) + if err != nil { + return time.Time{}, time.Time{}, + fmt.Errorf("bad halfDayHours.start: %w", err) + } + + endUTC, err := ParseTimeAny(halfDayHours.End) + if err != nil { + return time.Time{}, time.Time{}, + fmt.Errorf("bad halfDayHours.end: %w", err) + } + + startLocal := startUTC.In(loc) + endLocal := endUTC.In(loc) + + if !endLocal.After(startLocal) { + return time.Time{}, time.Time{}, + fmt.Errorf("half-day end must be after start") + } + + return startLocal, endLocal, nil +} + +func calendarEventTimeValue(eventTime *calendar.EventDateTime) string { + if eventTime == nil { + return "" + } + + if eventTime.DateTime != "" { + return eventTime.DateTime + } + + return eventTime.Date +} diff --git a/core/clockify.go b/core/clockify.go index 03e06bd..8bfc32c 100644 --- a/core/clockify.go +++ b/core/clockify.go @@ -10,6 +10,19 @@ import ( "time" ) +type ClockifyHalfDayPeriod string + +const ( + HalfDayPeriodNotDefined ClockifyHalfDayPeriod = "NOT_DEFINED" + HalfDayPeriodFirstHalf ClockifyHalfDayPeriod = "FIRST_HALF" + HalfDayPeriodSecondHalf ClockifyHalfDayPeriod = "SECOND_HALF" +) + +const ( + ClockifyStatusApproved = "APPROVED" + ClockifyStatusRejected = "REJECTED" +) + type ClockifyEnvelope struct { Requests []ClockifyRequest `json:"requests"` } @@ -23,10 +36,10 @@ type ClockifyRequest struct { UserTimeZone string `json:"userTimeZone"` TimeOffPeriod struct { - Period struct { - Start string `json:"start"` - End string `json:"end"` - } `json:"period"` + HalfDay bool `json:"halfDay"` + HalfDayHours *ClockifyTimePeriod `json:"halfDayHours"` + HalfDayPeriod string `json:"halfDayPeriod"` + Period ClockifyTimePeriod `json:"period"` } `json:"timeOffPeriod"` Status struct { @@ -35,10 +48,10 @@ type ClockifyRequest struct { } `json:"status"` } -const ( - ClockifyStatusApproved = "APPROVED" - ClockifyStatusRejected = "REJECTED" -) +type ClockifyTimePeriod struct { + Start string `json:"start"` + End string `json:"end"` +} type ClockifyRequestPayload struct { Start *string `json:"start,omitempty"` diff --git a/core/findClockifyCalEvent.go b/core/findClockifyCalEvent.go index 5f667b6..43cf9ac 100644 --- a/core/findClockifyCalEvent.go +++ b/core/findClockifyCalEvent.go @@ -1,7 +1,6 @@ package core import ( - "context" "time" "google.golang.org/api/calendar/v3" @@ -11,7 +10,6 @@ import ( // property "clockifyRequestId" == clockifyID, scoped to the given time range. // It returns an empty slice if no such events exist. func findClockifyEvents( - ctx context.Context, srv *calendar.Service, calID string, clockifyID string, From 6d2a286862a9fa9a824468dc97ca55745be49975 Mon Sep 17 00:00:00 2001 From: Tamara deMent Date: Wed, 2 Sep 2026 16:57:46 -0400 Subject: [PATCH 2/6] Add tests for new calendar utils --- core/calendar_utils_test.go | 241 ++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 core/calendar_utils_test.go diff --git a/core/calendar_utils_test.go b/core/calendar_utils_test.go new file mode 100644 index 0000000..fb8f24b --- /dev/null +++ b/core/calendar_utils_test.go @@ -0,0 +1,241 @@ +package core + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/calendar/v3" +) + +func TestHandleAllDay(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + require.NoError(t, err) + + tests := []struct { + name string + startUTC time.Time + endUTC time.Time + wantStart time.Time + wantEnd time.Time + }{ + { + name: "single-day request", + startUTC: time.Date( + 2026, time.September, 3, + 4, 0, 0, 0, + time.UTC, + ), + endUTC: time.Date( + 2026, time.September, 4, + 3, 59, 59, 999000000, + time.UTC, + ), + wantStart: time.Date( + 2026, time.September, 3, + 0, 0, 0, 0, + loc, + ), + wantEnd: time.Date( + 2026, time.September, 4, + 0, 0, 0, 0, + loc, + ), + }, + { + name: "multi-day request", + startUTC: time.Date( + 2026, time.September, 3, + 4, 0, 0, 0, + time.UTC, + ), + endUTC: time.Date( + 2026, time.September, 7, + 3, 59, 59, 999000000, + time.UTC, + ), + wantStart: time.Date( + 2026, time.September, 3, + 0, 0, 0, 0, + loc, + ), + wantEnd: time.Date( + 2026, time.September, 7, + 0, 0, 0, 0, + loc, + ), + }, + { + name: "request spanning daylight-saving transition", + startUTC: time.Date( + 2026, time.March, 8, + 5, 0, 0, 0, + time.UTC, + ), + endUTC: time.Date( + 2026, time.March, 9, + 3, 59, 59, 999000000, + time.UTC, + ), + wantStart: time.Date( + 2026, time.March, 8, + 0, 0, 0, 0, + loc, + ), + wantEnd: time.Date( + 2026, time.March, 9, + 0, 0, 0, 0, + loc, + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotStart, gotEnd := handleAllDay( + tt.startUTC, + tt.endUTC, + loc, + ) + + assert.Equal(t, tt.wantStart, gotStart) + assert.Equal(t, tt.wantEnd, gotEnd) + }) + } +} + +func TestHandleHalfDay(t *testing.T) { + loc, err := time.LoadLocation("America/New_York") + require.NoError(t, err) + + tests := []struct { + name string + hours *ClockifyTimePeriod + wantStart time.Time + wantEnd time.Time + wantError string + }{ + { + name: "converts half-day hours to local time", + hours: &ClockifyTimePeriod{ + Start: "2026-09-03T13:00:00Z", + End: "2026-09-03T16:30:00Z", + }, + wantStart: time.Date( + 2026, time.September, 3, + 9, 0, 0, 0, + loc, + ), + wantEnd: time.Date( + 2026, time.September, 3, + 12, 30, 0, 0, + loc, + ), + }, + { + name: "returns error when half-day hours are missing", + hours: nil, + wantError: "half-day request is missing halfDayHours", + }, + { + name: "returns error when start is invalid", + hours: &ClockifyTimePeriod{ + Start: "not-a-time", + End: "2026-09-03T16:30:00Z", + }, + wantError: "bad halfDayHours.start", + }, + { + name: "returns error when end is invalid", + hours: &ClockifyTimePeriod{ + Start: "2026-09-03T13:00:00Z", + End: "not-a-time", + }, + wantError: "bad halfDayHours.end", + }, + { + name: "returns error when end equals start", + hours: &ClockifyTimePeriod{ + Start: "2026-09-03T13:00:00Z", + End: "2026-09-03T13:00:00Z", + }, + wantError: "half-day end must be after start", + }, + { + name: "returns error when end is before start", + hours: &ClockifyTimePeriod{ + Start: "2026-09-03T16:30:00Z", + End: "2026-09-03T13:00:00Z", + }, + wantError: "half-day end must be after start", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotStart, gotEnd, err := handleHalfDay(tt.hours, loc) + + if tt.wantError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantError) + assert.True(t, gotStart.IsZero()) + assert.True(t, gotEnd.IsZero()) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantStart, gotStart) + assert.Equal(t, tt.wantEnd, gotEnd) + }) + } +} + +func TestCalendarEventTimeValue(t *testing.T) { + tests := []struct { + name string + eventTime *calendar.EventDateTime + want string + }{ + { + name: "returns empty string for nil event time", + eventTime: nil, + want: "", + }, + { + name: "returns DateTime for timed event", + eventTime: &calendar.EventDateTime{ + DateTime: "2026-09-03T09:00:00-04:00", + }, + want: "2026-09-03T09:00:00-04:00", + }, + { + name: "returns Date for all-day event", + eventTime: &calendar.EventDateTime{ + Date: "2026-09-03", + }, + want: "2026-09-03", + }, + { + name: "returns empty string when both values are empty", + eventTime: &calendar.EventDateTime{}, + want: "", + }, + { + name: "prefers DateTime when both values are populated", + eventTime: &calendar.EventDateTime{ + Date: "2026-09-03", + DateTime: "2026-09-03T09:00:00-04:00", + }, + want: "2026-09-03T09:00:00-04:00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := calendarEventTimeValue(tt.eventTime) + + assert.Equal(t, tt.want, got) + }) + } +} From 7f0358cfd1e8104b33ce3c9f7fd631c37e920b5e Mon Sep 17 00:00:00 2001 From: Tamara deMent Date: Thu, 3 Sep 2026 14:57:36 -0400 Subject: [PATCH 3/6] Refactors a comment --- core/calendar.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/calendar.go b/core/calendar.go index b2ff346..eba67ed 100644 --- a/core/calendar.go +++ b/core/calendar.go @@ -60,6 +60,8 @@ func InsertOOOEvents(ctx context.Context, jwtCfg jwt.Config, r ClockifyRequest, var calendarStart, calendarEnd *calendar.EventDateTime + // Google Calendar uses RFC3339 DateTime values for timed events + // and YYYY-MM-DD Date values for all-day events. if r.TimeOffPeriod.HalfDay { calendarStart = &calendar.EventDateTime{ DateTime: eventStart.Format(time.RFC3339), @@ -68,7 +70,6 @@ func InsertOOOEvents(ctx context.Context, jwtCfg jwt.Config, r ClockifyRequest, DateTime: eventEnd.Format(time.RFC3339), } } else { - // YYYY-MM-DD string format is used for the Insert event payload. calendarStart = &calendar.EventDateTime{ Date: eventStart.Format("2006-01-02"), } From eacf7b88aba2d0ca0bf9bb77d0b01969f400a552 Mon Sep 17 00:00:00 2001 From: Tamara deMent Date: Mon, 21 Sep 2026 17:09:02 -0400 Subject: [PATCH 4/6] Valdiate loc in calendar utils --- core/calendar.go | 12 +++++++++++- core/calendar_utils.go | 13 +++++++++++-- core/calendar_utils_test.go | 27 ++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/core/calendar.go b/core/calendar.go index eba67ed..cf5e230 100644 --- a/core/calendar.go +++ b/core/calendar.go @@ -55,7 +55,17 @@ func InsertOOOEvents(ctx context.Context, jwtCfg jwt.Config, r ClockifyRequest, ) } } else { - eventStart, eventEnd = handleAllDay(startUTC, endUTC, loc) + eventStart, eventEnd, err = handleAllDay(startUTC, endUTC, loc) + + if err != nil { + return nil, fmt.Errorf( + "req=%s user=%s: %w", + r.ID, + r.UserEmail, + err, + ) + } + } var calendarStart, calendarEnd *calendar.EventDateTime diff --git a/core/calendar_utils.go b/core/calendar_utils.go index e65c4fe..5ac5edd 100644 --- a/core/calendar_utils.go +++ b/core/calendar_utils.go @@ -9,8 +9,12 @@ import ( func handleAllDay(startUTC time.Time, endUTC time.Time, - loc *time.Location) (time.Time, time.Time) { + loc *time.Location) (time.Time, time.Time, error) { + if loc == nil { + return time.Time{}, time.Time{}, + fmt.Errorf("location is required") + } // Normalize to local dates startLocal := startUTC.In(loc) endLocal := endUTC.In(loc) @@ -24,13 +28,18 @@ func handleAllDay(startUTC time.Time, // So cover the last OOO day by adding +1 local day to the end date. allDayEndExclusive := time.Date(y2, m2, d2, 0, 0, 0, 0, loc).AddDate(0, 0, 1) - return allDayStart, allDayEndExclusive + return allDayStart, allDayEndExclusive, nil } func handleHalfDay( halfDayHours *ClockifyTimePeriod, loc *time.Location, ) (time.Time, time.Time, error) { + if loc == nil { + return time.Time{}, time.Time{}, + fmt.Errorf("location is required") + } + if halfDayHours == nil { return time.Time{}, time.Time{}, fmt.Errorf("half-day request is missing halfDayHours") diff --git a/core/calendar_utils_test.go b/core/calendar_utils_test.go index fb8f24b..91494f1 100644 --- a/core/calendar_utils_test.go +++ b/core/calendar_utils_test.go @@ -93,16 +93,29 @@ func TestHandleAllDay(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotStart, gotEnd := handleAllDay( + gotStart, gotEnd, err := handleAllDay( tt.startUTC, tt.endUTC, loc, ) + require.NoError(t, err) assert.Equal(t, tt.wantStart, gotStart) assert.Equal(t, tt.wantEnd, gotEnd) }) } + + t.Run("returns error when location is nil", func(t *testing.T) { + gotStart, gotEnd, err := handleAllDay( + time.Time{}, + time.Time{}, + nil, + ) + + require.EqualError(t, err, "location is required") + assert.True(t, gotStart.IsZero()) + assert.True(t, gotEnd.IsZero()) + }) } func TestHandleHalfDay(t *testing.T) { @@ -189,6 +202,18 @@ func TestHandleHalfDay(t *testing.T) { assert.Equal(t, tt.wantEnd, gotEnd) }) } + t.Run("returns error when location is nil", func(t *testing.T) { + hours := &ClockifyTimePeriod{ + Start: "2026-09-03T13:00:00Z", + End: "2026-09-03T16:30:00Z", + } + + gotStart, gotEnd, err := handleHalfDay(hours, nil) + + require.EqualError(t, err, "location is required") + assert.True(t, gotStart.IsZero()) + assert.True(t, gotEnd.IsZero()) + }) } func TestCalendarEventTimeValue(t *testing.T) { From 48f24f30f1e17504ce3b14e738743e59251be99d Mon Sep 17 00:00:00 2001 From: Tamara deMent Date: Mon, 21 Sep 2026 17:45:04 -0400 Subject: [PATCH 5/6] Removing unused enum --- core/calendar.go | 1 - core/clockify.go | 6 ------ 2 files changed, 7 deletions(-) diff --git a/core/calendar.go b/core/calendar.go index cf5e230..a2caf08 100644 --- a/core/calendar.go +++ b/core/calendar.go @@ -56,7 +56,6 @@ func InsertOOOEvents(ctx context.Context, jwtCfg jwt.Config, r ClockifyRequest, } } else { eventStart, eventEnd, err = handleAllDay(startUTC, endUTC, loc) - if err != nil { return nil, fmt.Errorf( "req=%s user=%s: %w", diff --git a/core/clockify.go b/core/clockify.go index 8bfc32c..bf6f03a 100644 --- a/core/clockify.go +++ b/core/clockify.go @@ -12,12 +12,6 @@ import ( type ClockifyHalfDayPeriod string -const ( - HalfDayPeriodNotDefined ClockifyHalfDayPeriod = "NOT_DEFINED" - HalfDayPeriodFirstHalf ClockifyHalfDayPeriod = "FIRST_HALF" - HalfDayPeriodSecondHalf ClockifyHalfDayPeriod = "SECOND_HALF" -) - const ( ClockifyStatusApproved = "APPROVED" ClockifyStatusRejected = "REJECTED" From f1ceb0eb0a0de7840b5b1e73338ae892e6881e54 Mon Sep 17 00:00:00 2001 From: Tamara deMent Date: Tue, 22 Sep 2026 10:06:09 -0400 Subject: [PATCH 6/6] Document Clockify time-range behavior --- core/clockify.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/core/clockify.go b/core/clockify.go index bf6f03a..691fe7b 100644 --- a/core/clockify.go +++ b/core/clockify.go @@ -33,7 +33,10 @@ type ClockifyRequest struct { HalfDay bool `json:"halfDay"` HalfDayHours *ClockifyTimePeriod `json:"halfDayHours"` HalfDayPeriod string `json:"halfDayPeriod"` - Period ClockifyTimePeriod `json:"period"` + + // Clockify represents the final all-day date inclusively. + // Google Calendar uses an exclusive end date for all-day events. + Period ClockifyTimePeriod `json:"period"` } `json:"timeOffPeriod"` Status struct { @@ -42,12 +45,15 @@ type ClockifyRequest struct { } `json:"status"` } +// ClockifyTimePeriod contains the start and end timestamps for either +// the overall time-off period or its specific half-day hours. type ClockifyTimePeriod struct { Start string `json:"start"` End string `json:"end"` } type ClockifyRequestPayload struct { + // Start and End define the inclusive Clockify query window. Start *string `json:"start,omitempty"` End *string `json:"end,omitempty"` Page int `json:"page,omitempty"`