diff --git a/core/calendar.go b/core/calendar.go index d0ac454..a2caf08 100644 --- a/core/calendar.go +++ b/core/calendar.go @@ -39,22 +39,53 @@ 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, err = handleAllDay(startUTC, endUTC, loc) + if err != nil { + return nil, fmt.Errorf( + "req=%s user=%s: %w", + r.ID, + r.UserEmail, + err, + ) + } + + } - // 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") + // 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), + } + calendarEnd = &calendar.EventDateTime{ + DateTime: eventEnd.Format(time.RFC3339), + } + } else { + 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 +104,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 +117,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 +140,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 +164,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..5ac5edd --- /dev/null +++ b/core/calendar_utils.go @@ -0,0 +1,81 @@ +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, 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) + + 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, 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") + } + + 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/calendar_utils_test.go b/core/calendar_utils_test.go new file mode 100644 index 0000000..91494f1 --- /dev/null +++ b/core/calendar_utils_test.go @@ -0,0 +1,266 @@ +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, 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) { + 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) + }) + } + 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) { + 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) + }) + } +} diff --git a/core/clockify.go b/core/clockify.go index 03e06bd..691fe7b 100644 --- a/core/clockify.go +++ b/core/clockify.go @@ -10,6 +10,13 @@ import ( "time" ) +type ClockifyHalfDayPeriod string + +const ( + ClockifyStatusApproved = "APPROVED" + ClockifyStatusRejected = "REJECTED" +) + type ClockifyEnvelope struct { Requests []ClockifyRequest `json:"requests"` } @@ -23,10 +30,13 @@ 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"` + + // 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 { @@ -35,12 +45,15 @@ type ClockifyRequest struct { } `json:"status"` } -const ( - ClockifyStatusApproved = "APPROVED" - ClockifyStatusRejected = "REJECTED" -) +// 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"` 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,