From ddbdba233d0a2c87787126974d6216c8391e2375 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 7 Oct 2026 20:11:21 +0200 Subject: [PATCH] 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). --- boundary.go | 12 ++++++++---- boundary_unit_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/boundary.go b/boundary.go index fa820f8..75342c8 100644 --- a/boundary.go +++ b/boundary.go @@ -153,8 +153,10 @@ func (c *Carbon) StartOfMinute() *Carbon { if c.IsInvalid() { return c } - year, month, day, hour, minute, _ := c.DateTime() - return c.create(year, month, day, hour, minute, MinSecond, MinNanosecond) + elapsed := time.Duration(c.Second())*time.Second + time.Duration(c.Nanosecond()) + result := c.Copy() + result.time = result.time.Add(-elapsed) + return result } // EndOfMinute returns a Carbon instance for end of the minute. @@ -162,8 +164,10 @@ func (c *Carbon) EndOfMinute() *Carbon { if c.IsInvalid() { return c } - year, month, day, hour, minute, _ := c.DateTime() - return c.create(year, month, day, hour, minute, MaxSecond, MaxNanosecond) + elapsed := time.Duration(c.Second())*time.Second + time.Duration(c.Nanosecond()) + 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. diff --git a/boundary_unit_test.go b/boundary_unit_test.go index 35c8c3d..217021b 100644 --- a/boundary_unit_test.go +++ b/boundary_unit_test.go @@ -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() { s.Run("nil carbon", func() { var c *Carbon