fix(boundary): preserve instants at minute boundaries

same bug as #377, one level up: StartOfMinute/EndOfMinute rebuilt the time with time.Date, so in a repeated hour new york 01:34:49 EST got a minute ending before it. they now copy the instance and subtract the local seconds, keeping the instant, location and offset (not Truncate(time.Minute), which breaks lmt offsets with seconds).
This commit is contained in:
dualfroz committed 2026-10-07 20:11:21 +02:00
1 parent 3dbfef56fe
commit ddbdba233d
2 files changed
+41 -4

No files matched your search

+8 -4
View File
@@ -153,8 +153,10 @@ func (c *Carbon) StartOfMinute() *Carbon {
if c.IsInvalid() { if c.IsInvalid() {
return c return c
} }
year, month, day, hour, minute, _ := c.DateTime() elapsed := time.Duration(c.Second())*time.Second + time.Duration(c.Nanosecond())
return c.create(year, month, day, hour, minute, MinSecond, MinNanosecond) result := c.Copy()
result.time = result.time.Add(-elapsed)
return result
} }
// EndOfMinute returns a Carbon instance for end of the minute. // EndOfMinute returns a Carbon instance for end of the minute.
@@ -162,8 +164,10 @@ func (c *Carbon) EndOfMinute() *Carbon {
if c.IsInvalid() { if c.IsInvalid() {
return c return c
} }
year, month, day, hour, minute, _ := c.DateTime() elapsed := time.Duration(c.Second())*time.Second + time.Duration(c.Nanosecond())
return c.create(year, month, day, hour, minute, MaxSecond, MaxNanosecond) result := c.Copy()
result.time = result.time.Add(time.Minute - time.Nanosecond - elapsed)
return result
} }
// StartOfSecond returns a Carbon instance for start of the second. // StartOfSecond returns a Carbon instance for start of the second.
+33
View File
@@ -48,6 +48,39 @@ func (s *BoundarySuite) TestCarbon_SecondBoundariesAtOffsetTransitions() {
} }
} }
func (s *BoundarySuite) TestCarbon_MinuteBoundariesAtOffsetTransitions() {
for _, test := range []struct {
name string
zone string
inputUTC time.Time
}{
{"New York first occurrence", "America/New_York", time.Date(2026, 11, 1, 5, 34, 49, 123456789, time.UTC)},
{"New York second occurrence", "America/New_York", time.Date(2026, 11, 1, 6, 34, 49, 123456789, time.UTC)},
{"Lord Howe first occurrence", "Australia/Lord_Howe", time.Date(2026, 4, 4, 14, 45, 49, 123456789, time.UTC)},
{"Lord Howe second occurrence", "Australia/Lord_Howe", time.Date(2026, 4, 4, 15, 15, 49, 123456789, time.UTC)},
{"Monrovia second-based offset", "Africa/Monrovia", time.Date(1960, 6, 15, 12, 34, 49, 123456789, time.UTC)},
{"UTC control", "UTC", time.Date(2026, 1, 2, 12, 34, 49, 123456789, time.UTC)},
} {
s.Run(test.name, func() {
loc, err := time.LoadLocation(test.zone)
s.Require().NoError(err)
input := test.inputUTC.In(loc)
startUnix := input.Unix() - int64(input.Second())
c := NewCarbon(input).SetWeekStartsAt(Sunday)
start, end := c.StartOfMinute(), c.EndOfMinute()
s.Equal(startUnix, start.StdTime().Unix())
s.Equal(startUnix+MaxSecond, end.StdTime().Unix())
s.Equal(0, start.Nanosecond())
s.Equal(MaxNanosecond, end.Nanosecond())
s.Same(loc, start.StdTime().Location())
s.Same(loc, end.StdTime().Location())
s.Equal(Sunday, start.WeekStartsAt())
s.Equal(Sunday, end.WeekStartsAt())
s.Equal(input, c.StdTime())
})
}
}
func (s *BoundarySuite) TestCarbon_StartOfCentury() { func (s *BoundarySuite) TestCarbon_StartOfCentury() {
s.Run("nil carbon", func() { s.Run("nil carbon", func() {
var c *Carbon var c *Carbon