From 4ea2bb2e78574607fcb1f22bf2af18eaa77f53c0 Mon Sep 17 00:00:00 2001 From: Tamara deMent Date: Fri, 31 Jul 2026 16:40:01 -0400 Subject: [PATCH 1/4] Filter requests by creation or status change activity --- cmd/sync_ooo_to_gcal/main.go | 2 +- core/clockify.go | 1 + core/clockify_fixtures_test.go | 6 +- core/filter.go | 88 ++++++++++++++----------- core/filter_test.go | 114 ++++++++++++++++++++++----------- 5 files changed, 131 insertions(+), 80 deletions(-) diff --git a/cmd/sync_ooo_to_gcal/main.go b/cmd/sync_ooo_to_gcal/main.go index 757f5f8..2465113 100644 --- a/cmd/sync_ooo_to_gcal/main.go +++ b/cmd/sync_ooo_to_gcal/main.go @@ -151,7 +151,7 @@ func (e *Event) Run(ctx context.Context) { return } - env, err := core.FilterByCreatedAt(respBytes, createdStartT, createdEndT) + env, err := core.FilterByActivity(respBytes, createdStartT, createdEndT) if err != nil { core.Die("filter: %v", err) } diff --git a/core/clockify.go b/core/clockify.go index 45fe76a..2d22e30 100644 --- a/core/clockify.go +++ b/core/clockify.go @@ -30,6 +30,7 @@ type ClockifyRequest struct { Status struct { StatusType string `json:"statusType"` + ChangedAt string `json:"changedAt"` } `json:"status"` } diff --git a/core/clockify_fixtures_test.go b/core/clockify_fixtures_test.go index e378419..c604357 100644 --- a/core/clockify_fixtures_test.go +++ b/core/clockify_fixtures_test.go @@ -3,16 +3,17 @@ package core import "time" func makeRequest(id, tz, start, end string) ClockifyRequest { - return makeRequestWithCreatedAt( + return makeRequestWithActivityTimestamps( id, tz, start, end, time.Date(2025, 12, 1, 12, 0, 0, 0, time.UTC), + time.Date(2025, 12, 1, 12, 0, 0, 0, time.UTC), ) } -func makeRequestWithCreatedAt(id, tz, start, end string, createdAt time.Time) ClockifyRequest { +func makeRequestWithActivityTimestamps(id, tz, start, end string, createdAt time.Time, statusChangedAt time.Time) ClockifyRequest { var r ClockifyRequest r.ID = id @@ -23,6 +24,7 @@ func makeRequestWithCreatedAt(id, tz, start, end string, createdAt time.Time) Cl r.TimeOffPeriod.Period.Start = start r.TimeOffPeriod.Period.End = end + r.Status.ChangedAt = statusChangedAt.Format(time.RFC3339) return r } diff --git a/core/filter.go b/core/filter.go index 0beb2f2..8efb74c 100644 --- a/core/filter.go +++ b/core/filter.go @@ -11,8 +11,11 @@ type rawClockifyEnvelope struct { Requests []json.RawMessage `json:"requests"` } -type createdOnly struct { +type requestTimestamps struct { CreatedAt string `json:"createdAt"` + Status struct { + ChangedAt string `json:"changedAt"` + } `json:"status"` } func ParseRawClockifyEnvelope(respBytes []byte) (rawClockifyEnvelope, error) { @@ -23,65 +26,74 @@ func ParseRawClockifyEnvelope(respBytes []byte) (rawClockifyEnvelope, error) { return env, nil } -// Filters raw request payloads by createdAt in [start, end]. -func FilterRawRequestsByCreatedAt( - rawRequests []json.RawMessage, - start, end time.Time, -) []json.RawMessage { - - filtered := make([]json.RawMessage, 0, len(rawRequests)) +// ParseClockifyRequests converts valid raw request payloads into ClockifyRequest structs and skips malformed JSON entries, logging each unmarshal error via log.Printf. +func ParseClockifyRequests(rawRequests []json.RawMessage) []ClockifyRequest { + requests := make([]ClockifyRequest, 0, len(rawRequests)) for _, raw := range rawRequests { - var c createdOnly - if err := json.Unmarshal(raw, &c); err != nil { - continue // skip if no createdAt - } - - ct, err := ParseFlexibleRFC3339(c.CreatedAt) - if err != nil { + var r ClockifyRequest + if err := json.Unmarshal(raw, &r); err != nil { + log.Printf("skipping bad request: %v", err) continue } - ct = ct.UTC() - if ct.Before(start) { - continue - } - if !ct.Before(end) { // exclusive - continue - } + requests = append(requests, r) + } + + return requests +} - filtered = append(filtered, raw) +func isTimestampInWindow( + value string, + start, end time.Time, +) bool { + timestamp, err := ParseFlexibleRFC3339(value) + if err != nil { + return false } - return filtered + + timestamp = timestamp.UTC() + + return !timestamp.Before(start) && timestamp.Before(end) } -// ParseClockifyRequests converts valid raw request payloads into ClockifyRequest structs and skips malformed JSON entries, logging each unmarshal error via log.Printf. -func ParseClockifyRequests(rawRequests []json.RawMessage) []ClockifyRequest { - requests := make([]ClockifyRequest, 0, len(rawRequests)) +// Filters raw requests that were created or had their status updated in [start, end). +func FilterRawRequestsByActivity( + rawRequests []json.RawMessage, + start, end time.Time, +) []json.RawMessage { + filtered := make([]json.RawMessage, 0, len(rawRequests)) for _, raw := range rawRequests { - var r ClockifyRequest - if err := json.Unmarshal(raw, &r); err != nil { - log.Printf("skipping bad request: %v", err) + var timestamps requestTimestamps + if err := json.Unmarshal(raw, ×tamps); err != nil { continue } - requests = append(requests, r) + createdInWindow := isTimestampInWindow(timestamps.CreatedAt, start, end) + + statusUpdatedInWindow := isTimestampInWindow(timestamps.Status.ChangedAt, start, end) + + if createdInWindow || statusUpdatedInWindow { + filtered = append(filtered, raw) + } } - return requests + return filtered } -// Filter a raw Clockify response for out-of-office requests that were created within the given time span. -func FilterByCreatedAt(respBytes []byte, createdStart, createdEnd time.Time) (ClockifyEnvelope, error) { +// FilterByActivity returns Clockify requests that were created or had their +// status updated within the given time span. +func FilterByActivity( + respBytes []byte, + start, end time.Time, +) (ClockifyEnvelope, error) { rawEnv, err := ParseRawClockifyEnvelope(respBytes) if err != nil { return ClockifyEnvelope{}, err } - filtered := FilterRawRequestsByCreatedAt(rawEnv.Requests, createdStart, createdEnd) + filtered := FilterRawRequestsByActivity(rawEnv.Requests, start, end) - return ClockifyEnvelope{ - Requests: ParseClockifyRequests(filtered), - }, nil + return ClockifyEnvelope{Requests: ParseClockifyRequests(filtered)}, nil } diff --git a/core/filter_test.go b/core/filter_test.go index cddcac4..92d6ae6 100644 --- a/core/filter_test.go +++ b/core/filter_test.go @@ -38,55 +38,85 @@ func mustRawMessage(t *testing.T, v any) json.RawMessage { return json.RawMessage(b) } -func TestFilterRawRequestsByCreatedAt(t *testing.T) { +func TestFilterRawRequestsByActivity(t *testing.T) { rangeStart := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) rangeEnd := time.Date(2025, 12, 3, 0, 0, 0, 0, time.UTC) + timeoffStart := "2025-12-10T00:00:00Z" timeoffEnd := "2025-12-10T23:59:59Z" timeZone := "America/New_York" - beforeStart := mustRawMessage(t, makeRequestWithCreatedAt( - "before-start", - timeZone, - timeoffStart, - timeoffEnd, - time.Date(2025, 11, 30, 23, 59, 59, 0, time.UTC), - )) - - atStart := mustRawMessage(t, makeRequestWithCreatedAt( - "at-start", - timeZone, - timeoffStart, - timeoffEnd, - time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC), - )) - - withinRange := mustRawMessage(t, makeRequestWithCreatedAt( - "within-range", - timeZone, - timeoffStart, - timeoffEnd, - time.Date(2025, 12, 2, 12, 0, 0, 0, time.UTC), - )) - - atEnd := mustRawMessage(t, makeRequestWithCreatedAt( - "at-end", - timeZone, - timeoffStart, - timeoffEnd, - time.Date(2025, 12, 3, 0, 0, 0, 0, time.UTC), - )) + beforeRange := time.Date(2025, 11, 30, 23, 59, 59, 0, time.UTC) + atStart := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) + withinRange := time.Date(2025, 12, 2, 12, 0, 0, 0, time.UTC) + atEnd := time.Date(2025, 12, 3, 0, 0, 0, 0, time.UTC) rawRequests := []json.RawMessage{ - beforeStart, - atStart, - withinRange, - atEnd, + mustRawMessage(t, makeRequestWithActivityTimestamps( + "created-before-status-before", + timeZone, + timeoffStart, + timeoffEnd, + beforeRange, + beforeRange, + )), + mustRawMessage(t, makeRequestWithActivityTimestamps( + "created-at-start", + timeZone, + timeoffStart, + timeoffEnd, + atStart, + beforeRange, + )), + mustRawMessage(t, makeRequestWithActivityTimestamps( + "status-at-start", + timeZone, + timeoffStart, + timeoffEnd, + beforeRange, + atStart, + )), + mustRawMessage(t, makeRequestWithActivityTimestamps( + "created-within-range", + timeZone, + timeoffStart, + timeoffEnd, + withinRange, + beforeRange, + )), + mustRawMessage(t, makeRequestWithActivityTimestamps( + "status-within-range", + timeZone, + timeoffStart, + timeoffEnd, + beforeRange, + withinRange, + )), + mustRawMessage(t, makeRequestWithActivityTimestamps( + "created-at-end", + timeZone, + timeoffStart, + timeoffEnd, + atEnd, + beforeRange, + )), + mustRawMessage(t, makeRequestWithActivityTimestamps( + "status-at-end", + timeZone, + timeoffStart, + timeoffEnd, + beforeRange, + atEnd, + )), } - got := FilterRawRequestsByCreatedAt(rawRequests, rangeStart, rangeEnd) + got := FilterRawRequestsByActivity( + rawRequests, + rangeStart, + rangeEnd, + ) - require.Len(t, got, 2) + require.Len(t, got, 4) var gotIDs []string for _, raw := range got { @@ -95,7 +125,13 @@ func TestFilterRawRequestsByCreatedAt(t *testing.T) { gotIDs = append(gotIDs, r.ID) } - wantIDs := []string{"at-start", "within-range"} + wantIDs := []string{ + "created-at-start", + "created-within-range", + "status-at-start", + "status-within-range", + } + assert.Equal(t, wantIDs, gotIDs) } From 669c0664144ea1b9a5358e0e7e0b3f9016ba22b6 Mon Sep 17 00:00:00 2001 From: Tamara deMent Date: Fri, 31 Jul 2026 16:54:46 -0400 Subject: [PATCH 2/4] Adds a todo --- cmd/sync_ooo_to_gcal/main.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/sync_ooo_to_gcal/main.go b/cmd/sync_ooo_to_gcal/main.go index 2465113..3f8797d 100644 --- a/cmd/sync_ooo_to_gcal/main.go +++ b/cmd/sync_ooo_to_gcal/main.go @@ -151,6 +151,8 @@ func (e *Event) Run(ctx context.Context) { return } + // TODO: Revisit the naming of the time window variables now that filtering + // includes both request creation and status changes. env, err := core.FilterByActivity(respBytes, createdStartT, createdEndT) if err != nil { core.Die("filter: %v", err) From 9016c6c6a43e4b3c45d3ee5319e8c1fbdcc80841 Mon Sep 17 00:00:00 2001 From: Tamara deMent Date: Fri, 31 Jul 2026 17:01:49 -0400 Subject: [PATCH 3/4] Fix test --- core/filter_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/filter_test.go b/core/filter_test.go index 92d6ae6..28dc44b 100644 --- a/core/filter_test.go +++ b/core/filter_test.go @@ -127,8 +127,8 @@ func TestFilterRawRequestsByActivity(t *testing.T) { wantIDs := []string{ "created-at-start", - "created-within-range", "status-at-start", + "created-within-range", "status-within-range", } From c72f378cbe77e352498c5849dbeec6f5c850cc63 Mon Sep 17 00:00:00 2001 From: Tamara deMent Date: Sat, 1 Aug 2026 14:21:35 -0400 Subject: [PATCH 4/4] Add test for null status.changedAt handling --- core/filter_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/core/filter_test.go b/core/filter_test.go index 28dc44b..2206975 100644 --- a/core/filter_test.go +++ b/core/filter_test.go @@ -135,6 +135,33 @@ func TestFilterRawRequestsByActivity(t *testing.T) { assert.Equal(t, wantIDs, gotIDs) } +func TestFilterRawRequestsByActivity_AllowsNullStatusChangedAt(t *testing.T) { + rangeStart := time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC) + rangeEnd := time.Date(2025, 12, 3, 0, 0, 0, 0, time.UTC) + + rawRequest := json.RawMessage(`{ + "id": "status-null-created-in-range", + "createdAt": "2025-12-02T12:00:00Z", + "status": { + "changedAt": null, + "statusType": "APPROVED" + } + }`) + + got := FilterRawRequestsByActivity( + []json.RawMessage{rawRequest}, + rangeStart, + rangeEnd, + ) + + require.Len(t, got, 1) + + var request ClockifyRequest + require.NoError(t, json.Unmarshal(got[0], &request)) + + assert.Equal(t, "status-null-created-in-range", request.ID) +} + func TestParseClockifyRequests(t *testing.T) { timeoffStart := "2025-12-10T00:00:00Z" timeoffEnd := "2025-12-10T23:59:59Z"