From a84cfde045ac46df567fa8105121731f5042e6ef Mon Sep 17 00:00:00 2001 From: Elijah Duffy Date: Thu, 11 Dec 2025 20:25:29 -0800 Subject: [PATCH] add ISODuration type for ISO8061 duration compatibility --- README.md | 18 ++++++++++ duration.go | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 11 ++++++ go.sum | 12 +++++++ 4 files changed, 140 insertions(+) create mode 100644 duration.go create mode 100644 go.sum diff --git a/README.md b/README.md index 3c91d33..040c5b9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,21 @@ # clocktime clocktime provides a type that holds a calendar-independent time of day value in 24-hour format. + +clocktime provides basic types that extend Go's built-in time.Time to provide time-of-day and duration support. + +## `clocktime.ClockTime + +Holds calendar-independent time-of-day in 24-hour format. + +- Converts to and from `HH:MM:SS` format (subset of ISO8061) +- Marshals to `[]byte` containing string `HH:MM:SS` for SQL +- Marshals to `string` in format `HH:MM:SS` for JSON & gqlgen + +## `clocktime.Duration` + +Wraps time.Duration with prioritized ISO8061 support and opinionated marshalling. + +- Converts to and from ISO8061 format (utilises [sosodev/duration](https://github.com/sosodev/duration)) +- Marshals to `uint64` with nanosecond precision. Largest representatable duration is about 290 years, limited by underlying time.Duration type. +- Marshals to `string` in ISO8061 format for JSON & gqlgen diff --git a/duration.go b/duration.go new file mode 100644 index 0000000..677e80a --- /dev/null +++ b/duration.go @@ -0,0 +1,99 @@ +package clocktime + +import ( + "encoding/json" + "fmt" + "io" + "time" + + "github.com/sosodev/duration" +) + +// ISODuration wraps a time.Duration to provide custom JSON and SQL +// serialization and full ISO8061 compatibility. Note: ISODuration is limited +// to the same range as time.Duration despite ISO8061 supporting larger durations. +type ISODuration time.Duration + +// NewISODuration creates a new duration from numeric components. +func NewISODuration(hours, minutes, seconds int) ISODuration { + return ISODuration( + time.Hour*time.Duration(hours) + + time.Minute*time.Duration(minutes) + + time.Second*time.Duration(seconds), + ) +} + +// DurationFromISOString parses an ISO8601 duration string (e.g., "PT1H30M45S") +// and returns an ISODuration. +func DurationFromISOString(s string) (ISODuration, error) { + d, err := duration.Parse(s) + if err != nil { + return 0, fmt.Errorf("error parsing ISO8061 duration: %w", err) + } + return ISODuration(d.ToTimeDuration()), nil +} + +// ISODurationFromDuration converts a time.Duration to an ISODuration. +func ISODurationFromDuration(d time.Duration) ISODuration { + return ISODuration(d) +} + +// String returns the ISO8601 string representation of the ISODuration. +func (d ISODuration) String() string { + td := time.Duration(d) + isoDur := duration.FromTimeDuration(td) + return isoDur.String() +} + +// Duration returns the time.Duration representation of the ISODuration. +func (d ISODuration) Duration() time.Duration { + return time.Duration(d) +} + +// MarshalJSON implements the json.Marshaler interface for ISODuration, +// serializing it as an ISO8601 duration string. +func (d ISODuration) MarshalJSON() ([]byte, error) { + return json.Marshal("\"" + d.String() + "\"") +} + +// UnmarshalJSON implements the json.Unmarshaler interface for ISODuration, +// parsing an ISO8601 duration string. +func (d *ISODuration) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + parsedDur, err := DurationFromISOString(s) + if err != nil { + return err + } + *d = parsedDur + return nil +} + +// MarshalGQL implements the graphql.Marshaler interface for ISODuration, +// serializing it as an ISO8601 duration string. +func (d ISODuration) MarshalGQL(w io.Writer) { + fmt.Fprint(w, "\""+d.String()+"\"") +} + +// UnmarshalGQL implements the graphql.Unmarshaler interface for ISODuration, +// parsing an ISO8601 duration string. nil values are treated as zero duration. +func (d *ISODuration) UnmarshalGQL(value any) error { + if value == nil { + *d = ISODuration(0) + return nil + } + + s, ok := value.(string) + if !ok { + return fmt.Errorf("ISODuration must be a string") + } + + parsedDur, err := DurationFromISOString(s) + if err != nil { + return err + } + *d = parsedDur + return nil +} diff --git a/go.mod b/go.mod index b74beee..cea7e29 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module gitea.auvem.com/go-toolkit/clocktime go 1.24.0 + +require ( + github.com/sosodev/duration v1.3.1 + 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 + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1cca06c --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=