diff --git a/docs/static/llms.txt b/docs/static/llms.txt index fcadb6e..1177e56 100644 --- a/docs/static/llms.txt +++ b/docs/static/llms.txt @@ -202,6 +202,7 @@ observable.Subscribe(ro.OnNext(func(s string) { - **bytes** - Byte slice manipulation operators - **strings** - String manipulation operators (Capitalize, CamelCase, SnakeCase, etc.) - **sort** - Sorting operators +- **time** - Time manipulation ### Encoding & Serialization - **encoding/json** - JSON marshaling and unmarshaling diff --git a/go.mod b/go.mod index a5f1aae..745b354 100644 --- a/go.mod +++ b/go.mod @@ -17,4 +17,4 @@ require ( golang.org/x/text v0.22.0 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/yaml.v3 v3.0.1 // indirect -) +) \ No newline at end of file diff --git a/go.work b/go.work index e30263e..5752a2f 100644 --- a/go.work +++ b/go.work @@ -52,6 +52,7 @@ use ( ./plugins/strings ./plugins/template ./plugins/testify + ./plugins/time ./plugins/websocket/client ) diff --git a/plugins/time/README.md b/plugins/time/README.md new file mode 100644 index 0000000..fe20f06 --- /dev/null +++ b/plugins/time/README.md @@ -0,0 +1,170 @@ +# Time Plugin + +The time plugin provides operators for manipulating dates/time object in reactive streams. + +## Installation + +```bash +go get github.com/samber/ro/plugins/time +``` + +## Operators + +### Add + +Add a duration to a date + +```go +import ( + "github.com/samber/ro" + rotime "github.com/samber/ro/plugins/time" +) + +observable := ro.Pipe1( + ro.Just( + time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + time.Date(0, time.January, 1, 23, 59, 59, 0, time.UTC), + ), + rotime.Add(2 * time.Hour), +) + +// Output: +// Next: time.Date(2026, time.January, 7, 16, 30, 0, 0, time.UTC) +// Next: time.Date(0, time.January, 2, 1, 59, 59, 0, time.UTC) +// Completed +``` + +### AddDate + +Add a duration defined by years, months, days to a date. + +```go +import ( + "github.com/samber/ro" + rotime "github.com/samber/ro/plugins/time" +) + +observable := ro.Pipe1( + ro.Just( + time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + ), + rotime.AddDate(0, 1, 0), +) + +// Output: +// Next: time.Date(2026, time.February, 7, 14, 30, 0, 0, time.UTC) +// Completed +``` + +### Format + +Transform an observable time.Time into a string, formatted according to the provided layout. + +```go +import ( + "github.com/samber/ro" + rotime "github.com/samber/ro/plugins/time" +) + +observable := ro.Pipe1( + ro.Just( + time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + ), + rotime.Format("2006-01-02 15:04:05"), +) + +// Output: +// Next: "2026-01-07 14:30:00" +// Completed +``` + +### In + +Transform an observable time.Time into a string, formatted according to the provided layout. + +```go +import ( + "github.com/samber/ro" + rotime "github.com/samber/ro/plugins/time" +) + +observable := ro.Pipe1( + ro.Just( + time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + ), + rotime.In(time.LoadLocation("Europe/Paris")), +) + +// Output: +// Next: time.Date(2026, time.January, 7, 16, 30, 0, 0, time.CET), +// Completed +``` + +### Parse + +Transform an observable string into an observable of time.Time. + +```go +import ( + "github.com/samber/ro" + rotime "github.com/samber/ro/plugins/time" +) + +observable := ro.Pipe1( + ro.Just( + "2026-01-07 14:30:00", + ), + rotime.Parse("2006-01-02 15:04:05"), +) + +// Output: +// Next: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC) +// Completed +``` + +### ParseInLocation + +Transform an observable string into an observable of time.Time, using location. + +```go +import ( + "github.com/samber/ro" + rotime "github.com/samber/ro/plugins/time" +) + +observable := ro.Pipe1( + ro.Just( + "2026-01-07 14:30:00", + ), + rotime.ParseInLocation("2006-01-02 15:04:05", time.UTC), +) + +// Output: +// Next: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC) +// Completed +``` + +### StartOfDay + +Truncates the time to the beginning of its day in the local time zone + +```go +import ( + "github.com/samber/ro" + rotime "github.com/samber/ro/plugins/time" +) + +observable := ro.Pipe1( + ro.Just( + time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + ), + rotime.StartOfDay(), +) + +// Output: +// Next: time.Date(2026, time.January, 7, 0, 0, 0, 0, time.UTC) +// Completed +``` + +## Performance Considerations +- The time plugin uses Go's standard `time` package for operations \ No newline at end of file diff --git a/plugins/time/go.mod b/plugins/time/go.mod new file mode 100644 index 0000000..0e84537 --- /dev/null +++ b/plugins/time/go.mod @@ -0,0 +1,17 @@ +module github.com/samber/ro/plugins/time + +go 1.18 + +require ( + github.com/samber/ro v0.2.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/samber/lo v1.52.0 // indirect + golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect + golang.org/x/text v0.22.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugins/time/go.sum b/plugins/time/go.sum new file mode 100644 index 0000000..c19d0c7 --- /dev/null +++ b/plugins/time/go.sum @@ -0,0 +1,20 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw= +github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/samber/ro v0.2.0 h1:pwhLFGZprz+3KyE3JIUnv8GGF/ADpIpvFUEDxuzfGbY= +github.com/samber/ro v0.2.0/go.mod h1:eInj5R1BbXfGoT1ef0HIO5Qie0wlPkkyL0koOaEmfNM= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/time/operator_add.go b/plugins/time/operator_add.go new file mode 100644 index 0000000..1e58db5 --- /dev/null +++ b/plugins/time/operator_add.go @@ -0,0 +1,57 @@ +// Copyright 2025 samber. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/samber/ro/blob/main/licenses/LICENSE.apache.md +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rotime + +import ( + "time" + + "github.com/samber/ro" +) + +// Add returns an operator that adds a fixed duration to each time value. +// +// Example: +// +// obs := ro.Pipe1( +// ro.Just(time.Now()), +// rotime.Add(2*time.Hour), +// ) +// +// The observable then emits: time.Now().Add(2 * time.Hour). +func Add(d time.Duration) func(destination ro.Observable[time.Time]) ro.Observable[time.Time] { + return ro.Map( + func(value time.Time) time.Time { + return value.Add(d) + }, + ) +} + +// AddDate returns an operator that adds a date offset (years, months, days) to each time value. +// +// Example: +// +// obs := ro.Pipe1( +// ro.Just(time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC)), +// rotime.AddDate(0, 1, 0), +// ) +// +// The observable then emits: time.Date(2026, time.February, 7, 14, 30, 0, 0, time.UTC). +func AddDate(years int, months int, days int) func(destination ro.Observable[time.Time]) ro.Observable[time.Time] { + return ro.Map( + func(value time.Time) time.Time { + return value.AddDate(years, months, days) + }, + ) +} diff --git a/plugins/time/operator_add_test.go b/plugins/time/operator_add_test.go new file mode 100644 index 0000000..cebf1a7 --- /dev/null +++ b/plugins/time/operator_add_test.go @@ -0,0 +1,165 @@ +package rotime + +import ( + "testing" + "time" + + "github.com/samber/ro" + "github.com/stretchr/testify/assert" +) + +type timeAddTest struct { + input time.Time + duration time.Duration + expected time.Time +} + +var addTests = []timeAddTest{ + { + input: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + duration: 2 * time.Hour, + expected: time.Date(2026, time.January, 7, 16, 30, 0, 0, time.UTC), + }, + { + input: time.Date(2025, time.December, 25, 0, 0, 0, 0, time.UTC), + duration: -24 * time.Hour, + expected: time.Date(2025, time.December, 24, 0, 0, 0, 0, time.UTC), + }, + { + input: time.Date(0, time.January, 1, 23, 59, 59, 0, time.UTC), + duration: time.Second, + expected: time.Date(0, time.January, 1, 23, 59, 59+1, 0, time.UTC), + }, + { + input: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + duration: 0, + expected: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + }, +} + +type timeAddDateTest struct { + input time.Time + years int + months int + days int + expected time.Time +} + +var addDateTests = []timeAddDateTest{ + { + input: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + years: 0, + months: 1, + days: 0, + expected: time.Date(2026, time.February, 7, 14, 30, 0, 0, time.UTC), + }, + { + input: time.Date(2025, time.December, 25, 0, 0, 0, 0, time.UTC), + years: -1, + months: 0, + days: 0, + expected: time.Date(2024, time.December, 25, 0, 0, 0, 0, time.UTC), + }, + { + input: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + years: 0, + months: 0, + days: 5, + expected: time.Date(2026, time.January, 12, 14, 30, 0, 0, time.UTC), + }, + { + input: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + years: 0, + months: 0, + days: 0, + expected: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + }, +} + +func TestAdd(t *testing.T) { + t.Run("Test Simple cases", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + for _, tt := range addTests { + values, err := ro.Collect( + ro.Pipe1( + ro.Just(tt.input), + Add(tt.duration), + ), + ) + is.Nil(err) + is.Equal([]time.Time{tt.expected}, values) + } + }) + + t.Run("Test empty observable case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Empty[time.Time](), + Add(2*time.Hour), + ), + ) + is.Nil(err) + is.Equal([]time.Time{}, values) + }) + + t.Run("Test error handling case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Throw[time.Time](assert.AnError), + Add(2*time.Hour), + ), + ) + is.Equal([]time.Time{}, values) + is.EqualError(err, assert.AnError.Error()) + }) +} + +func TestAddDate(t *testing.T) { + t.Run("Test Simple cases", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + for _, tt := range addDateTests { + values, err := ro.Collect( + ro.Pipe1( + ro.Just(tt.input), + AddDate(tt.years, tt.months, tt.days), + ), + ) + is.Nil(err) + is.Equal([]time.Time{tt.expected}, values) + } + }) + + t.Run("Test empty observable case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Empty[time.Time](), + AddDate(0, 0, 1), + ), + ) + is.Nil(err) + is.Equal([]time.Time{}, values) + }) + + t.Run("Test error handling case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Throw[time.Time](assert.AnError), + AddDate(0, 0, 1), + ), + ) + is.Equal([]time.Time{}, values) + is.EqualError(err, assert.AnError.Error()) + }) +} diff --git a/plugins/time/operator_format.go b/plugins/time/operator_format.go new file mode 100644 index 0000000..497fc69 --- /dev/null +++ b/plugins/time/operator_format.go @@ -0,0 +1,39 @@ +// Copyright 2025 samber. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/samber/ro/blob/main/licenses/LICENSE.apache.md +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rotime + +import ( + "time" + + "github.com/samber/ro" +) + +// Format returns an operator that formats each time value using the given layout. +// +// Example: +// +// obs := ro.Pipe1( +// ro.Just(time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC)), +// rotime.Format("2006-01-02 15:04:05"), +// ) +// +// The observable then emits: "2026-01-07 14:30:00". +func Format(format string) func(destination ro.Observable[time.Time]) ro.Observable[string] { + return ro.Map( + func(value time.Time) string { + return value.Format(format) + }, + ) +} diff --git a/plugins/time/operator_format_test.go b/plugins/time/operator_format_test.go new file mode 100644 index 0000000..a243e96 --- /dev/null +++ b/plugins/time/operator_format_test.go @@ -0,0 +1,91 @@ +// Copyright 2025 samber. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/samber/ro/blob/main/licenses/LICENSE.apache.md +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rotime + +import ( + "testing" + "time" + + "github.com/samber/ro" + "github.com/stretchr/testify/assert" +) + +type timeFormatTest struct { + input time.Time + format string + expected string +} + +var allTimeFormatTests = []timeFormatTest{ + { + input: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + format: "2006-01-02 15:04:05", + expected: "2026-01-07 14:30:00", + }, + { + input: time.Date(2025, time.December, 25, 0, 0, 0, 0, time.UTC), + format: "2006/01/02", + expected: "2025/12/25", + }, + { + input: time.Date(2024, time.November, 1, 23, 59, 59, 0, time.UTC), + format: "15:04:05", + expected: "23:59:59", + }, +} + +func TestFormat(t *testing.T) { + t.Run("Simple test cases", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + for _, tt := range allTimeFormatTests { + values, err := ro.Collect( + ro.Pipe1( + ro.Just(tt.input), + Format(tt.format), + ), + ) + is.Equal([]string{tt.expected}, values) + is.Nil(err) + } + }) + + t.Run("Test empty observable", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Empty[time.Time](), + Format("2006-01-02 15:04:05"), + ), + ) + is.Equal([]string{}, values) + is.Nil(err) + }) + + t.Run("Test error handling", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Throw[time.Time](assert.AnError), + Format("2006-01-02 15:04:05"), + ), + ) + is.Equal([]string{}, values) + is.EqualError(err, assert.AnError.Error()) + }) + +} diff --git a/plugins/time/operator_in_time_zone.go b/plugins/time/operator_in_time_zone.go new file mode 100644 index 0000000..3c4a7ba --- /dev/null +++ b/plugins/time/operator_in_time_zone.go @@ -0,0 +1,41 @@ +// Copyright 2025 samber. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/samber/ro/blob/main/licenses/LICENSE.apache.md +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rotime + +import ( + "time" + + "github.com/samber/ro" +) + +// In returns an operator that converts each time value to the given location. +// +// Example: +// +// loc, _ := time.LoadLocation("Europe/Paris") +// +// obs := ro.Pipe1( +// ro.Just(time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC)), +// rotime.In(loc), +// ) +// +// The observable then emits: time.Date(2026, time.January, 7, 15, 30, 0, 0, loc). +func In(loc *time.Location) func(destination ro.Observable[time.Time]) ro.Observable[time.Time] { + return ro.Map( + func(value time.Time) time.Time { + return value.In(loc) + }, + ) +} diff --git a/plugins/time/operator_in_time_zone_test.go b/plugins/time/operator_in_time_zone_test.go new file mode 100644 index 0000000..c6db992 --- /dev/null +++ b/plugins/time/operator_in_time_zone_test.go @@ -0,0 +1,80 @@ +// Copyright 2025 samber. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/samber/ro/blob/main/licenses/LICENSE.apache.md +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rotime + +import ( + "testing" + "time" + + "github.com/samber/ro" + "github.com/stretchr/testify/assert" +) + +func TestInTimeZone_SimpleConversion(t *testing.T) { + t.Run("Simple conversion test cases", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + utc := time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC) + paris, _ := time.LoadLocation("Europe/Paris") + + values, err := ro.Collect( + ro.Pipe1( + ro.Just(utc), + In(paris), + ), + ) + is.NoError(err) + + got := values[0] + + // Same instant. + is.True(utc.Equal(got)) + + // Different location (zone name / offset). + name, _ := got.Zone() + is.Equal("CET", name) + }) + + t.Run("Test Empty observable case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + values, err := ro.Collect( + ro.Pipe1( + ro.Empty[time.Time](), + In(time.UTC), + ), + ) + + is.NoError(err) + is.Equal([]time.Time{}, values) + }) + + t.Run("Test error handling case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + values, err := ro.Collect( + ro.Pipe1( + ro.Throw[time.Time](assert.AnError), + In(time.UTC), + ), + ) + + is.Equal([]time.Time{}, values) + is.EqualError(err, assert.AnError.Error()) + }) +} diff --git a/plugins/time/operator_parse.go b/plugins/time/operator_parse.go new file mode 100644 index 0000000..8e2a7d6 --- /dev/null +++ b/plugins/time/operator_parse.go @@ -0,0 +1,57 @@ +// Copyright 2025 samber. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/samber/ro/blob/main/licenses/LICENSE.apache.md +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rotime + +import ( + "time" + + "github.com/samber/ro" +) + +// Parse returns an operator that parses time strings using the given layout. +// +// Example: +// +// obs := ro.Pipe[string, time.Time]( +// ro.Just("2026-01-07 14:30:00"), +// rotime.Parse("2006-01-02 15:04:05"), +// ) +// +// The observable then emits: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC). +func Parse[T ~string](layout string) func(ro.Observable[T]) ro.Observable[time.Time] { + return ro.MapErr( + func(value T) (time.Time, error) { + return time.Parse(layout, string(value)) + }, + ) +} + +// ParseInLocation returns an operator that parses time strings in the given location. +// +// Example: +// +// obs := ro.Pipe[string, time.Time]( +// ro.Just("2026-01-07 14:30:00"), +// rotime.ParseInLocation("2006-01-02 15:04:05", time.UTC), +// ) +// +// The observable then emits: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC). +func ParseInLocation[T ~string](layout string, loc *time.Location) func(ro.Observable[T]) ro.Observable[time.Time] { + return ro.MapErr( + func(value T) (time.Time, error) { + return time.ParseInLocation(layout, string(value), loc) + }, + ) +} diff --git a/plugins/time/operator_parse_test.go b/plugins/time/operator_parse_test.go new file mode 100644 index 0000000..fbd609e --- /dev/null +++ b/plugins/time/operator_parse_test.go @@ -0,0 +1,171 @@ +// Copyright 2025 samber. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/samber/ro/blob/main/licenses/LICENSE.apache.md +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rotime + +import ( + "testing" + "time" + + "github.com/samber/ro" + "github.com/stretchr/testify/assert" +) + +type timeParseTest struct { + input string + format string + expected time.Time + loc *time.Location +} + +var parseTests = []timeParseTest{ + { + expected: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + format: "2006-01-02 15:04:05", + input: "2026-01-07 14:30:00", + }, + { + expected: time.Date(2025, time.December, 25, 0, 0, 0, 0, time.UTC), + format: "2006/01/02", + input: "2025/12/25", + }, + { + expected: time.Date(0, time.January, 1, 23, 59, 59, 0, time.UTC), + format: "15:04:05", + input: "23:59:59", + }, +} + +var parseInLocationTests = []timeParseTest{ + { + expected: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + format: "2006-01-02 15:04:05", + input: "2026-01-07 14:30:00", + loc: time.UTC, + }, + { + expected: time.Date(2025, time.December, 25, 0, 0, 0, 0, time.UTC), + format: "2006/01/02", + input: "2025/12/25", + loc: time.UTC, + }, + { + expected: time.Date(0, time.January, 1, 23, 59, 59, 0, time.UTC), + format: "15:04:05", + input: "23:59:59", + loc: time.UTC, + }, + { + input: "2026-01-07 14:30:00", + format: "2006-01-02 15:04:05", + loc: time.FixedZone("GMT+2", 2*60*60), + expected: time.Date( + 2026, time.January, 7, 14, 30, 0, 0, time.FixedZone("GMT+2", 2*60*60), + ), + }, +} + +func TestParse(t *testing.T) { + t.Run("Test Simple cases", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + for _, tt := range parseTests { + values, err := ro.Collect( + ro.Pipe1( + ro.Just(tt.input), + Parse[string](tt.format), + ), + ) + is.Equal([]time.Time{tt.expected}, values) + if tt.input == "not-a-date" { + is.Error(err) + } else { + is.NoError(err) + } + } + }) + + t.Run("Test empty obsersable case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Empty[string](), + Parse[string]("2006-01-02 15:04:05"), + ), + ) + is.Equal([]time.Time{}, values) + is.NoError(err) + }) + + t.Run("Test error handling case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Throw[string](assert.AnError), + Parse[string]("2006-01-02 15:04:05"), + ), + ) + is.Equal([]time.Time{}, values) + is.EqualError(err, assert.AnError.Error()) + }) +} + +func TestParseInLocation(t *testing.T) { + t.Run("Test Simple cases", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + for _, tt := range parseInLocationTests { + values, err := ro.Collect( + ro.Pipe1( + ro.Just(tt.input), + ParseInLocation[string](tt.format, tt.loc), + ), + ) + + is.Nil(err) + is.Equal([]time.Time{tt.expected}, values) + } + }) + t.Run("Test empty obsersable case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Empty[string](), + ParseInLocation[string]("2006-01-02 15:04:05", time.UTC), + ), + ) + + is.Nil(err) + is.Equal([]time.Time{}, values) + }) + + t.Run("Test error handling case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Throw[string](assert.AnError), + ParseInLocation[string]("2006-01-02 15:04:05", time.UTC), + ), + ) + + is.Equal([]time.Time{}, values) + is.EqualError(err, assert.AnError.Error()) + }) +} diff --git a/plugins/time/operator_start_of_day.go b/plugins/time/operator_start_of_day.go new file mode 100644 index 0000000..e07222e --- /dev/null +++ b/plugins/time/operator_start_of_day.go @@ -0,0 +1,40 @@ +// Copyright 2025 samber. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/samber/ro/blob/main/licenses/LICENSE.apache.md +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package rotime + +import ( + "time" + + "github.com/samber/ro" +) + +// StartOfDay returns an operator that truncates each time value to the start of its day. +// +// Example: +// +// obs := ro.Pipe1( +// ro.Just(time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC)), +// rotime.StartOfDay(), +// ) +// +// The observable then emits: time.Date(2026, time.January, 7, 0, 0, 0, 0, time.UTC). +func StartOfDay() func(ro.Observable[time.Time]) ro.Observable[time.Time] { + return ro.Map( + func(value time.Time) time.Time { + year, month, day := value.Date() + return time.Date(year, month, day, 0, 0, 0, 0, value.Location()) + }, + ) +} diff --git a/plugins/time/operator_start_of_day_test.go b/plugins/time/operator_start_of_day_test.go new file mode 100644 index 0000000..8603d97 --- /dev/null +++ b/plugins/time/operator_start_of_day_test.go @@ -0,0 +1,90 @@ +// Copyright 2025 samber. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/samber/ro/blob/main/licenses/LICENSE.apache.md +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package rotime + +import ( + "testing" + "time" + + "github.com/samber/ro" + "github.com/stretchr/testify/assert" +) + +type timeTruncateTest struct { + input time.Time + expected time.Time +} + +var truncateDayTests = []timeTruncateTest{ + { + input: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.UTC), + expected: time.Date(2026, time.January, 7, 0, 0, 0, 0, time.UTC), + }, + { + input: time.Date(2025, time.December, 25, 23, 59, 59, 0, time.UTC), + expected: time.Date(2025, time.December, 25, 0, 0, 0, 0, time.UTC), + }, + { + input: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + expected: time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC), + }, + { + input: time.Date(2026, time.January, 7, 14, 30, 0, 0, time.FixedZone("CET", 1*60*60)), + expected: time.Date(2026, time.January, 7, 0, 0, 0, 0, time.FixedZone("CET", 1*60*60)), + }, +} + +func TestStartOfDay(t *testing.T) { + t.Run("Test Simple cases", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + + for _, tt := range truncateDayTests { + values, err := ro.Collect( + ro.Pipe1( + ro.Just(tt.input), + StartOfDay(), + ), + ) + is.Nil(err) + is.Equal([]time.Time{tt.expected}, values) + } + }) + + t.Run("Test empty obsersable case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Empty[time.Time](), + StartOfDay(), + ), + ) + is.Equal([]time.Time{}, values) + is.Nil(err) + }) + + t.Run("Test error handling case", func(t *testing.T) { + t.Parallel() + is := assert.New(t) + values, err := ro.Collect( + ro.Pipe1( + ro.Throw[time.Time](assert.AnError), + StartOfDay(), + ), + ) + is.Equal([]time.Time{}, values) + is.EqualError(err, assert.AnError.Error()) + }) +}